| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183 |
- """
- Cog for detecting large numbers of guild joins in a short period of time.
- """
- import weakref
- from datetime import datetime, timedelta
- from discord import Guild, Member
- from discord.ext import commands
-
- from config import CONFIG
- from rocketbot.cogs.basecog import BaseCog, BotMessage, BotMessageReaction, CogSetting
- from rocketbot.collections import AgeBoundList
- from rocketbot.storage import Storage
-
- class JoinRaidContext:
- """
- Data about a join raid.
- """
- def __init__(self, join_members: list):
- self.join_members = list(join_members)
- self.kicked_members = set()
- self.banned_members = set()
- self.warning_message_ref = None
-
- def last_join_time(self) -> datetime:
- """Returns when the most recent member join was, in UTC"""
- return self.join_members[-1].joined_at
-
- class JoinRaidCog(BaseCog, name='Join Raids'):
- """
- Cog for monitoring member joins and detecting potential bot raids.
- """
- SETTING_ENABLED = CogSetting('enabled', bool,
- brief='join raid detection',
- description='Whether this cog is enabled for a guild.')
- SETTING_JOIN_COUNT = CogSetting('joincount', int,
- brief='number of joins to trigger a warning',
- description='The number of joins occuring within the time ' + \
- 'window to trigger a mod warning.',
- usage='<count:int>',
- min_value=2)
- SETTING_JOIN_TIME = CogSetting('jointime', float,
- brief='time window length to look for joins',
- description='The number of seconds of join history to look ' + \
- 'at when counting recent joins. If joincount or more ' + \
- 'joins occur within jointime seconds a mod warning is issued.',
- usage='<seconds:float>',
- min_value=1.0,
- max_value=900.0)
-
- STATE_KEY_RECENT_JOINS = "JoinRaidCog.recent_joins"
- STATE_KEY_LAST_RAID = "JoinRaidCog.last_raid"
-
- def __init__(self, bot):
- super().__init__(bot)
- self.add_setting(JoinRaidCog.SETTING_ENABLED)
- self.add_setting(JoinRaidCog.SETTING_JOIN_COUNT)
- self.add_setting(JoinRaidCog.SETTING_JOIN_TIME)
-
- @commands.group(
- brief='Manages join raid detection and handling',
- )
- @commands.has_permissions(ban_members=True)
- @commands.guild_only()
- async def joinraid(self, context: commands.Context):
- """Join raid detection command group"""
- if context.invoked_subcommand is None:
- await context.send_help()
-
- async def on_mod_react(self,
- bot_message: BotMessage,
- reaction: BotMessageReaction,
- reacted_by: Member) -> None:
- guild: Guild = bot_message.guild
- raid: JoinRaidContext = bot_message.context
- if reaction.emoji == CONFIG['kick_emoji']:
- to_kick = set(raid.join_members) - raid.kicked_members
- for member in to_kick:
- await member.kick(
- reason=f'Rocketbot: Part of join raid. Kicked by {reacted_by.name}.')
- raid.kicked_members |= to_kick
- await self.__update_warning_message(raid)
- self.log(guild, f'Join raid users kicked by {reacted_by.name}.')
- elif reaction.emoji == CONFIG['ban_emoji']:
- to_ban = set(raid.join_members) - raid.banned_members
- for member in to_ban:
- await member.ban(
- reason=f'Rocketbot: Part of join raid. Banned by {reacted_by.name}.',
- delete_message_days=0)
- raid.banned_members |= to_ban
- await self.__update_warning_message(raid)
- self.log(guild, f'Join raid users banned by {reacted_by.name}')
-
- @commands.Cog.listener()
- async def on_member_join(self, member: Member) -> None:
- """Event handler"""
- guild: Guild = member.guild
- if not self.get_guild_setting(guild, self.SETTING_ENABLED):
- return
- min_count = self.get_guild_setting(guild, self.SETTING_JOIN_COUNT)
- seconds = self.get_guild_setting(guild, self.SETTING_JOIN_TIME)
- timespan: timedelta = timedelta(seconds=seconds)
-
- last_raid: JoinRaidContext = Storage.get_state_value(guild, self.STATE_KEY_LAST_RAID)
- recent_joins: AgeBoundList = Storage.get_state_value(guild, self.STATE_KEY_RECENT_JOINS)
- if recent_joins is None:
- recent_joins = AgeBoundList(timespan, lambda i, member : member.joined_at)
- Storage.set_state_value(guild, self.STATE_KEY_RECENT_JOINS, recent_joins)
- if last_raid:
- if member.joined_at - last_raid.last_join_time() > timespan:
- # Last raid is over
- Storage.set_state_value(guild, self.STATE_KEY_LAST_RAID, None)
- recent_joins.append(member)
- return
- # Add join to existing raid
- last_raid.join_members.append(member)
- self.record_warning(member)
- if len(last_raid.banned_members) > 0:
- self.log(guild, f'Banning as part of last join raid: {member.name}')
- await member.ban(
- reason='Rocketbot: Part of join raid.',
- delete_message_days=0)
- last_raid.banned_members.add(member)
- elif len(last_raid.kicked_members) > 0:
- self.log(guild, f'Kicking as part of last join raid: {member.name}')
- await member.kick(
- reason='Rocketbot: Part of join raid.')
- last_raid.kicked_members.add(member)
- await self.__update_warning_message(last_raid)
- else:
- # Add join to the general, non-raid recent join list
- recent_joins.append(member)
- if len(recent_joins) >= min_count:
- self.log(guild, '\u0007Join raid detected')
- last_raid = JoinRaidContext(recent_joins)
- Storage.set_state_value(guild, self.STATE_KEY_LAST_RAID, last_raid)
- recent_joins.clear()
- msg = BotMessage(guild,
- text='',
- type=BotMessage.TYPE_MOD_WARNING,
- context=last_raid)
- self.record_warnings(recent_joins)
- last_raid.warning_message_ref = weakref.ref(msg)
- await self.__update_warning_message(last_raid)
- await self.post_message(msg)
-
- async def on_setting_updated(self, guild: Guild, setting: CogSetting) -> None:
- if setting is self.SETTING_JOIN_TIME:
- seconds = self.get_guild_setting(guild, self.SETTING_JOIN_TIME)
- timespan: timedelta = timedelta(seconds=seconds)
- recent_joins: AgeBoundList = Storage.get_state_value(guild,
- self.STATE_KEY_RECENT_JOINS)
- if recent_joins:
- recent_joins.max_age = timespan
- recent_joins.purge_old_elements()
-
- async def __update_warning_message(self, context: JoinRaidContext) -> None:
- if context.warning_message_ref is None:
- return
- bot_message = context.warning_message_ref()
- if bot_message is None:
- return
- text = 'JOIN RAID DETECTED\n\n' + \
- 'The following members joined in close succession:\n'
- max_members: int = CONFIG['max_members_per_message']
- for member in context.join_members[:max_members]:
- text += '\n• '
- if member in context.banned_members:
- text += f'~~{member.mention} ({member.id})~~ - banned'
- elif member in context.kicked_members:
- text += f'~~{member.mention} ({member.id})~~ - kicked'
- else:
- text += f'{member.mention} ({member.id})'
- if len(context.join_members) > max_members:
- text += f'\n• {len(context.join_members) - max_members} more'
- text += '\n_(list updates automatically)_'
- await bot_message.set_text(text)
- member_count = len(context.join_members)
- kick_count = len(context.kicked_members)
- ban_count = len(context.banned_members)
- await bot_message.set_reactions(BotMessageReaction.standard_set(
- did_kick=kick_count >= member_count,
- did_ban=ban_count >= member_count,
- user_count=member_count))
|