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

Crosspost cog seems done

tags/1.0.1
Rocketsoup преди 4 години
родител
ревизия
876644fda9
променени са 3 файла, в които са добавени 229 реда и са изтрити 43 реда
  1. 43
    7
      cogs/basecog.py
  2. 184
    34
      cogs/crosspostcog.py
  3. 2
    2
      rocketbot.py

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

9
 class BaseCog(commands.Cog):
9
 class BaseCog(commands.Cog):
10
 	def __init__(self, bot):
10
 	def __init__(self, bot):
11
 		self.bot = bot
11
 		self.bot = bot
12
-		self.listened_mod_react_message_ids = AgeBoundDict(timedelta(minutes=5), lambda message_id, age : age)
12
+		self.listened_mod_react_message_ids = AgeBoundDict(timedelta(minutes=5), lambda message_id, tpl : tpl[0])
13
 
13
 
14
-	def listen_for_reactions_to(self, message: Message) -> None:
15
-		self.listened_mod_react_message_ids[message.id] = message.created_at
14
+	def listen_for_reactions_to(self, message: Message, context = None) -> None:
15
+		"""
16
+		Registers a warning message as something a mod may react to to enact
17
+		some action. `context` will be passed back in `on_mod_react` and can be
18
+		any value that helps give the cog context about the action being
19
+		performed.
20
+		"""
21
+		self.listened_mod_react_message_ids[message.id] = (message.created_at, context)
16
 
22
 
17
 	@commands.Cog.listener()
23
 	@commands.Cog.listener()
18
 	async def on_raw_reaction_add(self, payload: RawReactionActionEvent):
24
 	async def on_raw_reaction_add(self, payload: RawReactionActionEvent):
41
 		if not member.permissions_in(channel).ban_members:
47
 		if not member.permissions_in(channel).ban_members:
42
 			# Not a mod
48
 			# Not a mod
43
 			return
49
 			return
44
-		if self.listened_mod_react_message_ids.get(message.id) is None:
50
+		tpl = self.listened_mod_react_message_ids.get(message.id)
51
+		if tpl is None:
45
 			# Not a message we're listening for
52
 			# Not a message we're listening for
46
 			return
53
 			return
47
-		await self.on_mod_react(message, payload.emoji)
54
+		context = tpl[1]
55
+		await self.on_mod_react(message, payload.emoji, context)
48
 
56
 
49
-	async def on_mod_react(self, message: Message, emoji: PartialEmoji) -> None:
57
+	async def on_mod_react(self, message: Message, emoji: PartialEmoji, context) -> None:
50
 		"""
58
 		"""
51
 		Override point for getting a mod's emote on a bot message. Used to take
59
 		Override point for getting a mod's emote on a bot message. Used to take
52
 		action on a warning, such as banning an offending user. This event is
60
 		action on a warning, such as banning an offending user. This event is
53
 		only triggered for registered bot messages and reactions by members
61
 		only triggered for registered bot messages and reactions by members
54
-		with the proper permissions.
62
+		with the proper permissions. The given `context` value is whatever was
63
+		passed in `listen_to_reactions_to()`.
55
 		"""
64
 		"""
56
 		pass
65
 		pass
57
 
66
 
67
+	async def validate_param(self, context: commands.Context, param_name: str, value,
68
+		allowed_types: tuple = None,
69
+		min_value = None,
70
+		max_value = None) -> bool:
71
+		"""
72
+		Convenience method for validating a command parameter is of the expected
73
+		type and in the expected range. Bad values will cause a reply to be sent
74
+		to the original message and a False will be returned. If all checks
75
+		succeed, True will be returned.
76
+		"""
77
+		if allowed_types is not None and not isinstance(value, allowed_types):
78
+			if len(allowed_types) == 1:
79
+				await context.message.reply(f'⚠️ `{param_name}` must be of type ' +
80
+					f'{allowed_types[0]}.', mention_author=False)
81
+			else:
82
+				await context.message.reply(f'⚠️ `{param_name}` must be of types ' +
83
+					f'{allowed_types}.', mention_author=False)
84
+			return False
85
+		if min_value is not None and value < min_value:
86
+			await context.message.reply(f'⚠️ `{param_name}` must be >= {min_value}.',
87
+				mention_author=False)
88
+			return False
89
+		if max_value is not None and value > max_value:
90
+			await context.message.reply(f'⚠️ `{param_name}` must be <= {max_value}.',
91
+				mention_author=False)
92
+		return True
93
+
58
 	@classmethod
94
 	@classmethod
59
 	async def warn(cls, guild: Guild, message: str) -> Message:
95
 	async def warn(cls, guild: Guild, message: str) -> Message:
60
 		"""
96
 		"""

+ 184
- 34
cogs/crosspostcog.py Целия файл

1
 from discord import Guild, Message, PartialEmoji
1
 from discord import Guild, Message, PartialEmoji
2
 from discord.ext import commands
2
 from discord.ext import commands
3
 from datetime import datetime, timedelta
3
 from datetime import datetime, timedelta
4
+import math
4
 
5
 
5
 from rscollections import AgeBoundList, SizeBoundDict
6
 from rscollections import AgeBoundList, SizeBoundDict
6
 from cogs.basecog import BaseCog
7
 from cogs.basecog import BaseCog
21
 	STATE_KEY_RECENT_MESSAGES = "crosspost_recent_messages"
22
 	STATE_KEY_RECENT_MESSAGES = "crosspost_recent_messages"
22
 	STATE_KEY_SPAM_CONTEXT = "crosspost_spam_context"
23
 	STATE_KEY_SPAM_CONTEXT = "crosspost_spam_context"
23
 
24
 
25
+	CONFIG_KEY_WARN_COUNT = "crosspost_warn_count"
26
+	CONFIG_KEY_BAN_COUNT = "crosspost_ban_count"
27
+	CONFIG_KEY_MIN_MESSAGE_LENGTH = "crosspost_min_message_length"
28
+	CONFIG_KEY_MESSAGE_AGE = "crosspost_message_age"
29
+
30
+	MIN_WARN_COUNT = 2
31
+	MIN_BAN_COUNT = 2
32
+	MIN_MESSAGE_LENGTH = 0
33
+	MIN_TIME_SPAN = 1
34
+
24
 	def __init__(self, bot):
35
 	def __init__(self, bot):
25
 		super().__init__(bot)
36
 		super().__init__(bot)
26
-		self.max_recent_message_age = timedelta(minutes=2)
27
 		self.max_spam_contexts = 12
37
 		self.max_spam_contexts = 12
28
-		self.warn_messages_per_user = 3
29
-		self.ban_messages_per_user = 5
30
-		self.min_message_length = 10
38
+
39
+	# Config
40
+
41
+	def __warn_count(self, guild: Guild) -> int:
42
+		return Storage.get_config_value(guild, self.CONFIG_KEY_WARN_COUNT) or 3
43
+
44
+	def __ban_count(self, guild: Guild) -> int:
45
+		return Storage.get_config_value(guild, self.CONFIG_KEY_BAN_COUNT) or 9999
46
+
47
+	def __min_message_length(self, guild: Guild) -> int:
48
+		return Storage.get_config_value(guild, self.CONFIG_KEY_MIN_MESSAGE_LENGTH) or 0
49
+
50
+	def __message_age_seconds(self, guild: Guild) -> int:
51
+		return Storage.get_config_value(guild, self.CONFIG_KEY_MESSAGE_AGE) or 120
31
 
52
 
32
 	async def __record_message(self, message: Message) -> None:
53
 	async def __record_message(self, message: Message) -> None:
33
 		if message.author.permissions_in(message.channel).ban_members:
54
 		if message.author.permissions_in(message.channel).ban_members:
34
 			# User exempt from spam detection
55
 			# User exempt from spam detection
35
 			return
56
 			return
57
+		if len(message.content) < self.__min_message_length(message.guild):
58
+			# Message too short to count towards spam total
59
+			return
60
+		max_age = timedelta(seconds=self.__message_age_seconds(message.guild))
36
 		recent_messages = Storage.get_state_value(message.guild, self.STATE_KEY_RECENT_MESSAGES) \
61
 		recent_messages = Storage.get_state_value(message.guild, self.STATE_KEY_RECENT_MESSAGES) \
37
-			or AgeBoundList(self.max_recent_message_age, lambda index, message : message.created_at)
62
+			or AgeBoundList(max_age, lambda index, message : message.created_at)
63
+		recent_messages.max_age = max_age
38
 		recent_messages.append(message)
64
 		recent_messages.append(message)
39
 		Storage.set_state_value(message.guild, self.STATE_KEY_RECENT_MESSAGES, recent_messages)
65
 		Storage.set_state_value(message.guild, self.STATE_KEY_RECENT_MESSAGES, recent_messages)
40
 
66
 
41
 		# Get all recent messages by user
67
 		# Get all recent messages by user
42
 		member_messages = [m for m in recent_messages if m.author.id == message.author.id]
68
 		member_messages = [m for m in recent_messages if m.author.id == message.author.id]
43
-		if len(member_messages) < self.warn_messages_per_user:
69
+		if len(member_messages) < self.__warn_count(message.guild):
44
 			return
70
 			return
45
 
71
 
46
 		# Look for repeats
72
 		# Look for repeats
51
 			count = (hash_to_count.get(key) or 0) + 1
77
 			count = (hash_to_count.get(key) or 0) + 1
52
 			hash_to_count[key] = count
78
 			hash_to_count[key] = count
53
 			max_count = max(max_count, count)
79
 			max_count = max(max_count, count)
54
-		if max_count < self.warn_messages_per_user:
80
+		if max_count < self.__warn_count(message.guild):
55
 			return
81
 			return
56
 
82
 
57
 		# Handle the spam
83
 		# Handle the spam
59
 			or SizeBoundDict(self.max_spam_contexts, lambda key, context : context.age)
85
 			or SizeBoundDict(self.max_spam_contexts, lambda key, context : context.age)
60
 		Storage.set_state_value(message.guild, self.STATE_KEY_SPAM_CONTEXT, spam_lookup)
86
 		Storage.set_state_value(message.guild, self.STATE_KEY_SPAM_CONTEXT, spam_lookup)
61
 		for message_hash, count in hash_to_count.items():
87
 		for message_hash, count in hash_to_count.items():
62
-			if count < self.warn_messages_per_user:
88
+			if count < self.__warn_count(message.guild):
63
 				continue
89
 				continue
64
 			key = f'{message.author.id}|{message_hash}'
90
 			key = f'{message.author.id}|{message_hash}'
65
 			context = spam_lookup.get(key)
91
 			context = spam_lookup.get(key)
75
 
101
 
76
 	async def __update_from_context(self, context: SpamContext):
102
 	async def __update_from_context(self, context: SpamContext):
77
 		content = next(iter(context.messages)).content
103
 		content = next(iter(context.messages)).content
78
-		if len(context.messages) > self.ban_messages_per_user:
104
+		if len(context.messages) >= self.__ban_count(context.member.guild):
79
 			if not context.is_banned:
105
 			if not context.is_banned:
80
 				await context.member.ban(reason='Posting same message repeatedly', delete_message_days=1)
106
 				await context.member.ban(reason='Posting same message repeatedly', delete_message_days=1)
81
 				msg = f'User {context.member.mention} auto banned for ' + \
107
 				msg = f'User {context.member.mention} auto banned for ' + \
82
 					'crosspost spamming.\n' + \
108
 					'crosspost spamming.\n' + \
83
-					'> {content}'
109
+					f'> {content}'
84
 				if context.warning_message:
110
 				if context.warning_message:
85
 					await self.update_warn(context.warning_message, msg)
111
 					await self.update_warn(context.warning_message, msg)
86
 					await context.warning_message.clear_reaction('🗑')
112
 					await context.warning_message.clear_reaction('🗑')
87
 					await context.warning_message.clear_reaction('👢')
113
 					await context.warning_message.clear_reaction('👢')
88
 					await context.warning_message.clear_reaction('🚫')
114
 					await context.warning_message.clear_reaction('🚫')
89
 				else:
115
 				else:
90
-					self.warning_message = await self.warn(context.member.guild, msg)
91
-		elif len(context.messages) > self.warn_messages_per_user:
116
+					context.warning_message = await self.warn(context.member.guild, msg)
117
+		elif len(context.messages) >= self.__warn_count(context.member.guild):
92
 			content = next(iter(context.messages)).content
118
 			content = next(iter(context.messages)).content
93
 			msg = f'User {context.member.mention} has posted the exact same ' + \
119
 			msg = f'User {context.member.mention} has posted the exact same ' + \
94
 				f'message {len(context.messages)} times.\n' + \
120
 				f'message {len(context.messages)} times.\n' + \
111
 				await self.update_warn(context.warning_message, msg)
137
 				await self.update_warn(context.warning_message, msg)
112
 			else:
138
 			else:
113
 				context.warning_message = await self.warn(context.member.guild, msg)
139
 				context.warning_message = await self.warn(context.member.guild, msg)
114
-				self.listen_for_reactions_to(context.warning_message)
140
+				self.listen_for_reactions_to(context.warning_message, context)
115
 			if can_delete:
141
 			if can_delete:
116
 				await context.warning_message.add_reaction('🗑')
142
 				await context.warning_message.add_reaction('🗑')
117
 			else:
143
 			else:
125
 			else:
151
 			else:
126
 				await context.warning_message.clear_reaction('🚫')
152
 				await context.warning_message.clear_reaction('🚫')
127
 
153
 
128
-	def __context_for_warning_message(self, message: Message) -> SpamContext:
129
-		spam_lookup = Storage.get_state_value(message.guild, self.STATE_KEY_SPAM_CONTEXT)
130
-		if spam_lookup is None:
131
-			return
132
-		for _, context in spam_lookup.items():
133
-			if context.warning_message.id == message.id:
134
-				return context
135
-		return None
136
-
137
-	async def on_mod_react(self, message: Message, emoji: PartialEmoji) -> None:
138
-		context = self.__context_for_warning_message(message)
139
-		if context is None:
140
-			return
141
-
142
-		if emoji.name == '🗑':
143
-			await self.__delete_messages(context)
144
-		elif emoji.name == '👢':
145
-			await self.__kick(context)
146
-		elif emoji.name == '🚫':
147
-			await self.__ban(context)
148
-
149
 	async def __delete_messages(self, context: SpamContext) -> None:
154
 	async def __delete_messages(self, context: SpamContext) -> None:
150
 		for message in context.messages - context.deleted_messages:
155
 		for message in context.messages - context.deleted_messages:
151
 			await message.delete()
156
 			await message.delete()
164
 		context.is_banned = True
169
 		context.is_banned = True
165
 		await self.__update_from_context(context)
170
 		await self.__update_from_context(context)
166
 
171
 
172
+	async def on_mod_react(self, message: Message, emoji: PartialEmoji, context: SpamContext) -> None:
173
+		if context is None:
174
+			return
175
+
176
+		if emoji.name == '🗑':
177
+			await self.__delete_messages(context)
178
+		elif emoji.name == '👢':
179
+			await self.__kick(context)
180
+		elif emoji.name == '🚫':
181
+			await self.__ban(context)
182
+
167
 	@commands.Cog.listener()
183
 	@commands.Cog.listener()
168
 	async def on_message(self, message: Message):
184
 	async def on_message(self, message: Message):
169
 		await self.__record_message(message)
185
 		await self.__record_message(message)
186
+
187
+	# -- Commands -----------------------------------------------------------
188
+
189
+	@commands.group(
190
+		brief='Manages crosspost/repeated post detection and handling',
191
+	)
192
+	@commands.has_permissions(ban_members=True)
193
+	@commands.guild_only()
194
+	async def crosspost(self, context: commands.Context):
195
+		'Command group'
196
+		if context.invoked_subcommand is None:
197
+			await context.send_help()
198
+
199
+	@crosspost.command(
200
+		name='setwarncount',
201
+		brief='Sets the number of duplicate messages to trigger a mod warning',
202
+		description='If the same user posts the exact same message ' +
203
+			'content this many times a warning will be posted to the mods,' +
204
+			'even if the messages are posted in different channels.',
205
+		usage='<warn_count:int>',
206
+	)
207
+	async def joinraid_setwarncount(self, context: commands.Context,
208
+			warn_count: int):
209
+		if not await self.validate_param(context, 'warn_count', warn_count,
210
+			allowed_types=(int, ), min_value=self.MIN_WARN_COUNT):
211
+			return
212
+		Storage.set_config_value(context.guild, self.CONFIG_KEY_WARN_COUNT, warn_count)
213
+		await context.message.reply(f'✅ Mods will be warned if a user posts ' +
214
+			f'the exact same message {warn_count} or more times within ' +
215
+			f'{self.__message_age_seconds(context.guild)} seconds.',
216
+			mention_author=False)
217
+
218
+	@crosspost.command(
219
+		name='getwarncount',
220
+		brief='Returns the number of duplicate messages to trigger a mod warning',
221
+	)
222
+	async def joinraid_getwarncount(self, context: commands.Context):
223
+		await context.message.reply(f'ℹ️ Mods will be warned if a user posts ' +
224
+			f'the exact same message {self.__warn_count(context.guild)} or more ' +
225
+			f'times within {self.__message_age_seconds(context.guild)} seconds.',
226
+			mention_author=False)
227
+
228
+	@crosspost.command(
229
+		name='setbancount',
230
+		brief='Sets the number of duplicate messages to trigger an automatic ban',
231
+		description='If the same user posts the exact same message ' +
232
+			'content this many times they will be automatically banned and the ' +
233
+			'mods will be alerted.',
234
+		usage='<ban_count:int>',
235
+	)
236
+	async def joinraid_setbancount(self, context: commands.Context,
237
+			ban_count: int):
238
+		if not await self.validate_param(context, 'ban_count', ban_count,
239
+			allowed_types=(int, ), min_value=self.MIN_BAN_COUNT):
240
+			return
241
+		Storage.set_config_value(context.guild, self.CONFIG_KEY_BAN_COUNT, ban_count)
242
+		await context.message.reply(f'✅ Users will be banned if they post ' +
243
+			f'the exact same message {ban_count} or more times within ' +
244
+			f'{self.__message_age_seconds(context.guild)} seconds.',
245
+			mention_author=False)
246
+
247
+	@crosspost.command(
248
+		name='getbancount',
249
+		brief='Returns the number of duplicate messages to trigger an automatic ban',
250
+	)
251
+	async def joinraid_getbancount(self, context: commands.Context):
252
+		await context.message.reply(f'ℹ️ Users will be banned if they post ' +
253
+			f'the exact same message {self.__ban_count(context.guild)} or more ' +
254
+			f'times within {self.__message_age_seconds(context.guild)} seconds.',
255
+			mention_author=False)
256
+
257
+	@crosspost.command(
258
+		name='setminlength',
259
+		brief='Sets the minimum number of characters for a message to count toward spamming',
260
+		description='Messages shorter than this number of characters will not ' +
261
+			'count toward spam counts. This helps prevent flagging common, ' +
262
+			'frequent, short responses like "lol". A value of 0 counts all messages.',
263
+		usage='<min_length:int>',
264
+	)
265
+	async def joinraid_setminlength(self, context: commands.Context,
266
+			min_length: int):
267
+		if not await self.validate_param(context, 'min_length', min_length,
268
+			allowed_types=(int, ), min_value=self.MIN_MESSAGE_LENGTH):
269
+			return
270
+		Storage.set_config_value(context.guild, self.CONFIG_KEY_MIN_MESSAGE_LENGTH, min_length)
271
+		if min_length == 0:
272
+			await context.message.reply(f'✅ All messages will count against ' +
273
+				'spam counts, regardless of length.', mention_author=False)
274
+		else:
275
+			await context.message.reply(f'✅ Only messages {min_length} ' +
276
+				f'characters or longer will count against spam counts.',
277
+				mention_author=False)
278
+
279
+	@crosspost.command(
280
+		name='getminlength',
281
+		brief='Returns the number of duplicate messages to trigger an automatic ban',
282
+	)
283
+	async def joinraid_getminlength(self, context: commands.Context):
284
+		min_length = self.__min_message_length(context.guild)
285
+		if min_length == 0:
286
+			await context.message.reply(f'ℹ️ All messages will count against ' +
287
+				'spam counts, regardless of length.', mention_author=False)
288
+		else:
289
+			await context.message.reply(f'ℹ️ Only messages {min_length} ' +
290
+				f'characters or longer will count against spam counts.',
291
+				mention_author=False)
292
+
293
+	@crosspost.command(
294
+		name='settimewindow',
295
+		brief='Sets the length of time recent messages are checked for duplicates',
296
+		description='Repeated messages are only checked against recent ' +
297
+			'messages. This sets the length of that window, in seconds. Lower ' +
298
+			'values save memory and prevent false positives.',
299
+		usage='<seconds:int>',
300
+	)
301
+	async def joinraid_settimewindow(self, context: commands.Context,
302
+			seconds: int):
303
+		if not await self.validate_param(context, 'seconds', seconds,
304
+			allowed_types=(int, ), min_value=self.MIN_TIME_SPAN):
305
+			return
306
+		Storage.set_config_value(context.guild, self.CONFIG_KEY_MESSAGE_AGE, seconds)
307
+		await context.message.reply(f'✅ Only messages in the past {seconds} ' +
308
+			f'seconds will be checked for duplicates.',
309
+			mention_author=False)
310
+
311
+	@crosspost.command(
312
+		name='gettimewindow',
313
+		brief='Returns the length of time recent messages are checked for duplicates',
314
+	)
315
+	async def joinraid_gettimewindow(self, context: commands.Context):
316
+		seconds = self.__message_age_seconds(context.guild)
317
+		await context.message.reply(f'ℹ️ Only messages in the past {seconds} ' +
318
+			'seconds will be checked for duplicates.',
319
+			mention_author=False)

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

10
 
10
 
11
 from config import CONFIG
11
 from config import CONFIG
12
 from cogs.configcog import ConfigCog
12
 from cogs.configcog import ConfigCog
13
-# from cogs.crosspostcog import CrossPostCog
13
+from cogs.crosspostcog import CrossPostCog
14
 from cogs.generalcog import GeneralCog
14
 from cogs.generalcog import GeneralCog
15
 from cogs.joinraidcog import JoinRaidCog
15
 from cogs.joinraidcog import JoinRaidCog
16
 
16
 
25
 bot.add_cog(GeneralCog(bot))
25
 bot.add_cog(GeneralCog(bot))
26
 bot.add_cog(ConfigCog(bot))
26
 bot.add_cog(ConfigCog(bot))
27
 bot.add_cog(JoinRaidCog(bot))
27
 bot.add_cog(JoinRaidCog(bot))
28
-# bot.add_cog(CrossPostCog(bot))
28
+bot.add_cog(CrossPostCog(bot))
29
 bot.run(CONFIG['clientToken'], bot=True, reconnect=True)
29
 bot.run(CONFIG['clientToken'], bot=True, reconnect=True)
30
 print('\nBot aborted')
30
 print('\nBot aborted')

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