| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256 |
- """
- Cog for detecting spam messages posted in multiple channels.
- """
- from datetime import datetime, timedelta
- from discord import Member, Message, utils as discordutils
- from discord.ext import commands
-
- from config import CONFIG
- from rocketbot.cogs.basecog import BaseCog, BotMessage, BotMessageReaction, CogSetting
- from rocketbot.collections import AgeBoundList, SizeBoundDict
- from rocketbot.storage import Storage
-
- class SpamContext:
- """
- Data about a set of duplicate messages from a user.
- """
- def __init__(self, member, message_hash):
- self.member = member
- self.message_hash = message_hash
- self.age = datetime.now()
- self.bot_message = None # BotMessage
- self.is_kicked = False
- self.is_banned = False
- self.is_autobanned = False
- self.spam_messages = set() # of Message
- self.deleted_messages = set() # of Message
- self.unique_channels = set() # of TextChannel
-
- class CrossPostCog(BaseCog, name='Crosspost Detection'):
- """
- Detects a user posting the same text in multiple channels in a short period
- of time: a common pattern for spammers. Repeated posts in the same channel
- aren't detected, as this can often be for a reason or due to trying a
- failed post when connectivity is poor. Minimum message length can be
- enforced for detection. Minimum is always at least 1 to ignore posts with
- just embeds or images and no text.
- """
- SETTING_ENABLED = CogSetting('enabled', bool,
- brief='crosspost detection',
- description='Whether crosspost detection is enabled.')
- SETTING_WARN_COUNT = CogSetting('warncount', int,
- brief='number of messages to trigger a warning',
- description='The number of unique channels the same message is ' + \
- 'posted in by the same user to trigger a mod warning.',
- usage='<count:int>',
- min_value=2)
- SETTING_BAN_COUNT = CogSetting('bancount', int,
- brief='number of messages to trigger a ban',
- description='The number of unique channels the same message is ' + \
- 'posted in by the same user to trigger an automatic ban. Set ' + \
- 'to a large value to effectively disable, e.g. 9999.',
- usage='<count:int>',
- min_value=2)
- SETTING_MIN_LENGTH = CogSetting('minlength', int,
- brief='minimum message length',
- description='The minimum number of characters in a message to be ' + \
- 'checked for duplicates. This can help ignore common short ' + \
- 'messages like "lol" or a single emoji.',
- usage='<character_count:int>',
- min_value=1)
- SETTING_TIMESPAN = CogSetting('timespan', float,
- brief='time window to look for dupe messages',
- description='The number of seconds of message history to look at ' + \
- 'when looking for duplicates. Shorter values are preferred, ' + \
- 'both to detect bots and avoid excessive memory usage.',
- usage='<seconds:int>',
- min_value=1)
-
- STATE_KEY_RECENT_MESSAGES = "CrossPostCog.recent_messages"
- STATE_KEY_SPAM_CONTEXT = "CrossPostCog.spam_context"
-
- def __init__(self, bot):
- super().__init__(bot)
- self.add_setting(CrossPostCog.SETTING_ENABLED)
- self.add_setting(CrossPostCog.SETTING_WARN_COUNT)
- self.add_setting(CrossPostCog.SETTING_BAN_COUNT)
- self.add_setting(CrossPostCog.SETTING_MIN_LENGTH)
- self.add_setting(CrossPostCog.SETTING_TIMESPAN)
- self.max_spam_contexts = 12
-
- async def __record_message(self, message: Message) -> None:
- if message.author.permissions_in(message.channel).ban_members:
- # User exempt from spam detection
- return
- if len(message.content) < self.get_guild_setting(message.guild, self.SETTING_MIN_LENGTH):
- # Message too short to count towards spam total
- return
- max_age = timedelta(seconds=self.get_guild_setting(message.guild, self.SETTING_TIMESPAN))
- warn_count = self.get_guild_setting(message.guild, self.SETTING_WARN_COUNT)
- recent_messages = Storage.get_state_value(message.guild, self.STATE_KEY_RECENT_MESSAGES)
- if recent_messages is None:
- recent_messages = AgeBoundList(max_age, lambda index, message : message.created_at)
- Storage.set_state_value(message.guild, self.STATE_KEY_RECENT_MESSAGES, recent_messages)
- recent_messages.max_age = max_age
- recent_messages.append(message)
-
- # Get all recent messages by user
- member_messages = [m for m in recent_messages if m.author.id == message.author.id]
- if len(member_messages) < warn_count:
- return
-
- # Look for repeats
- hash_to_channels = {} # int --> set(TextChannel)
- max_count = 0
- for m in member_messages:
- key = hash(m.content)
- channels = hash_to_channels.get(key)
- if channels is None:
- channels = set()
- hash_to_channels[key] = channels
- channels.add(m.channel)
- max_count = max(max_count, len(channels))
- if max_count < warn_count:
- return
-
- # Handle the spam
- spam_lookup = Storage.get_state_value(message.guild, self.STATE_KEY_SPAM_CONTEXT)
- if spam_lookup is None:
- spam_lookup = SizeBoundDict(
- self.max_spam_contexts,
- lambda key, context : context.age)
- Storage.set_state_value(message.guild, self.STATE_KEY_SPAM_CONTEXT, spam_lookup)
- for message_hash, channels in hash_to_channels.items():
- channel_count = len(channels)
- if channel_count < warn_count:
- continue
- key = f'{message.author.id}|{message_hash}'
- context = spam_lookup.get(key)
- if context is None:
- context = SpamContext(message.author, message_hash)
- spam_lookup[key] = context
- context.age = message.created_at
- self.log(message.guild,
- f'\u0007{message.author.name} ({message.author.id}) ' + \
- f'posted the same message in {channel_count} or more channels.')
- for m in member_messages:
- if hash(m.content) == message_hash:
- context.spam_messages.add(m)
- context.unique_channels.add(m.channel)
- await self.__update_from_context(context)
-
- async def __update_from_context(self, context: SpamContext):
- ban_count = self.get_guild_setting(context.member.guild, self.SETTING_BAN_COUNT)
- channel_count = len(context.unique_channels)
- if channel_count >= ban_count:
- if not context.is_banned:
- await context.member.ban(
- reason='Rocketbot: Posted same message in ' + \
- f'{channel_count} channels. Banned by ' + \
- f'{self.bot.user.name}.',
- delete_message_days=1)
- context.is_kicked = True
- context.is_banned = True
- context.is_autobanned = True
- context.deleted_messages |= context.spam_messages
- self.log(context.member.guild,
- f'{context.member.name} ({context.member.id}) posted ' + \
- f'same message in {channel_count} channels. Banned by ' + \
- f'{self.bot.user.name}.')
- else:
- # Already banned. Nothing to update in the message.
- return
- await self.__update_message_from_context(context)
-
- async def __update_message_from_context(self, context: SpamContext) -> None:
- first_spam_message: Message = next(iter(context.spam_messages))
- spam_count = len(context.spam_messages)
- channel_count = len(context.unique_channels)
- deleted_count = len(context.spam_messages)
- message = context.bot_message
- if message is None:
- message_type: int = BotMessage.TYPE_INFO if self.was_warned_recently(context.member) \
- else BotMessage.TYPE_MOD_WARNING
- message = BotMessage(context.member.guild, '', message_type, context)
- message.quote = discordutils.remove_markdown(first_spam_message.clean_content)
- self.record_warning(context.member)
- if context.is_autobanned:
- text = f'User {context.member.mention} auto banned for ' + \
- f'posting the same message in {channel_count} channels. ' + \
- 'Messages from past 24 hours deleted.'
- await message.set_reactions([])
- await message.set_text(text)
- else:
- await message.set_text(f'User {context.member.mention} posted ' +
- f'the same message in {channel_count} channels.')
- await message.set_reactions(BotMessageReaction.standard_set(
- did_delete = deleted_count >= spam_count,
- message_count = spam_count,
- did_kick = context.is_kicked,
- did_ban = context.is_banned))
- if context.bot_message is None:
- await self.post_message(message)
- context.bot_message = message
-
- async def on_mod_react(self,
- bot_message: BotMessage,
- reaction: BotMessageReaction,
- reacted_by: Member) -> None:
- context: SpamContext = bot_message.context
- if context is None:
- return
-
- channel_count = len(context.unique_channels)
- if reaction.emoji == CONFIG['trash_emoji']:
- for message in context.spam_messages - context.deleted_messages:
- await message.delete()
- context.deleted_messages.add(message)
- await self.__update_from_context(context)
- self.log(context.member.guild,
- f'{context.member.name} ({context.member.id}) posted same ' + \
- f'message in {channel_count} channels. Deleted by {reacted_by.name}.')
- elif reaction.emoji == CONFIG['kick_emoji']:
- await context.member.kick(
- reason=f'Rocketbot: Posted same message in {channel_count} ' + \
- f'channels. Kicked by {reacted_by.name}.')
- context.is_kicked = True
- await self.__update_from_context(context)
- self.log(context.member.guild,
- f'{context.member.name} ({context.member.id}) posted same ' + \
- f'message in {channel_count} channels. Kicked by {reacted_by.name}.')
- elif reaction.emoji == CONFIG['ban_emoji']:
- await context.member.ban(
- reason=f'Rocketbot: Posted same message in {channel_count} ' + \
- f'channels. Banned by {reacted_by.name}.',
- delete_message_days=1)
- context.deleted_messages |= context.spam_messages
- context.is_kicked = True
- context.is_banned = True
- await self.__update_from_context(context)
- self.log(context.member.guild,
- f'{context.member.name} ({context.member.id}) posted same ' + \
- f'message in {channel_count} channels. Kicked by {reacted_by.name}.')
-
- @commands.Cog.listener()
- async def on_message(self, message: Message):
- 'Event handler'
- if message.author is None or \
- message.author.bot or \
- message.channel is None or \
- message.guild is None or \
- message.content is None or \
- message.content == '':
- return
- if not self.get_guild_setting(message.guild, self.SETTING_ENABLED):
- return
- await self.__record_message(message)
-
- @commands.group(
- brief='Manages crosspost detection and handling',
- )
- @commands.has_permissions(ban_members=True)
- @commands.guild_only()
- async def crosspost(self, context: commands.Context):
- 'Crosspost detection command group'
- if context.invoked_subcommand is None:
- await context.send_help()
|