|
|
@@ -5,7 +5,7 @@ import math
|
|
5
|
5
|
|
|
6
|
6
|
from config import CONFIG
|
|
7
|
7
|
from rscollections import AgeBoundList, SizeBoundDict
|
|
8
|
|
-from cogs.basecog import BaseCog, BotMessage, BotMessageReaction
|
|
|
8
|
+from cogs.basecog import BaseCog, BotMessage, BotMessageReaction, CogSetting
|
|
9
|
9
|
from storage import Storage
|
|
10
|
10
|
|
|
11
|
11
|
class SpamContext:
|
|
|
@@ -21,6 +21,33 @@ class SpamContext:
|
|
21
|
21
|
self.deleted_messages = set() # of Message
|
|
22
|
22
|
|
|
23
|
23
|
class CrossPostCog(BaseCog):
|
|
|
24
|
+ SETTING_WARN_COUNT = CogSetting('warncount',
|
|
|
25
|
+ brief='number of messages to trigger a warning',
|
|
|
26
|
+ description='The number of identical messages to trigger a mod warning.',
|
|
|
27
|
+ usage='<count:int>',
|
|
|
28
|
+ min_value=2)
|
|
|
29
|
+ SETTING_BAN_COUNT = CogSetting('bancount',
|
|
|
30
|
+ brief='number of messages to trigger a ban',
|
|
|
31
|
+ description='The number of identical messages to trigger an ' + \
|
|
|
32
|
+ 'automatic ban. Set to a large value to effectively disable, e.g. 9999.',
|
|
|
33
|
+ usage='<count:int>',
|
|
|
34
|
+ min_value=2)
|
|
|
35
|
+ SETTING_MIN_LENGTH = CogSetting('minlength',
|
|
|
36
|
+ brief='minimum message length',
|
|
|
37
|
+ description='The minimum number of characters in a message to be ' + \
|
|
|
38
|
+ 'checked for duplicates. This can help ignore common short ' + \
|
|
|
39
|
+ 'messages like "lol" or a single emoji. Set to 0 to count all ' + \
|
|
|
40
|
+ 'message lengths.',
|
|
|
41
|
+ usage='<character_count:int>',
|
|
|
42
|
+ min_value=0)
|
|
|
43
|
+ SETTING_TIMESPAN = CogSetting('timespan',
|
|
|
44
|
+ brief='time window to look for dupe messages',
|
|
|
45
|
+ description='The number of seconds of message history to look at ' + \
|
|
|
46
|
+ 'when looking for duplicates. Shorter values are preferred, ' + \
|
|
|
47
|
+ 'both to detect bots and avoid excessive memory usage.',
|
|
|
48
|
+ usage='<seconds:int>',
|
|
|
49
|
+ min_value=1)
|
|
|
50
|
+
|
|
24
|
51
|
STATE_KEY_RECENT_MESSAGES = "crosspost_recent_messages"
|
|
25
|
52
|
STATE_KEY_SPAM_CONTEXT = "crosspost_spam_context"
|
|
26
|
53
|
|
|
|
@@ -29,41 +56,24 @@ class CrossPostCog(BaseCog):
|
|
29
|
56
|
CONFIG_KEY_MIN_MESSAGE_LENGTH = "crosspost_min_message_length"
|
|
30
|
57
|
CONFIG_KEY_MESSAGE_AGE = "crosspost_message_age"
|
|
31
|
58
|
|
|
32
|
|
- MIN_WARN_COUNT = 2
|
|
33
|
|
- MIN_BAN_COUNT = 2
|
|
34
|
|
- MIN_MESSAGE_LENGTH = 0
|
|
35
|
|
- MIN_TIME_SPAN = 1
|
|
36
|
|
-
|
|
37
|
59
|
def __init__(self, bot):
|
|
38
|
60
|
super().__init__(bot)
|
|
|
61
|
+ self.add_setting(CrossPostCog.SETTING_WARN_COUNT)
|
|
|
62
|
+ self.add_setting(CrossPostCog.SETTING_BAN_COUNT)
|
|
|
63
|
+ self.add_setting(CrossPostCog.SETTING_MIN_LENGTH)
|
|
|
64
|
+ self.add_setting(CrossPostCog.SETTING_TIMESPAN)
|
|
39
|
65
|
self.max_spam_contexts = 12
|
|
40
|
66
|
|
|
41
|
67
|
# Config
|
|
42
|
68
|
|
|
43
|
|
- def __warn_count(self, guild: Guild) -> int:
|
|
44
|
|
- return Storage.get_config_value(guild, self.CONFIG_KEY_WARN_COUNT) or \
|
|
45
|
|
- self.get_cog_default('warn_message_count')
|
|
46
|
|
-
|
|
47
|
|
- def __ban_count(self, guild: Guild) -> int:
|
|
48
|
|
- return Storage.get_config_value(guild, self.CONFIG_KEY_BAN_COUNT) or \
|
|
49
|
|
- self.get_cog_default('ban_message_count')
|
|
50
|
|
-
|
|
51
|
|
- def __min_message_length(self, guild: Guild) -> int:
|
|
52
|
|
- return Storage.get_config_value(guild, self.CONFIG_KEY_MIN_MESSAGE_LENGTH) or \
|
|
53
|
|
- self.get_cog_default('min_message_length')
|
|
54
|
|
-
|
|
55
|
|
- def __message_age_seconds(self, guild: Guild) -> int:
|
|
56
|
|
- return Storage.get_config_value(guild, self.CONFIG_KEY_MESSAGE_AGE) or \
|
|
57
|
|
- self.get_cog_default('time_window_seconds')
|
|
58
|
|
-
|
|
59
|
69
|
async def __record_message(self, message: Message) -> None:
|
|
60
|
70
|
if message.author.permissions_in(message.channel).ban_members:
|
|
61
|
71
|
# User exempt from spam detection
|
|
62
|
72
|
return
|
|
63
|
|
- if len(message.content) < self.__min_message_length(message.guild):
|
|
|
73
|
+ if len(message.content) < self.get_guild_setting(message.guild, self.SETTING_MIN_LENGTH):
|
|
64
|
74
|
# Message too short to count towards spam total
|
|
65
|
75
|
return
|
|
66
|
|
- max_age = timedelta(seconds=self.__message_age_seconds(message.guild))
|
|
|
76
|
+ max_age = timedelta(seconds=self.get_guild_setting(message.guild, self.SETTING_TIMESPAN))
|
|
67
|
77
|
recent_messages = Storage.get_state_value(message.guild, self.STATE_KEY_RECENT_MESSAGES) \
|
|
68
|
78
|
or AgeBoundList(max_age, lambda index, message : message.created_at)
|
|
69
|
79
|
recent_messages.max_age = max_age
|
|
|
@@ -72,7 +82,7 @@ class CrossPostCog(BaseCog):
|
|
72
|
82
|
|
|
73
|
83
|
# Get all recent messages by user
|
|
74
|
84
|
member_messages = [m for m in recent_messages if m.author.id == message.author.id]
|
|
75
|
|
- if len(member_messages) < self.__warn_count(message.guild):
|
|
|
85
|
+ if len(member_messages) < self.get_guild_setting(message.guild, self.SETTING_WARN_COUNT):
|
|
76
|
86
|
return
|
|
77
|
87
|
|
|
78
|
88
|
# Look for repeats
|
|
|
@@ -83,7 +93,7 @@ class CrossPostCog(BaseCog):
|
|
83
|
93
|
count = (hash_to_count.get(key) or 0) + 1
|
|
84
|
94
|
hash_to_count[key] = count
|
|
85
|
95
|
max_count = max(max_count, count)
|
|
86
|
|
- if max_count < self.__warn_count(message.guild):
|
|
|
96
|
+ if max_count < self.get_guild_setting(message.guild, self.SETTING_WARN_COUNT):
|
|
87
|
97
|
return
|
|
88
|
98
|
|
|
89
|
99
|
# Handle the spam
|
|
|
@@ -91,7 +101,7 @@ class CrossPostCog(BaseCog):
|
|
91
|
101
|
or SizeBoundDict(self.max_spam_contexts, lambda key, context : context.age)
|
|
92
|
102
|
Storage.set_state_value(message.guild, self.STATE_KEY_SPAM_CONTEXT, spam_lookup)
|
|
93
|
103
|
for message_hash, count in hash_to_count.items():
|
|
94
|
|
- if count < self.__warn_count(message.guild):
|
|
|
104
|
+ if count < self.get_guild_setting(message.guild, self.SETTING_WARN_COUNT):
|
|
95
|
105
|
continue
|
|
96
|
106
|
key = f'{message.author.id}|{message_hash}'
|
|
97
|
107
|
context = spam_lookup.get(key)
|
|
|
@@ -106,7 +116,7 @@ class CrossPostCog(BaseCog):
|
|
106
|
116
|
await self.__update_from_context(context)
|
|
107
|
117
|
|
|
108
|
118
|
async def __update_from_context(self, context: SpamContext):
|
|
109
|
|
- if len(context.spam_messages) >= self.__ban_count(context.member.guild):
|
|
|
119
|
+ if len(context.spam_messages) >= self.get_guild_setting(context.member.guild, self.SETTING_BAN_COUNT):
|
|
110
|
120
|
if not context.is_banned:
|
|
111
|
121
|
count = len(context.spam_messages)
|
|
112
|
122
|
await context.member.ban(reason=f'Autobanned by Rocketbot for posting same message {count} times', delete_message_days=1)
|
|
|
@@ -205,143 +215,3 @@ class CrossPostCog(BaseCog):
|
|
205
|
215
|
'Command group'
|
|
206
|
216
|
if context.invoked_subcommand is None:
|
|
207
|
217
|
await context.send_help()
|
|
208
|
|
-
|
|
209
|
|
- @crosspost.command(
|
|
210
|
|
- name='setwarncount',
|
|
211
|
|
- brief='Sets the number of duplicate messages to trigger a mod warning',
|
|
212
|
|
- description='If the same user posts the exact same message ' +
|
|
213
|
|
- 'content this many times a warning will be posted to the mods,' +
|
|
214
|
|
- 'even if the messages are posted in different channels.',
|
|
215
|
|
- usage='<warn_count:int>',
|
|
216
|
|
- )
|
|
217
|
|
- async def joinraid_setwarncount(self, context: commands.Context,
|
|
218
|
|
- warn_count: int):
|
|
219
|
|
- if not await self.validate_param(context, 'warn_count', warn_count,
|
|
220
|
|
- allowed_types=(int, ), min_value=self.MIN_WARN_COUNT):
|
|
221
|
|
- return
|
|
222
|
|
- Storage.set_config_value(context.guild, self.CONFIG_KEY_WARN_COUNT, warn_count)
|
|
223
|
|
- await context.message.reply(
|
|
224
|
|
- CONFIG['success_emoji'] + ' ' +
|
|
225
|
|
- 'Mods will be warned if a user posts the exact same message ' +
|
|
226
|
|
- f'{warn_count} or more times within ' +
|
|
227
|
|
- f'{self.__message_age_seconds(context.guild)} seconds.',
|
|
228
|
|
- mention_author=False)
|
|
229
|
|
-
|
|
230
|
|
- @crosspost.command(
|
|
231
|
|
- name='getwarncount',
|
|
232
|
|
- brief='Returns the number of duplicate messages to trigger a mod warning',
|
|
233
|
|
- )
|
|
234
|
|
- async def joinraid_getwarncount(self, context: commands.Context):
|
|
235
|
|
- await context.message.reply(f'ℹ️ Mods will be warned if a user posts ' +
|
|
236
|
|
- f'the exact same message {self.__warn_count(context.guild)} or more ' +
|
|
237
|
|
- f'times within {self.__message_age_seconds(context.guild)} seconds.',
|
|
238
|
|
- mention_author=False)
|
|
239
|
|
-
|
|
240
|
|
- @crosspost.command(
|
|
241
|
|
- name='setbancount',
|
|
242
|
|
- brief='Sets the number of duplicate messages to trigger an automatic ban',
|
|
243
|
|
- description='If the same user posts the exact same message ' +
|
|
244
|
|
- 'content this many times they will be automatically banned and the ' +
|
|
245
|
|
- 'mods will be alerted.',
|
|
246
|
|
- usage='<ban_count:int>',
|
|
247
|
|
- )
|
|
248
|
|
- async def joinraid_setbancount(self, context: commands.Context,
|
|
249
|
|
- ban_count: int):
|
|
250
|
|
- if not await self.validate_param(context, 'ban_count', ban_count,
|
|
251
|
|
- allowed_types=(int, ), min_value=self.MIN_BAN_COUNT):
|
|
252
|
|
- return
|
|
253
|
|
- Storage.set_config_value(context.guild, self.CONFIG_KEY_BAN_COUNT, ban_count)
|
|
254
|
|
- await context.message.reply(
|
|
255
|
|
- CONFIG['success_emoji'] + ' ' +
|
|
256
|
|
- 'Users will be banned if they post the exact same message ' +
|
|
257
|
|
- f'{ban_count} or more times within ' +
|
|
258
|
|
- f'{self.__message_age_seconds(context.guild)} seconds.',
|
|
259
|
|
- mention_author=False)
|
|
260
|
|
-
|
|
261
|
|
- @crosspost.command(
|
|
262
|
|
- name='getbancount',
|
|
263
|
|
- brief='Returns the number of duplicate messages to trigger an automatic ban',
|
|
264
|
|
- )
|
|
265
|
|
- async def joinraid_getbancount(self, context: commands.Context):
|
|
266
|
|
- await context.message.reply(
|
|
267
|
|
- CONFIG['info_emoji'] + ' ' +
|
|
268
|
|
- 'Users will be banned if they post the exact same message ' +
|
|
269
|
|
- f'{self.__ban_count(context.guild)} or more times within ' +
|
|
270
|
|
- f'{self.__message_age_seconds(context.guild)} seconds.',
|
|
271
|
|
- mention_author=False)
|
|
272
|
|
-
|
|
273
|
|
- @crosspost.command(
|
|
274
|
|
- name='setminlength',
|
|
275
|
|
- brief='Sets the minimum number of characters for a message to count toward spamming',
|
|
276
|
|
- description='Messages shorter than this number of characters will not ' +
|
|
277
|
|
- 'count toward spam counts. This helps prevent flagging common, ' +
|
|
278
|
|
- 'frequent, short responses like "lol". A value of 0 counts all messages.',
|
|
279
|
|
- usage='<min_length:int>',
|
|
280
|
|
- )
|
|
281
|
|
- async def joinraid_setminlength(self, context: commands.Context,
|
|
282
|
|
- min_length: int):
|
|
283
|
|
- if not await self.validate_param(context, 'min_length', min_length,
|
|
284
|
|
- allowed_types=(int, ), min_value=self.MIN_MESSAGE_LENGTH):
|
|
285
|
|
- return
|
|
286
|
|
- Storage.set_config_value(context.guild, self.CONFIG_KEY_MIN_MESSAGE_LENGTH, min_length)
|
|
287
|
|
- if min_length == 0:
|
|
288
|
|
- await context.message.reply(
|
|
289
|
|
- CONFIG['success_emoji'] + ' ' +
|
|
290
|
|
- f'All messages will count against spam counts, regardless ' +
|
|
291
|
|
- 'of length.', mention_author=False)
|
|
292
|
|
- else:
|
|
293
|
|
- await context.message.reply(
|
|
294
|
|
- CONFIG['success_emoji'] + ' ' +
|
|
295
|
|
- f'Only messages {min_length} characters or longer will ' +
|
|
296
|
|
- 'count against spam counts.',
|
|
297
|
|
- mention_author=False)
|
|
298
|
|
-
|
|
299
|
|
- @crosspost.command(
|
|
300
|
|
- name='getminlength',
|
|
301
|
|
- brief='Returns the number of duplicate messages to trigger an automatic ban',
|
|
302
|
|
- )
|
|
303
|
|
- async def joinraid_getminlength(self, context: commands.Context):
|
|
304
|
|
- min_length = self.__min_message_length(context.guild)
|
|
305
|
|
- if min_length == 0:
|
|
306
|
|
- await context.message.reply(
|
|
307
|
|
- CONFIG['info_emoji'] + ' ' +
|
|
308
|
|
- f'All messages will count against spam counts, regardless ' +
|
|
309
|
|
- 'of length.', mention_author=False)
|
|
310
|
|
- else:
|
|
311
|
|
- await context.message.reply(
|
|
312
|
|
- CONFIG['info_emoji'] + ' ' +
|
|
313
|
|
- f'Only messages {min_length} characters or longer will ' +
|
|
314
|
|
- 'count against spam counts.',
|
|
315
|
|
- mention_author=False)
|
|
316
|
|
-
|
|
317
|
|
- @crosspost.command(
|
|
318
|
|
- name='settimewindow',
|
|
319
|
|
- brief='Sets the length of time recent messages are checked for duplicates',
|
|
320
|
|
- description='Repeated messages are only checked against recent ' +
|
|
321
|
|
- 'messages. This sets the length of that window, in seconds. Lower ' +
|
|
322
|
|
- 'values save memory and prevent false positives.',
|
|
323
|
|
- usage='<seconds:int>',
|
|
324
|
|
- )
|
|
325
|
|
- async def joinraid_settimewindow(self, context: commands.Context,
|
|
326
|
|
- seconds: int):
|
|
327
|
|
- if not await self.validate_param(context, 'seconds', seconds,
|
|
328
|
|
- allowed_types=(int, ), min_value=self.MIN_TIME_SPAN):
|
|
329
|
|
- return
|
|
330
|
|
- Storage.set_config_value(context.guild, self.CONFIG_KEY_MESSAGE_AGE, seconds)
|
|
331
|
|
- await context.message.reply(
|
|
332
|
|
- CONFIG['success_emoji'] + ' ' +
|
|
333
|
|
- f'Only messages in the past {seconds} seconds will be checked ' +
|
|
334
|
|
- 'for duplicates.',
|
|
335
|
|
- mention_author=False)
|
|
336
|
|
-
|
|
337
|
|
- @crosspost.command(
|
|
338
|
|
- name='gettimewindow',
|
|
339
|
|
- brief='Returns the length of time recent messages are checked for duplicates',
|
|
340
|
|
- )
|
|
341
|
|
- async def joinraid_gettimewindow(self, context: commands.Context):
|
|
342
|
|
- seconds = self.__message_age_seconds(context.guild)
|
|
343
|
|
- await context.message.reply(
|
|
344
|
|
- CONFIG['info_emoji'] + ' ' +
|
|
345
|
|
- f'Only messages in the past {seconds} seconds will be checked ' +
|
|
346
|
|
- 'for duplicates.',
|
|
347
|
|
- mention_author=False)
|