""" 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='', 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='', 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='', 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='', 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.channel.permissions_for(message.author).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()