瀏覽代碼

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,10 +9,16 @@ import json
9 9
 class BaseCog(commands.Cog):
10 10
 	def __init__(self, bot):
11 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 23
 	@commands.Cog.listener()
18 24
 	async def on_raw_reaction_add(self, payload: RawReactionActionEvent):
@@ -41,20 +47,50 @@ class BaseCog(commands.Cog):
41 47
 		if not member.permissions_in(channel).ban_members:
42 48
 			# Not a mod
43 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 52
 			# Not a message we're listening for
46 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 59
 		Override point for getting a mod's emote on a bot message. Used to take
52 60
 		action on a warning, such as banning an offending user. This event is
53 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 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 94
 	@classmethod
59 95
 	async def warn(cls, guild: Guild, message: str) -> Message:
60 96
 		"""

+ 184
- 34
cogs/crosspostcog.py 查看文件

@@ -1,6 +1,7 @@
1 1
 from discord import Guild, Message, PartialEmoji
2 2
 from discord.ext import commands
3 3
 from datetime import datetime, timedelta
4
+import math
4 5
 
5 6
 from rscollections import AgeBoundList, SizeBoundDict
6 7
 from cogs.basecog import BaseCog
@@ -21,26 +22,51 @@ class CrossPostCog(BaseCog):
21 22
 	STATE_KEY_RECENT_MESSAGES = "crosspost_recent_messages"
22 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 35
 	def __init__(self, bot):
25 36
 		super().__init__(bot)
26
-		self.max_recent_message_age = timedelta(minutes=2)
27 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 53
 	async def __record_message(self, message: Message) -> None:
33 54
 		if message.author.permissions_in(message.channel).ban_members:
34 55
 			# User exempt from spam detection
35 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 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 64
 		recent_messages.append(message)
39 65
 		Storage.set_state_value(message.guild, self.STATE_KEY_RECENT_MESSAGES, recent_messages)
40 66
 
41 67
 		# Get all recent messages by user
42 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 70
 			return
45 71
 
46 72
 		# Look for repeats
@@ -51,7 +77,7 @@ class CrossPostCog(BaseCog):
51 77
 			count = (hash_to_count.get(key) or 0) + 1
52 78
 			hash_to_count[key] = count
53 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 81
 			return
56 82
 
57 83
 		# Handle the spam
@@ -59,7 +85,7 @@ class CrossPostCog(BaseCog):
59 85
 			or SizeBoundDict(self.max_spam_contexts, lambda key, context : context.age)
60 86
 		Storage.set_state_value(message.guild, self.STATE_KEY_SPAM_CONTEXT, spam_lookup)
61 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 89
 				continue
64 90
 			key = f'{message.author.id}|{message_hash}'
65 91
 			context = spam_lookup.get(key)
@@ -75,20 +101,20 @@ class CrossPostCog(BaseCog):
75 101
 
76 102
 	async def __update_from_context(self, context: SpamContext):
77 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 105
 			if not context.is_banned:
80 106
 				await context.member.ban(reason='Posting same message repeatedly', delete_message_days=1)
81 107
 				msg = f'User {context.member.mention} auto banned for ' + \
82 108
 					'crosspost spamming.\n' + \
83
-					'> {content}'
109
+					f'> {content}'
84 110
 				if context.warning_message:
85 111
 					await self.update_warn(context.warning_message, msg)
86 112
 					await context.warning_message.clear_reaction('🗑')
87 113
 					await context.warning_message.clear_reaction('👢')
88 114
 					await context.warning_message.clear_reaction('🚫')
89 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 118
 			content = next(iter(context.messages)).content
93 119
 			msg = f'User {context.member.mention} has posted the exact same ' + \
94 120
 				f'message {len(context.messages)} times.\n' + \
@@ -111,7 +137,7 @@ class CrossPostCog(BaseCog):
111 137
 				await self.update_warn(context.warning_message, msg)
112 138
 			else:
113 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 141
 			if can_delete:
116 142
 				await context.warning_message.add_reaction('🗑')
117 143
 			else:
@@ -125,27 +151,6 @@ class CrossPostCog(BaseCog):
125 151
 			else:
126 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 154
 	async def __delete_messages(self, context: SpamContext) -> None:
150 155
 		for message in context.messages - context.deleted_messages:
151 156
 			await message.delete()
@@ -164,6 +169,151 @@ class CrossPostCog(BaseCog):
164 169
 		context.is_banned = True
165 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 183
 	@commands.Cog.listener()
168 184
 	async def on_message(self, message: Message):
169 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,7 +10,7 @@ from discord.ext import commands
10 10
 
11 11
 from config import CONFIG
12 12
 from cogs.configcog import ConfigCog
13
-# from cogs.crosspostcog import CrossPostCog
13
+from cogs.crosspostcog import CrossPostCog
14 14
 from cogs.generalcog import GeneralCog
15 15
 from cogs.joinraidcog import JoinRaidCog
16 16
 
@@ -25,6 +25,6 @@ bot = Rocketbot(command_prefix=CONFIG['commandPrefix'], intents=intents)
25 25
 bot.add_cog(GeneralCog(bot))
26 26
 bot.add_cog(ConfigCog(bot))
27 27
 bot.add_cog(JoinRaidCog(bot))
28
-# bot.add_cog(CrossPostCog(bot))
28
+bot.add_cog(CrossPostCog(bot))
29 29
 bot.run(CONFIG['clientToken'], bot=True, reconnect=True)
30 30
 print('\nBot aborted')

Loading…
取消
儲存