瀏覽代碼

Crosspost cog looks at attachments as well

tags/2.0.0
Rocketsoup 2 月之前
父節點
當前提交
461c1520ea

+ 2
- 2
rocketbot/cogs/autokickcog.py 查看文件

@@ -107,7 +107,7 @@ class AutoKickCog(BaseCog, name='Auto Kick'):
107 107
 
108 108
 	async def __kick_or_ban_if_needed(self, member: Member):
109 109
 		guild: Guild = member.guild
110
-		recent_kicks: AgeBoundDict = Storage.get_state_value(guild, AutoKickCog.STATE_KEY_RECENT_KICKS)
110
+		recent_kicks: AgeBoundDict[int, AutoKickContext, datetime, timedelta] = Storage.get_state_value(guild, AutoKickCog.STATE_KEY_RECENT_KICKS)
111 111
 		if recent_kicks is None:
112 112
 			recent_kicks = AgeBoundDict(timedelta(seconds=3600), lambda i, context : context.last_kick)
113 113
 			Storage.set_state_value(guild, self.STATE_KEY_RECENT_KICKS, recent_kicks)
@@ -146,7 +146,7 @@ class AutoKickCog(BaseCog, name='Auto Kick'):
146 146
 	@staticmethod
147 147
 	def ordinal(val: int):
148 148
 		"""Formats an integer with an ordinal suffix (English only)"""
149
-		if val % 100 < 10 or val % 100 > 20:
149
+		if (val // 10) % 10 != 1:
150 150
 			if val % 10 == 1:
151 151
 				return f'{val}st'
152 152
 			if val % 10 == 2:

+ 4
- 4
rocketbot/cogs/basecog.py 查看文件

@@ -118,7 +118,7 @@ class BaseCog(commands.Cog):
118 118
 		issue warnings regardless. Call record_warning or record_warnings after
119 119
 		triggering a mod warning.
120 120
 		"""
121
-		recent_warns: AgeBoundDict = Storage.get_state_value(member.guild,
121
+		recent_warns: AgeBoundDict[int, WarningContext, datetime, timedelta] = Storage.get_state_value(member.guild,
122 122
 			BaseCog.STATE_KEY_RECENT_WARNINGS)
123 123
 		if recent_warns is None:
124 124
 			return False
@@ -133,7 +133,7 @@ class BaseCog(commands.Cog):
133 133
 		Records that mods have been warned about a member and do not need to be
134 134
 		warned about them again for a short while.
135 135
 		"""
136
-		recent_warns: AgeBoundDict = Storage.get_state_value(member.guild,
136
+		recent_warns: AgeBoundDict[int, WarningContext, datetime, timedelta] = Storage.get_state_value(member.guild,
137 137
 			BaseCog.STATE_KEY_RECENT_WARNINGS)
138 138
 		if recent_warns is None:
139 139
 			recent_warns = AgeBoundDict(timedelta(seconds=CONFIG['squelch_warning_seconds']),
@@ -157,8 +157,8 @@ class BaseCog(commands.Cog):
157 157
 	# Bot message handling
158 158
 
159 159
 	@classmethod
160
-	def __bot_messages(cls, guild: Guild) -> AgeBoundDict[int, BotMessage]:
161
-		bm = Storage.get_state_value(guild, 'bot_messages')
160
+	def __bot_messages(cls, guild: Guild) -> AgeBoundDict[int, BotMessage, datetime, timedelta]:
161
+		bm: AgeBoundDict[int, BotMessage, datetime, timedelta] = Storage.get_state_value(guild, 'bot_messages')
162 162
 		if bm is None:
163 163
 			far_future = datetime.now(timezone.utc) + timedelta(days=1000)
164 164
 			bm = AgeBoundDict(timedelta(seconds=600),

+ 32
- 24
rocketbot/cogs/crosspostcog.py 查看文件

@@ -2,7 +2,9 @@
2 2
 Cog for detecting spam messages posted in multiple channels.
3 3
 """
4 4
 from datetime import datetime, timedelta
5
-from discord import Member, Message, utils as discordutils
5
+from typing import Optional
6
+
7
+from discord import Member, Message, utils as discordutils, TextChannel
6 8
 from discord.ext import commands
7 9
 
8 10
 from config import CONFIG
@@ -14,17 +16,17 @@ class SpamContext:
14 16
 	"""
15 17
 	Data about a set of duplicate messages from a user.
16 18
 	"""
17
-	def __init__(self, member, message_hash):
18
-		self.member = member
19
-		self.message_hash = message_hash
20
-		self.age = datetime.now()
21
-		self.bot_message = None  # BotMessage
22
-		self.is_kicked = False
23
-		self.is_banned = False
24
-		self.is_autobanned = False
25
-		self.spam_messages = set()  # of Message
26
-		self.deleted_messages = set()  # of Message
27
-		self.unique_channels = set()  # of TextChannel
19
+	def __init__(self, member: Member, message_hash: int) -> None:
20
+		self.member: Member = member
21
+		self.message_hash: int = message_hash
22
+		self.age: datetime = datetime.now()
23
+		self.bot_message: Optional[BotMessage] = None
24
+		self.is_kicked: bool = False
25
+		self.is_banned: bool = False
26
+		self.is_autobanned: bool = False
27
+		self.spam_messages: set[Message] = set()
28
+		self.deleted_messages: set[Message] = set()
29
+		self.unique_channels: set[TextChannel] = set()
28 30
 
29 31
 class CrossPostCog(BaseCog, name='Crosspost Detection'):
30 32
 	"""
@@ -82,12 +84,19 @@ class CrossPostCog(BaseCog, name='Crosspost Detection'):
82 84
 		if message.channel.permissions_for(message.author).ban_members:
83 85
 			# User exempt from spam detection
84 86
 			return
85
-		if len(message.content) < self.get_guild_setting(message.guild, self.SETTING_MIN_LENGTH):
87
+		def compute_message_hash(m: Message) -> int:
88
+			to_hash = m.content
89
+			for attachment in m.attachments:
90
+				to_hash += f'\n[[ATT: ct={attachment.content_type} s={attachment.size} w={attachment.width} h={attachment.height}]]'
91
+			h = hash(to_hash)
92
+			return h
93
+		compute_message_hash(message)
94
+		if len(message.attachments) == 0 and len(message.content) < self.get_guild_setting(message.guild, self.SETTING_MIN_LENGTH):
86 95
 			# Message too short to count towards spam total
87 96
 			return
88 97
 		max_age = timedelta(seconds=self.get_guild_setting(message.guild, self.SETTING_TIMESPAN))
89
-		warn_count = self.get_guild_setting(message.guild, self.SETTING_WARN_COUNT)
90
-		recent_messages = Storage.get_state_value(message.guild, self.STATE_KEY_RECENT_MESSAGES)
98
+		warn_count: int = self.get_guild_setting(message.guild, self.SETTING_WARN_COUNT)
99
+		recent_messages: AgeBoundList[Message, datetime, timedelta] = Storage.get_state_value(message.guild, self.STATE_KEY_RECENT_MESSAGES)
91 100
 		if recent_messages is None:
92 101
 			recent_messages = AgeBoundList(max_age, lambda index, message : message.created_at)
93 102
 			Storage.set_state_value(message.guild, self.STATE_KEY_RECENT_MESSAGES, recent_messages)
@@ -100,21 +109,21 @@ class CrossPostCog(BaseCog, name='Crosspost Detection'):
100 109
 			return
101 110
 
102 111
 		# Look for repeats
103
-		hash_to_channels = {}  # int --> set(TextChannel)
112
+		hash_to_channels: dict[int, set[TextChannel]] = {}
104 113
 		max_count = 0
105 114
 		for m in member_messages:
106
-			key = hash(m.content)
107
-			channels = hash_to_channels.get(key)
115
+			message_hash = compute_message_hash(m)
116
+			channels: set[TextChannel] = hash_to_channels.get(message_hash)
108 117
 			if channels is None:
109 118
 				channels = set()
110
-				hash_to_channels[key] = channels
119
+				hash_to_channels[message_hash] = channels
111 120
 			channels.add(m.channel)
112 121
 			max_count = max(max_count, len(channels))
113 122
 		if max_count < warn_count:
114 123
 			return
115 124
 
116 125
 		# Handle the spam
117
-		spam_lookup = Storage.get_state_value(message.guild, self.STATE_KEY_SPAM_CONTEXT)
126
+		spam_lookup: SizeBoundDict[str, SpamContext, datetime] = Storage.get_state_value(message.guild, self.STATE_KEY_SPAM_CONTEXT)
118 127
 		if spam_lookup is None:
119 128
 			spam_lookup = SizeBoundDict(
120 129
 				self.max_spam_contexts,
@@ -134,7 +143,7 @@ class CrossPostCog(BaseCog, name='Crosspost Detection'):
134 143
 					f'\u0007{message.author.name} ({message.author.id}) ' + \
135 144
 					f'posted the same message in {channel_count} or more channels.')
136 145
 			for m in member_messages:
137
-				if hash(m.content) == message_hash:
146
+				if compute_message_hash(m) == message_hash:
138 147
 					context.spam_messages.add(m)
139 148
 					context.unique_channels.add(m.channel)
140 149
 			await self.__update_from_context(context)
@@ -172,7 +181,7 @@ class CrossPostCog(BaseCog, name='Crosspost Detection'):
172 181
 			message_type: int = BotMessage.TYPE_INFO if self.was_warned_recently(context.member) \
173 182
 				else BotMessage.TYPE_MOD_WARNING
174 183
 			message = BotMessage(context.member.guild, '', message_type, context)
175
-			message.quote = discordutils.remove_markdown(first_spam_message.clean_content())
184
+			message.quote = discordutils.remove_markdown(first_spam_message.clean_content)
176 185
 			self.record_warning(context.member)
177 186
 		if context.is_autobanned:
178 187
 			text = f'User {context.member.mention} auto banned for ' + \
@@ -238,8 +247,7 @@ class CrossPostCog(BaseCog, name='Crosspost Detection'):
238 247
 				message.author.bot or \
239 248
 				message.channel is None or \
240 249
 				message.guild is None or \
241
-				message.content is None or \
242
-				message.content == '':
250
+				message.content is None:
243 251
 			return
244 252
 		if not self.get_guild_setting(message.guild, self.SETTING_ENABLED):
245 253
 			return

+ 2
- 2
rocketbot/cogs/joinagecog.py 查看文件

@@ -60,7 +60,7 @@ class JoinAgeCog(BaseCog, name='Join Age'):
60 60
 	async def search(self, context: commands.Context, timespan: str):
61 61
 		"""Command handler"""
62 62
 		guild: Guild = context.guild
63
-		recent_joins: AgeBoundList = Storage.get_state_value(guild, self.STATE_KEY_RECENT_JOINS)
63
+		recent_joins: AgeBoundList[Member, datetime, timedelta] = Storage.get_state_value(guild, self.STATE_KEY_RECENT_JOINS)
64 64
 		if recent_joins is None:
65 65
 			max_age: timedelta = timedelta(seconds=self.get_guild_setting(guild, self.SETTING_JOIN_TIME))
66 66
 			recent_joins = AgeBoundList(max_age, lambda i, member0 : member0.joined_at)
@@ -110,7 +110,7 @@ class JoinAgeCog(BaseCog, name='Join Age'):
110 110
 		guild: Guild = member.guild
111 111
 		if not self.get_guild_setting(guild, self.SETTING_ENABLED):
112 112
 			return
113
-		recent_joins: AgeBoundList = Storage.get_state_value(guild, self.STATE_KEY_RECENT_JOINS)
113
+		recent_joins: AgeBoundList[Member, datetime, timedelta] = Storage.get_state_value(guild, self.STATE_KEY_RECENT_JOINS)
114 114
 		if recent_joins is None:
115 115
 			max_age: timedelta = timedelta(seconds=self.get_guild_setting(guild, self.SETTING_JOIN_TIME))
116 116
 			recent_joins = AgeBoundList(max_age, lambda i, member : member.joined_at)

+ 2
- 2
rocketbot/cogs/joinraidcog.py 查看文件

@@ -101,7 +101,7 @@ class JoinRaidCog(BaseCog, name='Join Raids'):
101 101
 		timespan: timedelta = timedelta(seconds=seconds)
102 102
 
103 103
 		last_raid: JoinRaidContext = Storage.get_state_value(guild, self.STATE_KEY_LAST_RAID)
104
-		recent_joins: AgeBoundList = Storage.get_state_value(guild, self.STATE_KEY_RECENT_JOINS)
104
+		recent_joins: AgeBoundList[Member, datetime, timedelta] = Storage.get_state_value(guild, self.STATE_KEY_RECENT_JOINS)
105 105
 		if recent_joins is None:
106 106
 			recent_joins = AgeBoundList(timespan, lambda i, member : member.joined_at)
107 107
 			Storage.set_state_value(guild, self.STATE_KEY_RECENT_JOINS, recent_joins)
@@ -147,7 +147,7 @@ class JoinRaidCog(BaseCog, name='Join Raids'):
147 147
 		if setting is self.SETTING_JOIN_TIME:
148 148
 			seconds = self.get_guild_setting(guild, self.SETTING_JOIN_TIME)
149 149
 			timespan: timedelta = timedelta(seconds=seconds)
150
-			recent_joins: AgeBoundList = Storage.get_state_value(guild,
150
+			recent_joins: AgeBoundList[Member, datetime, timedelta] = Storage.get_state_value(guild,
151 151
 				self.STATE_KEY_RECENT_JOINS)
152 152
 			if recent_joins:
153 153
 				recent_joins.max_age = timespan

+ 1
- 1
rocketbot/cogs/patterncog.py 查看文件

@@ -158,7 +158,7 @@ class PatternCog(BaseCog, name='Pattern Matching'):
158 158
 				type=message_type,
159 159
 				context=context)
160 160
 			self.record_warning(message.author)
161
-			bm.quote = discordutils.remove_markdown(message.clean_content())
161
+			bm.quote = discordutils.remove_markdown(message.clean_content)
162 162
 			await bm.set_reactions(BotMessageReaction.standard_set(
163 163
 				did_delete=context.is_deleted,
164 164
 				did_kick=context.is_kicked,

+ 1
- 1
rocketbot/cogs/urlspamcog.py 查看文件

@@ -132,7 +132,7 @@ class URLSpamCog(BaseCog, name='URL Spam'):
132 132
 					f'{join_age_str} after joining.',
133 133
 					type = BotMessage.TYPE_MOD_WARNING if needs_attention else BotMessage.TYPE_INFO,
134 134
 					context = context)
135
-			bm.quote = discordutils.remove_markdown(message.clean_content())
135
+			bm.quote = discordutils.remove_markdown(message.clean_content)
136 136
 			await bm.set_reactions(BotMessageReaction.standard_set(
137 137
 				did_delete=context.is_deleted,
138 138
 				did_kick=context.is_kicked,

+ 24
- 18
rocketbot/collections.py 查看文件

@@ -3,12 +3,18 @@ Subclasses of list, set, and dict with special behaviors.
3 3
 """
4 4
 
5 5
 from abc import ABCMeta, abstractmethod
6
-from typing import Generic, TypeVar
6
+from typing import Callable, Generic, TypeVar, Optional
7 7
 
8 8
 # Abstract collections
9 9
 
10
+# Dictionary key
10 11
 K = TypeVar('K')
12
+# Collection value
11 13
 V = TypeVar('V')
14
+# Element age
15
+A = TypeVar('A')
16
+# Age delta
17
+D = TypeVar('D')
12 18
 
13 19
 class AbstractMutableList(list[V], Generic[V], metaclass=ABCMeta):
14 20
 	"""
@@ -232,7 +238,7 @@ class AbstractMutableDict(dict[K, V], Generic[K, V], metaclass=ABCMeta):
232 238
 
233 239
 # Collections with limited number of elements
234 240
 
235
-class SizeBoundList(AbstractMutableList[V], Generic[V]):
241
+class SizeBoundList(AbstractMutableList[V], Generic[V, A]):
236 242
 	"""
237 243
 	Subclass of `list` that enforces a maximum number of elements.
238 244
 
@@ -253,7 +259,7 @@ class SizeBoundList(AbstractMutableList[V], Generic[V]):
253 259
 	"""
254 260
 	def __init__(self,
255 261
 			max_element_count: int,
256
-			element_age,
262
+			element_age: Callable[[int, V], A],
257 263
 			*args, **kwargs):
258 264
 		super().__init__(*args, **kwargs)
259 265
 		self.element_age = element_age
@@ -290,7 +296,7 @@ class SizeBoundList(AbstractMutableList[V], Generic[V]):
290 296
 	def copy(self):
291 297
 		return SizeBoundList(self.max_element_count, self.element_age, super())
292 298
 
293
-class SizeBoundSet(AbstractMutableSet[V], Generic[V]):
299
+class SizeBoundSet(AbstractMutableSet[V], Generic[V, A]):
294 300
 	"""
295 301
 	Subclass of `set` that enforces a maximum number of elements.
296 302
 
@@ -311,7 +317,7 @@ class SizeBoundSet(AbstractMutableSet[V], Generic[V]):
311 317
 	"""
312 318
 	def __init__(self,
313 319
 			max_element_count: int,
314
-			element_age,
320
+			element_age: Callable[[int, V], A],
315 321
 			*args, **kwargs):
316 322
 		super().__init__(*args, **kwargs)
317 323
 		self.element_age = element_age
@@ -347,7 +353,7 @@ class SizeBoundSet(AbstractMutableSet[V], Generic[V]):
347 353
 	def copy(self):
348 354
 		return SizeBoundSet(self.max_element_count, self.element_age, super())
349 355
 
350
-class SizeBoundDict(AbstractMutableDict[K, V], Generic[K, V]):
356
+class SizeBoundDict(AbstractMutableDict[K, V], Generic[K, V, A]):
351 357
 	"""
352 358
 	Subclass of `dict` that enforces a maximum number of elements.
353 359
 
@@ -368,7 +374,7 @@ class SizeBoundDict(AbstractMutableDict[K, V], Generic[K, V]):
368 374
 	"""
369 375
 	def __init__(self,
370 376
 			max_element_count: int,
371
-			element_age,
377
+			element_age: Callable[[K, V], A],
372 378
 			*args, **kwargs):
373 379
 		super().__init__(*args, **kwargs)
374 380
 		self.element_age = element_age
@@ -406,7 +412,7 @@ class SizeBoundDict(AbstractMutableDict[K, V], Generic[K, V]):
406 412
 
407 413
 # Collections with limited age of elements
408 414
 
409
-class AgeBoundList(AbstractMutableList[V], Generic[V]):
415
+class AgeBoundList(AbstractMutableList[V], Generic[V, A, D]):
410 416
 	"""
411 417
 	Subclass of `list` that enforces a maximum "age" of elements.
412 418
 
@@ -425,7 +431,7 @@ class AgeBoundList(AbstractMutableList[V], Generic[V]):
425 431
 	however elements will only be discarded following the next mutating
426 432
 	operation. Call `self.purge_old_elements()` to force resizing.
427 433
 	"""
428
-	def __init__(self, max_age, element_age, *args, **kwargs):
434
+	def __init__(self, max_age: D, element_age: Callable[[int, V], A], *args, **kwargs):
429 435
 		super().__init__(*args, **kwargs)
430 436
 		self.max_age = max_age
431 437
 		self.element_age = element_age
@@ -445,17 +451,17 @@ class AgeBoundList(AbstractMutableList[V], Generic[V]):
445 451
 		if self.is_culling or len(self) <= 1:
446 452
 			return
447 453
 		self.is_culling = True
448
-		min_age = None
449
-		max_age = None
450
-		ages = {}
454
+		min_age: Optional[A] = None
455
+		max_age: Optional[A] = None
456
+		ages: dict[int, A] = {}
451 457
 		for i, elem in enumerate(self):
452
-			age = self.element_age(i, elem)
458
+			age: A = self.element_age(i, elem)
453 459
 			ages[i] = age
454 460
 			if min_age is None or age < min_age:
455 461
 				min_age = age
456 462
 			if max_age is None or age > max_age:
457 463
 				max_age = age
458
-		cutoff = max_age - self.max_age
464
+		cutoff: A = max_age - self.max_age
459 465
 		if min_age >= cutoff:
460 466
 			self.is_culling = False
461 467
 			return
@@ -467,7 +473,7 @@ class AgeBoundList(AbstractMutableList[V], Generic[V]):
467 473
 	def copy(self):
468 474
 		return AgeBoundList(self.max_age, self.element_age, super())
469 475
 
470
-class AgeBoundSet(AbstractMutableSet[V], Generic[V]):
476
+class AgeBoundSet(AbstractMutableSet[V], Generic[V, A, D]):
471 477
 	"""
472 478
 	Subclass of `set` that enforces a maximum "age" of elements.
473 479
 
@@ -486,7 +492,7 @@ class AgeBoundSet(AbstractMutableSet[V], Generic[V]):
486 492
 	however elements will only be discarded following the next mutating
487 493
 	operation. Call `self.purge_old_elements()` to force resizing.
488 494
 	"""
489
-	def __init__(self, max_age, element_age, *args, **kwargs):
495
+	def __init__(self, max_age: D, element_age: Callable[[int, V], A], *args, **kwargs):
490 496
 		super().__init__(*args, **kwargs)
491 497
 		self.max_age = max_age
492 498
 		self.element_age = element_age
@@ -528,7 +534,7 @@ class AgeBoundSet(AbstractMutableSet[V], Generic[V]):
528 534
 	def copy(self):
529 535
 		return AgeBoundSet(self.max_age, self.element_age, super())
530 536
 
531
-class AgeBoundDict(AbstractMutableDict[K, V], Generic[K, V]):
537
+class AgeBoundDict(AbstractMutableDict[K, V], Generic[K, V, A, D]):
532 538
 	"""
533 539
 	Subclass of `dict` that enforces a maximum "age" of elements.
534 540
 
@@ -547,7 +553,7 @@ class AgeBoundDict(AbstractMutableDict[K, V], Generic[K, V]):
547 553
 	however elements will only be discarded following the next mutating
548 554
 	operation. Call `self.purge_old_elements()` to force resizing.
549 555
 	"""
550
-	def __init__(self, max_age, element_age, *args, **kwargs):
556
+	def __init__(self, max_age: D, element_age: Callable[[int, V], A], *args, **kwargs):
551 557
 		super().__init__(*args, **kwargs)
552 558
 		self.max_age = max_age
553 559
 		self.element_age = element_age

+ 1
- 1
rocketbot/pattern.py 查看文件

@@ -65,7 +65,7 @@ class PatternSimpleExpression(PatternExpression):
65 65
 		if self.field in ('content.markdown', 'content'):
66 66
 			return message.content
67 67
 		if self.field == 'content.plain':
68
-			return discordutils.remove_markdown(message.clean_content())
68
+			return discordutils.remove_markdown(message.clean_content)
69 69
 		if self.field == 'author':
70 70
 			return str(message.author.id)
71 71
 		if self.field == 'author.id':

+ 1
- 1
rocketbot/storage.py 查看文件

@@ -136,7 +136,7 @@ class Storage:
136 136
 		cls.__write_guild_config(guild, config)
137 137
 
138 138
 	@classmethod
139
-	def get_bot_messages(cls, guild: Guild) -> AgeBoundDict:
139
+	def get_bot_messages(cls, guild: Guild) -> AgeBoundDict[int, Any, datetime, timedelta]:
140 140
 		"""Returns all the bot messages for a guild."""
141 141
 		bm = cls.get_state_value(guild, 'bot_messages')
142 142
 		if bm is None:

Loading…
取消
儲存