|
|
@@ -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)
|