|
|
@@ -1,7 +1,8 @@
|
|
1
|
1
|
"""
|
|
2
|
2
|
Cog for detecting spam messages posted in multiple channels.
|
|
3
|
3
|
"""
|
|
4
|
|
-from datetime import datetime, timedelta
|
|
|
4
|
+import re
|
|
|
5
|
+from datetime import datetime, timedelta, timezone
|
|
5
|
6
|
from typing import Optional
|
|
6
|
7
|
|
|
7
|
8
|
from discord import Member, Message, utils as discordutils, TextChannel
|
|
|
@@ -9,16 +10,17 @@ from discord.ext import commands
|
|
9
|
10
|
|
|
10
|
11
|
from config import CONFIG
|
|
11
|
12
|
from rocketbot.cogs.basecog import BaseCog, BotMessage, BotMessageReaction, CogSetting
|
|
12
|
|
-from rocketbot.collections import AgeBoundList, SizeBoundDict
|
|
|
13
|
+from rocketbot.collections import AgeBoundList, AgeBoundDict
|
|
13
|
14
|
from rocketbot.storage import Storage
|
|
|
15
|
+from rocketbot.utils import str_from_timedelta
|
|
|
16
|
+
|
|
14
|
17
|
|
|
15
|
18
|
class SpamContext:
|
|
16
|
19
|
"""
|
|
17
|
20
|
Data about a set of duplicate messages from a user.
|
|
18
|
21
|
"""
|
|
19
|
|
- def __init__(self, member: Member, message_hash: int) -> None:
|
|
|
22
|
+ def __init__(self, member: Member) -> None:
|
|
20
|
23
|
self.member: Member = member
|
|
21
|
|
- self.message_hash: int = message_hash
|
|
22
|
24
|
self.age: datetime = datetime.now()
|
|
23
|
25
|
self.bot_message: Optional[BotMessage] = None
|
|
24
|
26
|
self.is_kicked: bool = False
|
|
|
@@ -27,28 +29,49 @@ class SpamContext:
|
|
27
|
29
|
self.spam_messages: set[Message] = set()
|
|
28
|
30
|
self.deleted_messages: set[Message] = set()
|
|
29
|
31
|
self.unique_channels: set[TextChannel] = set()
|
|
|
32
|
+ self.duplicate_count: int = 0
|
|
30
|
33
|
|
|
31
|
34
|
class CrossPostCog(BaseCog, name='Crosspost Detection'):
|
|
32
|
35
|
"""
|
|
33
|
|
- Detects a user posting the same text in multiple channels in a short period
|
|
34
|
|
- of time: a common pattern for spammers. Repeated posts in the same channel
|
|
35
|
|
- aren't detected, as this can often be for a reason or due to trying a
|
|
36
|
|
- failed post when connectivity is poor. Minimum message length can be
|
|
37
|
|
- enforced for detection. Minimum is always at least 1 to ignore posts with
|
|
38
|
|
- just embeds or images and no text.
|
|
|
36
|
+ Detects a user posting in multiple channels in a short period
|
|
|
37
|
+ of time: a common pattern for spammers.
|
|
|
38
|
+
|
|
|
39
|
+ These used to be identical text, but more recent attacks have had small
|
|
|
40
|
+ variations, such as different imgur URLs. It's reasonable to treat
|
|
|
41
|
+ posting in many channels in a short period as suspicious on its own,
|
|
|
42
|
+ regardless of whether they are identical.
|
|
|
43
|
+
|
|
|
44
|
+ Repeated posts in the same channel aren't currently detected, as this can
|
|
|
45
|
+ often be for a reason or due to trying a failed post when connectivity is
|
|
|
46
|
+ poor. Minimum message length can be enforced for detection.
|
|
39
|
47
|
"""
|
|
40
|
48
|
SETTING_ENABLED = CogSetting('enabled', bool,
|
|
41
|
49
|
brief='crosspost detection',
|
|
42
|
50
|
description='Whether crosspost detection is enabled.')
|
|
43
|
51
|
SETTING_WARN_COUNT = CogSetting('warncount', int,
|
|
44
|
52
|
brief='number of messages to trigger a warning',
|
|
45
|
|
- description='The number of unique channels the same message is ' + \
|
|
|
53
|
+ description='The number of unique channels messages are ' + \
|
|
|
54
|
+ 'posted in by the same user to trigger a mod warning. The ' + \
|
|
|
55
|
+ 'messages need not be identical (see dupewarncount).',
|
|
|
56
|
+ usage='<count:int>',
|
|
|
57
|
+ min_value=2)
|
|
|
58
|
+ SETTING_DUPE_WARN_COUNT = CogSetting('dupewarncount', int,
|
|
|
59
|
+ brief='number of identical messages to trigger a warning',
|
|
|
60
|
+ description='The number of unique channels identical messages are ' + \
|
|
46
|
61
|
'posted in by the same user to trigger a mod warning.',
|
|
47
|
62
|
usage='<count:int>',
|
|
48
|
63
|
min_value=2)
|
|
49
|
64
|
SETTING_BAN_COUNT = CogSetting('bancount', int,
|
|
50
|
65
|
brief='number of messages to trigger a ban',
|
|
51
|
|
- description='The number of unique channels the same message is ' + \
|
|
|
66
|
+ description='The number of unique channels messages are ' + \
|
|
|
67
|
+ 'posted in by the same user to trigger an automatic ban. The ' + \
|
|
|
68
|
+ 'messages need not be identical (see dupebancount). Set ' + \
|
|
|
69
|
+ 'to a large value to effectively disable, e.g. 9999.',
|
|
|
70
|
+ usage='<count:int>',
|
|
|
71
|
+ min_value=2)
|
|
|
72
|
+ SETTING_DUPE_BAN_COUNT = CogSetting('dupebancount', int,
|
|
|
73
|
+ brief='number of identical messages to trigger a ban',
|
|
|
74
|
+ description='The number of unique channels identical messages are ' + \
|
|
52
|
75
|
'posted in by the same user to trigger an automatic ban. Set ' + \
|
|
53
|
76
|
'to a large value to effectively disable, e.g. 9999.',
|
|
54
|
77
|
usage='<count:int>',
|
|
|
@@ -75,7 +98,9 @@ class CrossPostCog(BaseCog, name='Crosspost Detection'):
|
|
75
|
98
|
super().__init__(bot)
|
|
76
|
99
|
self.add_setting(CrossPostCog.SETTING_ENABLED)
|
|
77
|
100
|
self.add_setting(CrossPostCog.SETTING_WARN_COUNT)
|
|
|
101
|
+ self.add_setting(CrossPostCog.SETTING_DUPE_WARN_COUNT)
|
|
78
|
102
|
self.add_setting(CrossPostCog.SETTING_BAN_COUNT)
|
|
|
103
|
+ self.add_setting(CrossPostCog.SETTING_DUPE_BAN_COUNT)
|
|
79
|
104
|
self.add_setting(CrossPostCog.SETTING_MIN_LENGTH)
|
|
80
|
105
|
self.add_setting(CrossPostCog.SETTING_TIMESPAN)
|
|
81
|
106
|
self.max_spam_contexts = 12
|
|
|
@@ -83,99 +108,121 @@ class CrossPostCog(BaseCog, name='Crosspost Detection'):
|
|
83
|
108
|
async def __record_message(self, message: Message) -> None:
|
|
84
|
109
|
if message.channel.permissions_for(message.author).ban_members:
|
|
85
|
110
|
# User exempt from spam detection
|
|
|
111
|
+ self.__trace("User exempt from crosspost checks")
|
|
86
|
112
|
return
|
|
87
|
113
|
def compute_message_hash(m: Message) -> int:
|
|
88
|
114
|
to_hash = m.content
|
|
|
115
|
+ # URLs sometimes differ per spam message, so simplify them
|
|
|
116
|
+ url_regex = r'http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+'
|
|
|
117
|
+ to_hash = re.sub(url_regex, '<url>', to_hash)
|
|
|
118
|
+ # Add attachment metadata
|
|
89
|
119
|
for attachment in m.attachments:
|
|
90
|
120
|
to_hash += f'\n[[ATT: ct={attachment.content_type} s={attachment.size} w={attachment.width} h={attachment.height}]]'
|
|
91
|
121
|
h = hash(to_hash)
|
|
|
122
|
+ self.__trace(f"Message hash for {m.id} is {h}")
|
|
92
|
123
|
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):
|
|
|
124
|
+
|
|
|
125
|
+ min_length = self.get_guild_setting(message.guild, self.SETTING_MIN_LENGTH)
|
|
|
126
|
+ if len(message.attachments) == 0 and len(message.content) < min_length:
|
|
95
|
127
|
# Message too short to count towards spam total
|
|
|
128
|
+ self.__trace(f"Message len {len(message.content)} < {min_length}")
|
|
96
|
129
|
return
|
|
|
130
|
+
|
|
|
131
|
+ # Get config
|
|
97
|
132
|
max_age = timedelta(seconds=self.get_guild_setting(message.guild, self.SETTING_TIMESPAN))
|
|
98
|
133
|
warn_count: int = self.get_guild_setting(message.guild, self.SETTING_WARN_COUNT)
|
|
|
134
|
+ dupe_warn_count: int = self.get_guild_setting(message.guild, self.SETTING_DUPE_WARN_COUNT)
|
|
|
135
|
+
|
|
|
136
|
+ # Record message
|
|
99
|
137
|
recent_messages: AgeBoundList[Message, datetime, timedelta] = Storage.get_state_value(message.guild, self.STATE_KEY_RECENT_MESSAGES)
|
|
100
|
138
|
if recent_messages is None:
|
|
101
|
139
|
recent_messages = AgeBoundList(max_age, lambda index, message : message.created_at)
|
|
102
|
140
|
Storage.set_state_value(message.guild, self.STATE_KEY_RECENT_MESSAGES, recent_messages)
|
|
103
|
141
|
recent_messages.max_age = max_age
|
|
104
|
142
|
recent_messages.append(message)
|
|
|
143
|
+ self.__trace(f"Recent messages now length {len(recent_messages)}")
|
|
105
|
144
|
|
|
106
|
145
|
# Get all recent messages by user
|
|
107
|
146
|
member_messages = [m for m in recent_messages if m.author.id == message.author.id]
|
|
108
|
|
- if len(member_messages) < warn_count:
|
|
|
147
|
+ message_count = len(member_messages)
|
|
|
148
|
+ self.__trace(f"Found {message_count} messages for {message.author.name}")
|
|
|
149
|
+ if message_count < warn_count and message_count < dupe_warn_count:
|
|
|
150
|
+ self.__trace(f"Bailing because message count {message_count} < warn count {warn_count} and < dupe warn count {dupe_warn_count}")
|
|
109
|
151
|
return
|
|
110
|
152
|
|
|
111
|
|
- # Look for repeats
|
|
|
153
|
+ # Look for identical(ish) messages and unique channels
|
|
112
|
154
|
hash_to_channels: dict[int, set[TextChannel]] = {}
|
|
113
|
|
- max_count = 0
|
|
|
155
|
+ unique_channels: set[TextChannel] = set()
|
|
|
156
|
+ max_duplicate_count = 0
|
|
114
|
157
|
for m in member_messages:
|
|
115
|
158
|
message_hash = compute_message_hash(m)
|
|
116
|
|
- channels: set[TextChannel] = hash_to_channels.get(message_hash)
|
|
117
|
|
- if channels is None:
|
|
118
|
|
- channels = set()
|
|
119
|
|
- hash_to_channels[message_hash] = channels
|
|
120
|
|
- channels.add(m.channel)
|
|
121
|
|
- max_count = max(max_count, len(channels))
|
|
122
|
|
- if max_count < warn_count:
|
|
|
159
|
+ dupe_message_channels: set[TextChannel] = hash_to_channels.get(message_hash)
|
|
|
160
|
+ if dupe_message_channels is None:
|
|
|
161
|
+ dupe_message_channels = set()
|
|
|
162
|
+ hash_to_channels[message_hash] = dupe_message_channels
|
|
|
163
|
+ dupe_message_channels.add(m.channel)
|
|
|
164
|
+ unique_channels.add(m.channel)
|
|
|
165
|
+ max_duplicate_count = max(max_duplicate_count, len(dupe_message_channels))
|
|
|
166
|
+ channel_count = len(unique_channels)
|
|
|
167
|
+ self.__trace(f"Found {len(hash_to_channels)} unique messages, {channel_count} unique channels, {max_duplicate_count} duplicated messages")
|
|
|
168
|
+ if channel_count < warn_count and max_duplicate_count < dupe_warn_count:
|
|
|
169
|
+ self.__trace(f"Bailing because channels {channel_count} < warn count {warn_count} and max dupes {max_duplicate_count} < dupe warn count {dupe_warn_count}")
|
|
123
|
170
|
return
|
|
124
|
171
|
|
|
125
|
|
- # Handle the spam
|
|
126
|
|
- spam_lookup: SizeBoundDict[str, SpamContext, datetime] = Storage.get_state_value(message.guild, self.STATE_KEY_SPAM_CONTEXT)
|
|
|
172
|
+ # This person is a problem
|
|
|
173
|
+
|
|
|
174
|
+ spam_lookup: AgeBoundDict[str, SpamContext, datetime, timedelta] = Storage.get_state_value(message.guild, self.STATE_KEY_SPAM_CONTEXT)
|
|
127
|
175
|
if spam_lookup is None:
|
|
128
|
|
- spam_lookup = SizeBoundDict(
|
|
129
|
|
- self.max_spam_contexts,
|
|
|
176
|
+ spam_lookup = AgeBoundDict(
|
|
|
177
|
+ max_age,
|
|
130
|
178
|
lambda key, context : context.age)
|
|
131
|
179
|
Storage.set_state_value(message.guild, self.STATE_KEY_SPAM_CONTEXT, spam_lookup)
|
|
132
|
|
- for message_hash, channels in hash_to_channels.items():
|
|
133
|
|
- channel_count = len(channels)
|
|
134
|
|
- if channel_count < warn_count:
|
|
135
|
|
- continue
|
|
136
|
|
- key = f'{message.author.id}|{message_hash}'
|
|
137
|
|
- context = spam_lookup.get(key)
|
|
138
|
|
- if context is None:
|
|
139
|
|
- context = SpamContext(message.author, message_hash)
|
|
140
|
|
- spam_lookup[key] = context
|
|
141
|
|
- context.age = message.created_at
|
|
142
|
|
- self.log(message.guild,
|
|
143
|
|
- f'\u0007{message.author.name} ({message.author.id}) ' + \
|
|
144
|
|
- f'posted the same message in {channel_count} or more channels.')
|
|
145
|
|
- for m in member_messages:
|
|
146
|
|
- if compute_message_hash(m) == message_hash:
|
|
147
|
|
- context.spam_messages.add(m)
|
|
148
|
|
- context.unique_channels.add(m.channel)
|
|
149
|
|
- await self.__update_from_context(context)
|
|
|
180
|
+ key = f'{message.author.id}'
|
|
|
181
|
+ context = spam_lookup.get(key)
|
|
|
182
|
+ if context is not None and message.created_at - context.age > max_age:
|
|
|
183
|
+ context = None
|
|
|
184
|
+ if context is None:
|
|
|
185
|
+ context = SpamContext(message.author)
|
|
|
186
|
+ spam_lookup[key] = context
|
|
|
187
|
+ self.log(message.guild,
|
|
|
188
|
+ f'\u0007{message.author.name} ({message.author.id}) ' + \
|
|
|
189
|
+ f'posted messages in {channel_count} channels.')
|
|
|
190
|
+ context.age = message.created_at
|
|
|
191
|
+ context.duplicate_count = max_duplicate_count
|
|
|
192
|
+ context.spam_messages.update(member_messages)
|
|
|
193
|
+ context.unique_channels.update(unique_channels)
|
|
|
194
|
+ await self.__update_from_context(context)
|
|
150
|
195
|
|
|
151
|
196
|
async def __update_from_context(self, context: SpamContext):
|
|
152
|
197
|
ban_count = self.get_guild_setting(context.member.guild, self.SETTING_BAN_COUNT)
|
|
|
198
|
+ dupe_ban_count = self.get_guild_setting(context.member.guild, self.SETTING_DUPE_BAN_COUNT)
|
|
153
|
199
|
channel_count = len(context.unique_channels)
|
|
154
|
|
- if channel_count >= ban_count:
|
|
|
200
|
+ if channel_count >= ban_count or context.duplicate_count >= dupe_ban_count:
|
|
155
|
201
|
if not context.is_banned:
|
|
|
202
|
+ max_age = timedelta(seconds=self.get_guild_setting(context.member.guild, self.SETTING_TIMESPAN))
|
|
|
203
|
+ max_age_str = str_from_timedelta(max_age)
|
|
156
|
204
|
await context.member.ban(
|
|
157
|
|
- reason='Rocketbot: Posted same message in ' + \
|
|
158
|
|
- f'{channel_count} channels. Banned by ' + \
|
|
159
|
|
- f'{self.bot.user.name}.',
|
|
|
205
|
+ reason=f'Rocketbot: Posted in {channel_count} channels within {max_age_str} ' + \
|
|
|
206
|
+ f'({context.duplicate_count} identical). Banned by {self.bot.user.name}.',
|
|
160
|
207
|
delete_message_days=1)
|
|
161
|
208
|
context.is_kicked = True
|
|
162
|
209
|
context.is_banned = True
|
|
163
|
210
|
context.is_autobanned = True
|
|
164
|
211
|
context.deleted_messages |= context.spam_messages
|
|
165
|
|
- self.log(context.member.guild,
|
|
166
|
|
- f'{context.member.name} ({context.member.id}) posted ' + \
|
|
167
|
|
- f'same message in {channel_count} channels. Banned by ' + \
|
|
168
|
|
- f'{self.bot.user.name}.')
|
|
|
212
|
+ self.__log_ban(context, self.bot.user.name)
|
|
169
|
213
|
else:
|
|
170
|
214
|
# Already banned. Nothing to update in the message.
|
|
171
|
215
|
return
|
|
172
|
216
|
await self.__update_message_from_context(context)
|
|
173
|
217
|
|
|
174
|
218
|
async def __update_message_from_context(self, context: SpamContext) -> None:
|
|
175
|
|
- first_spam_message: Message = next(iter(context.spam_messages))
|
|
|
219
|
+ first_spam_message: Message = sorted(list(context.spam_messages), key=lambda m: m.created_at)[0]
|
|
176
|
220
|
spam_count = len(context.spam_messages)
|
|
177
|
221
|
channel_count = len(context.unique_channels)
|
|
178
|
|
- deleted_count = len(context.spam_messages)
|
|
|
222
|
+ deleted_count = len(context.deleted_messages)
|
|
|
223
|
+ duplicate_count = context.duplicate_count
|
|
|
224
|
+ max_age = timedelta(seconds=self.get_guild_setting(context.member.guild, self.SETTING_TIMESPAN))
|
|
|
225
|
+ max_age_str = str_from_timedelta(max_age)
|
|
179
|
226
|
message = context.bot_message
|
|
180
|
227
|
if message is None:
|
|
181
|
228
|
message_type: int = BotMessage.TYPE_INFO if self.was_warned_recently(context.member) \
|
|
|
@@ -185,15 +232,25 @@ class CrossPostCog(BaseCog, name='Crosspost Detection'):
|
|
185
|
232
|
self.record_warning(context.member)
|
|
186
|
233
|
if context.is_autobanned:
|
|
187
|
234
|
text = f'User {context.member.mention} auto banned for ' + \
|
|
188
|
|
- f'posting the same message in {channel_count} channels. ' + \
|
|
189
|
|
- 'Messages from past 24 hours deleted.'
|
|
|
235
|
+ f'posting messages in {channel_count} channels within {max_age_str} ' + \
|
|
|
236
|
+ f'({duplicate_count} identical). Messages from past 24 hours deleted.'
|
|
190
|
237
|
await message.set_reactions([])
|
|
191
|
238
|
await message.set_text(text)
|
|
192
|
239
|
else:
|
|
193
|
|
- body: str = f'User {context.member.mention} posted ' + \
|
|
194
|
|
- f'the same message in {channel_count} channels.'
|
|
195
|
|
- for msg in context.spam_messages:
|
|
|
240
|
+ body: str = f'User {context.member.mention} posted '
|
|
|
241
|
+ if duplicate_count == channel_count:
|
|
|
242
|
+ body += f'identical messages in {channel_count} channels within {max_age_str} .'
|
|
|
243
|
+ elif duplicate_count == 1:
|
|
|
244
|
+ body += f'**different** messages in {channel_count} channels within ' + \
|
|
|
245
|
+ f'{max_age_str}. (Showing first one).'
|
|
|
246
|
+ else:
|
|
|
247
|
+ body += f'messages in {channel_count} channels within {max_age_str} ' + \
|
|
|
248
|
+ f'({duplicate_count} are identical, showing first one).'
|
|
|
249
|
+ max_links = 10
|
|
|
250
|
+ for msg in sorted(list(context.spam_messages), key=lambda m: m.created_at)[:max_links]:
|
|
196
|
251
|
body += f'\n- {msg.jump_url}'
|
|
|
252
|
+ if len(context.spam_messages) > max_links:
|
|
|
253
|
+ body += f'\n- ...{len(context.spam_messages) - max_links} more...'
|
|
197
|
254
|
await message.set_text(body)
|
|
198
|
255
|
await message.set_reactions(BotMessageReaction.standard_set(
|
|
199
|
256
|
did_delete = deleted_count >= spam_count,
|
|
|
@@ -218,30 +275,58 @@ class CrossPostCog(BaseCog, name='Crosspost Detection'):
|
|
218
|
275
|
await message.delete()
|
|
219
|
276
|
context.deleted_messages.add(message)
|
|
220
|
277
|
await self.__update_from_context(context)
|
|
221
|
|
- self.log(context.member.guild,
|
|
222
|
|
- f'{context.member.name} ({context.member.id}) posted same ' + \
|
|
223
|
|
- f'message in {channel_count} channels. Deleted by {reacted_by.name}.')
|
|
|
278
|
+ self.__log_deletion(context, reacted_by.name)
|
|
224
|
279
|
elif reaction.emoji == CONFIG['kick_emoji']:
|
|
225
|
280
|
await context.member.kick(
|
|
226
|
|
- reason=f'Rocketbot: Posted same message in {channel_count} ' + \
|
|
|
281
|
+ reason=f'Rocketbot: Posted messages in {channel_count} ' + \
|
|
227
|
282
|
f'channels. Kicked by {reacted_by.name}.')
|
|
228
|
283
|
context.is_kicked = True
|
|
229
|
284
|
await self.__update_from_context(context)
|
|
230
|
|
- self.log(context.member.guild,
|
|
231
|
|
- f'{context.member.name} ({context.member.id}) posted same ' + \
|
|
232
|
|
- f'message in {channel_count} channels. Kicked by {reacted_by.name}.')
|
|
|
285
|
+ self.__log_kick(context, reacted_by.name)
|
|
233
|
286
|
elif reaction.emoji == CONFIG['ban_emoji']:
|
|
234
|
287
|
await context.member.ban(
|
|
235
|
|
- reason=f'Rocketbot: Posted same message in {channel_count} ' + \
|
|
|
288
|
+ reason=f'Rocketbot: Posted messages in {channel_count} ' + \
|
|
236
|
289
|
f'channels. Banned by {reacted_by.name}.',
|
|
237
|
290
|
delete_message_days=1)
|
|
238
|
291
|
context.deleted_messages |= context.spam_messages
|
|
239
|
292
|
context.is_kicked = True
|
|
240
|
293
|
context.is_banned = True
|
|
241
|
294
|
await self.__update_from_context(context)
|
|
242
|
|
- self.log(context.member.guild,
|
|
243
|
|
- f'{context.member.name} ({context.member.id}) posted same ' + \
|
|
244
|
|
- f'message in {channel_count} channels. Kicked by {reacted_by.name}.')
|
|
|
295
|
+ self.__log_ban(context, reacted_by.name)
|
|
|
296
|
+
|
|
|
297
|
+ def __log_deletion(self, context: SpamContext, by_who: str) -> None:
|
|
|
298
|
+ max_age = timedelta(seconds=self.get_guild_setting(context.member.guild, self.SETTING_TIMESPAN))
|
|
|
299
|
+ max_age_str = str_from_timedelta(max_age)
|
|
|
300
|
+ channel_count = len(context.unique_channels)
|
|
|
301
|
+ duplicate_count = context.duplicate_count
|
|
|
302
|
+ self.log(context.member.guild,
|
|
|
303
|
+ f'{context.member.name} ({context.member.id}) posted ' + \
|
|
|
304
|
+ f'messages in {channel_count} channels withint {max_age_str} ' + \
|
|
|
305
|
+ f'({duplicate_count} identical). Deleted by {by_who}.')
|
|
|
306
|
+
|
|
|
307
|
+ def __log_kick(self, context: SpamContext, by_who: str) -> None:
|
|
|
308
|
+ max_age = timedelta(seconds=self.get_guild_setting(context.member.guild, self.SETTING_TIMESPAN))
|
|
|
309
|
+ max_age_str = str_from_timedelta(max_age)
|
|
|
310
|
+ channel_count = len(context.unique_channels)
|
|
|
311
|
+ duplicate_count = context.duplicate_count
|
|
|
312
|
+ self.log(context.member.guild,
|
|
|
313
|
+ f'{context.member.name} ({context.member.id}) posted ' + \
|
|
|
314
|
+ f'messages in {channel_count} channels within {max_age_str} ' + \
|
|
|
315
|
+ f'({duplicate_count} identical). Kicked by {by_who}.')
|
|
|
316
|
+
|
|
|
317
|
+ def __log_ban(self, context: SpamContext, by_who: str) -> None:
|
|
|
318
|
+ max_age = timedelta(seconds=self.get_guild_setting(context.member.guild, self.SETTING_TIMESPAN))
|
|
|
319
|
+ max_age_str = str_from_timedelta(max_age)
|
|
|
320
|
+ channel_count = len(context.unique_channels)
|
|
|
321
|
+ duplicate_count = context.duplicate_count
|
|
|
322
|
+ self.log(context.member.guild,
|
|
|
323
|
+ f'{context.member.name} ({context.member.id}) posted ' + \
|
|
|
324
|
+ f'messages in {channel_count} channels within {max_age_str} ' + \
|
|
|
325
|
+ f'({duplicate_count} identical). Banned by {by_who}.')
|
|
|
326
|
+
|
|
|
327
|
+ def __trace(self, message):
|
|
|
328
|
+ # print(message)
|
|
|
329
|
+ pass
|
|
245
|
330
|
|
|
246
|
331
|
@commands.Cog.listener()
|
|
247
|
332
|
async def on_message(self, message: Message):
|
|
|
@@ -249,11 +334,11 @@ class CrossPostCog(BaseCog, name='Crosspost Detection'):
|
|
249
|
334
|
if message.author is None or \
|
|
250
|
335
|
message.author.bot or \
|
|
251
|
336
|
message.channel is None or \
|
|
252
|
|
- message.guild is None or \
|
|
253
|
|
- message.content is None:
|
|
|
337
|
+ message.guild is None:
|
|
254
|
338
|
return
|
|
255
|
339
|
if not self.get_guild_setting(message.guild, self.SETTING_ENABLED):
|
|
256
|
340
|
return
|
|
|
341
|
+ self.__trace("--ON MESSAGE--")
|
|
257
|
342
|
await self.__record_message(message)
|
|
258
|
343
|
|
|
259
|
344
|
@commands.group(
|
|
|
@@ -262,6 +347,6 @@ class CrossPostCog(BaseCog, name='Crosspost Detection'):
|
|
262
|
347
|
@commands.has_permissions(ban_members=True)
|
|
263
|
348
|
@commands.guild_only()
|
|
264
|
349
|
async def crosspost(self, context: commands.Context):
|
|
265
|
|
- """Crosspost detection command group"""
|
|
|
350
|
+ """Detects members posting messages in multiple channels in a short period of time."""
|
|
266
|
351
|
if context.invoked_subcommand is None:
|
|
267
|
352
|
await context.send_help()
|