Explorar el Código

Crosspost cog now working

tags/1.0.1
Rocketsoup hace 4 años
padre
commit
b5e38b79c0
Se han modificado 5 ficheros con 712 adiciones y 564 borrados
  1. 48
    1
      cogs/basecog.py
  2. 150
    51
      cogs/crosspostcog.py
  3. 507
    507
      cogs/joinraidcog.py
  4. 4
    2
      rocketbot.py
  5. 3
    3
      rscollections.py

+ 48
- 1
cogs/basecog.py Ver fichero

@@ -1,12 +1,59 @@
1
-from discord import Guild, Message, TextChannel
1
+from discord import Guild, Message, PartialEmoji, RawReactionActionEvent, TextChannel
2 2
 from discord.ext import commands
3
+from datetime import timedelta
3 4
 
5
+from rscollections import AgeBoundDict
4 6
 from storage import ConfigKey, Storage
5 7
 import json
6 8
 
7 9
 class BaseCog(commands.Cog):
8 10
 	def __init__(self, bot):
9 11
 		self.bot = bot
12
+		self.listened_mod_react_message_ids = AgeBoundDict(timedelta(minutes=5), lambda message_id, age : age)
13
+
14
+	def listen_for_reactions_to(self, message: Message) -> None:
15
+		self.listened_mod_react_message_ids[message.id] = message.created_at
16
+
17
+	@commands.Cog.listener()
18
+	async def on_raw_reaction_add(self, payload: RawReactionActionEvent):
19
+		'Event handler'
20
+		if payload.user_id == self.bot.user.id:
21
+			# Ignore bot's own reactions
22
+			return
23
+		member: Member = payload.member
24
+		if member is None:
25
+			return
26
+		guild: Guild = self.bot.get_guild(payload.guild_id)
27
+		if guild is None:
28
+			# Possibly a DM
29
+			return
30
+		channel: GuildChannel = guild.get_channel(payload.channel_id)
31
+		if channel is None:
32
+			# Possibly a DM
33
+			return
34
+		message: Message = await channel.fetch_message(payload.message_id)
35
+		if message is None:
36
+			# Message deleted?
37
+			return
38
+		if message.author.id != self.bot.user.id:
39
+			# Bot didn't author this
40
+			return
41
+		if not member.permissions_in(channel).ban_members:
42
+			# Not a mod
43
+			return
44
+		if self.listened_mod_react_message_ids.get(message.id) is None:
45
+			# Not a message we're listening for
46
+			return
47
+		await self.on_mod_react(message, payload.emoji)
48
+
49
+	async def on_mod_react(self, message: Message, emoji: PartialEmoji) -> None:
50
+		"""
51
+		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
53
+		only triggered for registered bot messages and reactions by members
54
+		with the proper permissions.
55
+		"""
56
+		pass
10 57
 
11 58
 	@classmethod
12 59
 	async def warn(cls, guild: Guild, message: str) -> Message:

+ 150
- 51
cogs/crosspostcog.py Ver fichero

@@ -1,70 +1,169 @@
1
-from rscollections import SizeBoundList
2
-from discord import Guild, Message
1
+from discord import Guild, Message, PartialEmoji
2
+from discord.ext import commands
3
+from datetime import datetime, timedelta
4
+
5
+from rscollections import AgeBoundList, SizeBoundDict
3 6
 from cogs.basecog import BaseCog
7
+from storage import Storage
4 8
 
5
-class CrossPostCog(BaseCog):
6
-	class SpamContext:
7
-		def __init__(self, member):
8
-			self.member = member
9
-			self.age = timedate.now()
10
-			self.is_warned = False
9
+class SpamContext:
10
+	def __init__(self, member, message_hash):
11
+		self.member = member
12
+		self.message_hash = message_hash
13
+		self.age = datetime.now()
14
+		self.warning_message = None
15
+		self.is_kicked = False
16
+		self.is_banned = False
17
+		self.messages = set()
18
+		self.deleted_messages = set()
11 19
 
20
+class CrossPostCog(BaseCog):
12 21
 	STATE_KEY_RECENT_MESSAGES = "crosspost_recent_messages"
13 22
 	STATE_KEY_SPAM_CONTEXT = "crosspost_spam_context"
14 23
 
15 24
 	def __init__(self, bot):
16 25
 		super().__init__(bot)
17
-		self.max_recent_messages = 20
26
+		self.max_recent_message_age = timedelta(minutes=2)
18 27
 		self.max_spam_contexts = 12
19
-		self.messages_per_user = 3
28
+		self.warn_messages_per_user = 3
29
+		self.ban_messages_per_user = 5
20 30
 		self.min_message_length = 10
21 31
 
22
-	def __record_message(self, message: Message) -> None:
32
+	async def __record_message(self, message: Message) -> None:
33
+		if message.author.permissions_in(message.channel).ban_members:
34
+			# User exempt from spam detection
35
+			return
23 36
 		recent_messages = Storage.get_state_value(message.guild, self.STATE_KEY_RECENT_MESSAGES) \
24
-			or SizeBoundList(self.max_recent_messages, lambda index, message : message.created_at)
37
+			or AgeBoundList(self.max_recent_message_age, lambda index, message : message.created_at)
25 38
 		recent_messages.append(message)
26 39
 		Storage.set_state_value(message.guild, self.STATE_KEY_RECENT_MESSAGES, recent_messages)
27 40
 
28
-	def __check_for_spam(self, guild: Guild) -> None:
29
-		recent_messages = Storage.get_state_value(guild, self.STATE_KEY_RECENT_MESSAGES)
30
-		if recent_messages is None:
41
+		# Get all recent messages by user
42
+		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:
31 44
 			return
32
-		user_id_to_count = {}
33
-		spamming_members = set()
34
-		for message in recent_messages:
35
-			content = message.content
36
-			if len(content) < self.min_message_length:
37
-				continue
38
-			key = str(message.author.id) + '|' + hash(content)
39
-			count = (user_id_to_count.get(key) or 0) + 1
40
-			user_id_to_count[key] = count
41
-			if count >= self.messages_per_user:
42
-				spamming_members.add(message.author)
43
-		for member in spamming_members:
44
-			context = self.__spam_context_for_user(member, True)
45
-			if not context.is_warned:
46
-				self.__on_new_spam(context)
47
-
48
-	def __on_new_spam(self, context: SpamContext):
49
-		context.is_warned = True
50
-		# TODO
51
-
52
-	def __spam_context_for_user(self,
53
-			member: Member,
54
-			create_if_missing: bool = False) -> SpamContext:
55
-		spam_lookup = Storage.get_state_value(member.guild, self.STATE_KEY_SPAM_CONTEXT) \
45
+
46
+		# Look for repeats
47
+		hash_to_count = {}
48
+		max_count = 0
49
+		for m in member_messages:
50
+			key = hash(m.content)
51
+			count = (hash_to_count.get(key) or 0) + 1
52
+			hash_to_count[key] = count
53
+			max_count = max(max_count, count)
54
+		if max_count < self.warn_messages_per_user:
55
+			return
56
+
57
+		# Handle the spam
58
+		spam_lookup = Storage.get_state_value(message.guild, self.STATE_KEY_SPAM_CONTEXT) \
56 59
 			or SizeBoundDict(self.max_spam_contexts, lambda key, context : context.age)
57
-		context = spam_lookup.get(member.id)
58
-		if context:
59
-			return context
60
-		if not create_if_missing:
61
-			return None
62
-		context = SpamContext(member)
63
-		spam_lookup[member.id] = context
64
-		Storage.set_state_value(member.guild, self.STATE_KEY_SPAM_CONTEXT, spam_lookup)
65
-		return context
60
+		Storage.set_state_value(message.guild, self.STATE_KEY_SPAM_CONTEXT, spam_lookup)
61
+		for message_hash, count in hash_to_count.items():
62
+			if count < self.warn_messages_per_user:
63
+				continue
64
+			key = f'{message.author.id}|{message_hash}'
65
+			context = spam_lookup.get(key)
66
+			is_new = context is None
67
+			if context is None:
68
+				context = SpamContext(message.author, message_hash)
69
+				spam_lookup[key] = context
70
+				context.age = message.created_at
71
+			for m in member_messages:
72
+				if hash(m.content) == message_hash:
73
+					context.messages.add(m)
74
+			await self.__update_from_context(context)
75
+
76
+	async def __update_from_context(self, context: SpamContext):
77
+		content = next(iter(context.messages)).content
78
+		if len(context.messages) > self.ban_messages_per_user:
79
+			if not context.is_banned:
80
+				await context.member.ban(reason='Posting same message repeatedly', delete_message_days=1)
81
+				msg = f'User {context.member.mention} auto banned for ' + \
82
+					'crosspost spamming.\n' + \
83
+					'> {content}'
84
+				if context.warning_message:
85
+					await self.update_warn(context.warning_message, msg)
86
+					await context.warning_message.clear_reaction('🗑')
87
+					await context.warning_message.clear_reaction('👢')
88
+					await context.warning_message.clear_reaction('🚫')
89
+				else:
90
+					self.warning_message = await self.warn(context.member.guild, msg)
91
+		elif len(context.messages) > self.warn_messages_per_user:
92
+			content = next(iter(context.messages)).content
93
+			msg = f'User {context.member.mention} has posted the exact same ' + \
94
+				f'message {len(context.messages)} times.\n' + \
95
+				f'> {content}' + \
96
+				'\n'
97
+			can_delete = len(context.messages) > len(context.deleted_messages)
98
+			if can_delete:
99
+				msg += '\n🗑 to delete messages'
100
+			else:
101
+				msg += '\nAll messages deleted'
102
+			if not context.is_kicked:
103
+				msg += '\n👢 to kick user'
104
+			elif not context.is_banned:
105
+				msg += '\nUser kicked'
106
+			if context.is_banned:
107
+				msg += '\nUser banned'
108
+			else:
109
+				msg += '\n🚫 to ban user'
110
+			if context.warning_message:
111
+				await self.update_warn(context.warning_message, msg)
112
+			else:
113
+				context.warning_message = await self.warn(context.member.guild, msg)
114
+				self.listen_for_reactions_to(context.warning_message)
115
+			if can_delete:
116
+				await context.warning_message.add_reaction('🗑')
117
+			else:
118
+				await context.warning_message.clear_reaction('🗑')
119
+			if not context.is_kicked:
120
+				await context.warning_message.add_reaction('👢')
121
+			else:
122
+				await context.warning_message.clear_reaction('👢')
123
+			if not context.is_banned:
124
+				await context.warning_message.add_reaction('🚫')
125
+			else:
126
+				await context.warning_message.clear_reaction('🚫')
127
+
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:
150
+		for message in context.messages - context.deleted_messages:
151
+			await message.delete()
152
+			context.deleted_messages.add(message)
153
+		await self.__update_from_context(context)
154
+
155
+	async def __kick(self, context: SpamContext) -> None:
156
+		await context.member.kick(reason='Posting same message repeatedly')
157
+		context.is_kicked = True
158
+		await self.__update_from_context(context)
159
+
160
+	async def __ban(self, context: SpamContext) -> None:
161
+		await context.member.ban(reason='Posting same message repeatedly', delete_message_days=1)
162
+		context.deleted_messages |= context.messages
163
+		context.is_kicked = True
164
+		context.is_banned = True
165
+		await self.__update_from_context(context)
66 166
 
67 167
 	@commands.Cog.listener()
68
-	def on_message(self, message: Message):
69
-		self.__record_message(message)
70
-		self.__check_for_spam(message.guild)
168
+	async def on_message(self, message: Message):
169
+		await self.__record_message(message)

+ 507
- 507
cogs/joinraidcog.py
La diferencia del archivo ha sido suprimido porque es demasiado grande
Ver fichero


+ 4
- 2
rocketbot.py Ver fichero

@@ -10,12 +10,13 @@ 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 14
 from cogs.generalcog import GeneralCog
14 15
 from cogs.joinraidcog import JoinRaidCog
15 16
 
16 17
 class Rocketbot(commands.Bot):
17
-    def __init__(self, command_prefix, **kwargs):
18
-        super().__init__(command_prefix, **kwargs)
18
+	def __init__(self, command_prefix, **kwargs):
19
+		super().__init__(command_prefix, **kwargs)
19 20
 
20 21
 intents = Intents.default()
21 22
 intents.messages = True
@@ -24,5 +25,6 @@ bot = Rocketbot(command_prefix=CONFIG['commandPrefix'], intents=intents)
24 25
 bot.add_cog(GeneralCog(bot))
25 26
 bot.add_cog(ConfigCog(bot))
26 27
 bot.add_cog(JoinRaidCog(bot))
28
+# bot.add_cog(CrossPostCog(bot))
27 29
 bot.run(CONFIG['clientToken'], bot=True, reconnect=True)
28 30
 print('\nBot aborted')

+ 3
- 3
rscollections.py Ver fichero

@@ -407,7 +407,7 @@ class AgeBoundList(AbstractMutableList):
407 407
 	The `element_age` lambda takes two arguments: the element index and the
408 408
 	element value. It must return values that can be compared to one another
409 409
 	and be added and subtracted (e.g. int, float, datetime). If the lambda
410
-	returns `datetime`s, the `max_age` should be a `timespan`.
410
+	returns `datetime`s, the `max_age` should be a `timedelta`.
411 411
 
412 412
 	`self.element_age` and `self.max_age` can be modified at runtime,
413 413
 	however elements will only be discarded following the next mutating
@@ -466,7 +466,7 @@ class AgeBoundSet(AbstractMutableSet):
466 466
 	The `element_age` lambda takes one argument: the element value. It must
467 467
 	return values that can be compared to one another and be added and
468 468
 	subtracted (e.g. int, float, datetime). If the lambda returns `datetime`s,
469
-	the `max_age` should be a `timespan`.
469
+	the `max_age` should be a `timedelta`.
470 470
 
471 471
 	`self.element_age` and `self.max_age` can be modified at runtime,
472 472
 	however elements will only be discarded following the next mutating
@@ -524,7 +524,7 @@ class AgeBoundDict(AbstractMutableDict):
524 524
 	The `element_age` lambda takes two arguments: the key and value of a pair.
525 525
 	It must return values that can be compared to one another and be added and
526 526
 	subtracted (e.g. int, float, datetime). If the lambda returns `datetime`s,
527
-	the `max_age` should be a `timespan`.
527
+	the `max_age` should be a `timedelta`.
528 528
 
529 529
 	`self.element_age` and `self.max_age` can be modified at runtime,
530 530
 	however elements will only be discarded following the next mutating

Loading…
Cancelar
Guardar