diff options
| -rw-r--r-- | bot/__main__.py | 1 | ||||
| -rw-r--r-- | bot/cogs/clean.py | 293 | ||||
| -rw-r--r-- | bot/cogs/moderation.py | 4 | ||||
| -rw-r--r-- | bot/cogs/modlog.py | 31 | ||||
| -rw-r--r-- | bot/constants.py | 22 | ||||
| -rw-r--r-- | config-default.yml | 23 | 
6 files changed, 348 insertions, 26 deletions
diff --git a/bot/__main__.py b/bot/__main__.py index b9e6001ac..9e5806690 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -45,6 +45,7 @@ bot.load_extension("bot.cogs.events")  # Commands, etc  bot.load_extension("bot.cogs.bigbrother")  bot.load_extension("bot.cogs.bot") +bot.load_extension("bot.cogs.clean")  bot.load_extension("bot.cogs.cogs")  # Local setups usually don't have the clickup key set, diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py new file mode 100644 index 000000000..efedc2dce --- /dev/null +++ b/bot/cogs/clean.py @@ -0,0 +1,293 @@ +import logging +import random +import re +from typing import Optional + +from aiohttp.client_exceptions import ClientResponseError +from discord import Colour, Embed, Message, User +from discord.ext.commands import Bot, Context, group + +from bot.cogs.modlog import ModLog +from bot.constants import ( +    Channels, CleanMessages, Colours, Icons, +    Keys, NEGATIVE_REPLIES, Roles, URLs +) +from bot.decorators import with_role + +log = logging.getLogger(__name__) + + +class Clean: + +    def __init__(self, bot: Bot): +        self.bot = bot +        self.headers = {"X-API-KEY": Keys.site_api} +        self.cleaning = False + +    @property +    def mod_log(self) -> ModLog: +        return self.bot.get_cog("ModLog") + +    async def _upload_log(self, log_data: list) -> str: +        """ +        Uploads the log data to the database via +        an API endpoint for uploading logs. + +        Returns a URL that can be used to view the log. +        """ + +        response = await self.bot.http_session.post( +            URLs.site_clean_api, +            headers=self.headers, +            json={"log_data": log_data} +        ) + +        try: +            data = await response.json() +            log_id = data["log_id"] +        except (KeyError, ClientResponseError): +            log.debug( +                "API returned an unexpected result:\n" +                f"{response.text}" +            ) +            return + +        return f"{URLs.site_clean_logs}/{log_id}" + +    async def _clean_messages( +            self, amount: int, ctx: Context, +            bots_only: bool = False, user: User = None, +            regex: Optional[str] = None +    ): +        """ +        A helper function that does the actual message cleaning. + +        :param bots_only: Set this to True if you only want to delete bot messages. +        :param user: Specify a user and it will only delete messages by this user. +        :param regular_expression: Specify a regular expression and it will only +                                   delete messages that match this. +        """ + +        def predicate_bots_only(message: Message) -> bool: +            """ +            Returns true if the message was sent by a bot +            """ + +            return message.author.bot + +        def predicate_specific_user(message: Message) -> bool: +            """ +            Return True if the message was sent by the +            user provided in the _clean_messages call. +            """ + +            return message.author == user + +        def predicate_regex(message: Message): +            """ +            Returns True if the regex provided in the +            _clean_messages matches the message content +            or any embed attributes the message may have. +            """ + +            content = [message.content] + +            # Add the content for all embed attributes +            for embed in message.embeds: +                content.append(embed.title) +                content.append(embed.description) +                content.append(embed.footer.text) +                content.append(embed.author.name) +                for field in embed.fields: +                    content.append(field.name) +                    content.append(field.value) + +            # Get rid of empty attributes and turn it into a string +            content = [attr for attr in content if attr] +            content = "\n".join(content) + +            # Now let's see if there's a regex match +            if not content: +                return False +            else: +                return bool(re.search(regex.lower(), content.lower())) + +        # Is this an acceptable amount of messages to clean? +        if amount > CleanMessages.message_limit: +            embed = Embed( +                color=Colour(Colours.soft_red), +                title=random.choice(NEGATIVE_REPLIES), +                description=f"You cannot clean more than {CleanMessages.message_limit} messages." +            ) +            await ctx.send(embed=embed) +            return + +        # Are we already performing a clean? +        if self.cleaning: +            embed = Embed( +                color=Colour(Colours.soft_red), +                title=random.choice(NEGATIVE_REPLIES), +                description="Multiple simultaneous cleaning processes is not allowed." +            ) +            await ctx.send(embed=embed) +            return + +        # Set up the correct predicate +        if bots_only: +            predicate = predicate_bots_only      # Delete messages from bots +        elif user: +            predicate = predicate_specific_user  # Delete messages from specific user +        elif regex: +            predicate = predicate_regex          # Delete messages that match regex +        else: +            predicate = None                     # Delete all messages + +        # Look through the history and retrieve message data +        message_log = [] +        message_ids = [] +        self.cleaning = True +        invocation_deleted = False + +        async for message in ctx.channel.history(limit=amount): + +            # If at any point the cancel command is invoked, we should stop. +            if not self.cleaning: +                return + +            # Always start by deleting the invocation +            if not invocation_deleted: +                await message.delete() +                invocation_deleted = True +                continue + +            # If the message passes predicate, let's save it. +            if predicate is None or predicate(message): +                author = f"{message.author.name}#{message.author.discriminator}" +                role = message.author.top_role.name + +                content = message.content +                embeds = [embed.to_dict() for embed in message.embeds] +                attachments = ["<Attachment>" for _ in message.attachments] + +                message_ids.append(message.id) +                message_log.append({ +                    "content": content, +                    "author": author, +                    "user_id": str(message.author.id), +                    "role": role.lower(), +                    "timestamp": message.created_at.strftime("%D %H:%M"), +                    "attachments": attachments, +                    "embeds": embeds, +                }) + +        self.cleaning = False + +        # We should ignore the ID's we stored, so we don't get mod-log spam. +        self.mod_log.ignore_message_deletion(*message_ids) + +        # Use bulk delete to actually do the cleaning. It's far faster. +        await ctx.channel.purge( +            limit=amount, +            check=predicate +        ) + +        # Reverse the list to restore chronological order +        if message_log: +            message_log = list(reversed(message_log)) +            upload_log = await self._upload_log(message_log) +        else: +            # Can't build an embed, nothing to clean! +            embed = Embed( +                color=Colour(Colours.soft_red), +                description="No matching messages could be found." +            ) +            await ctx.send(embed=embed, delete_after=10.0) +            return + +        # Build the embed and send it +        message = ( +            f"**{len(message_ids)}** messages deleted in <#{ctx.channel.id}> by **{ctx.author.name}**\n\n" +            f"A log of the deleted messages can be found [here]({upload_log})." +        ) + +        embed = Embed( +            color=Colour(Colours.soft_red), +            description=message +        ) + +        embed.set_author( +            name=f"Bulk message delete", +            icon_url=Icons.message_bulk_delete +        ) + +        await self.bot.get_channel(Channels.modlog).send(embed=embed) + +    @group(invoke_without_command=True, name="clean", hidden=True) +    @with_role(Roles.moderator, Roles.admin, Roles.owner) +    async def clean_group(self, ctx: Context): +        """ +        Commands for cleaning messages in channels +        """ + +        await ctx.invoke(self.bot.get_command("help"), "clean") + +    @clean_group.command(name="user", aliases=["users"]) +    @with_role(Roles.moderator, Roles.admin, Roles.owner) +    async def clean_user(self, ctx: Context, user: User, amount: int = 10): +        """ +        Delete messages posted by the provided user, +        and stop cleaning after traversing `amount` messages. +        """ + +        await self._clean_messages(amount, ctx, user=user) + +    @clean_group.command(name="all", aliases=["everything"]) +    @with_role(Roles.moderator, Roles.admin, Roles.owner) +    async def clean_all(self, ctx: Context, amount: int = 10): +        """ +        Delete all messages, regardless of poster, +        and stop cleaning after traversing `amount` messages. +        """ + +        await self._clean_messages(amount, ctx) + +    @clean_group.command(name="bots", aliases=["bot"]) +    @with_role(Roles.moderator, Roles.admin, Roles.owner) +    async def clean_bots(self, ctx: Context, amount: int = 10): +        """ +        Delete all messages posted by a bot, +        and stop cleaning after traversing `amount` messages. +        """ + +        await self._clean_messages(amount, ctx, bots_only=True) + +    @clean_group.command(name="regex", aliases=["word", "expression"]) +    @with_role(Roles.moderator, Roles.admin, Roles.owner) +    async def clean_regex(self, ctx: Context, regex, amount: int = 10): +        """ +        Delete all messages that match a certain regex, +        and stop cleaning after traversing `amount` messages. +        """ + +        await self._clean_messages(amount, ctx, regex=regex) + +    @clean_group.command(name="stop", aliases=["cancel", "abort"]) +    @with_role(Roles.moderator, Roles.admin, Roles.owner) +    async def clean_cancel(self, ctx: Context): +        """ +        If there is an ongoing cleaning process, +        attempt to immediately cancel it. +        """ + +        self.cleaning = False + +        embed = Embed( +            color=Colour.blurple(), +            description="Clean interrupted." +        ) +        await ctx.send(embed=embed) + + +def setup(bot): +    bot.add_cog(Clean(bot)) +    log.info("Cog loaded: Clean") diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index 245f17fda..585bba6a6 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -663,8 +663,8 @@ def parse_rfc1123(time_str):  def _silent_exception(future):      try:          future.exception() -    except Exception: -        pass +    except Exception as e: +        log.debug(f"_silent_exception silenced the following exception: {e}")  def setup(bot): diff --git a/bot/cogs/modlog.py b/bot/cogs/modlog.py index 87cea2b5a..b5a73d6e0 100644 --- a/bot/cogs/modlog.py +++ b/bot/cogs/modlog.py @@ -13,16 +13,13 @@ from discord import (  from discord.abc import GuildChannel  from discord.ext.commands import Bot -from bot.constants import Channels, Emojis, Icons +from bot.constants import Channels, Colours, Emojis, Icons  from bot.constants import Guild as GuildConstant  from bot.utils.time import humanize  log = logging.getLogger(__name__) -BULLET_POINT = "\u2022" -COLOUR_RED = Colour(0xcd6d6d) -COLOUR_GREEN = Colour(0x68c290)  GUILD_CHANNEL = Union[CategoryChannel, TextChannel, VoiceChannel]  CHANNEL_CHANGES_UNSUPPORTED = ("permissions",) @@ -92,7 +89,7 @@ class ModLog:              else:                  message = f"{channel.name} (`{channel.id}`)" -        await self.send_log_message(Icons.hash_green, COLOUR_GREEN, title, message) +        await self.send_log_message(Icons.hash_green, Colour(Colours.soft_green), title, message)      async def on_guild_channel_delete(self, channel: GUILD_CHANNEL):          if channel.guild.id != GuildConstant.id: @@ -111,7 +108,7 @@ class ModLog:              message = f"{channel.name} (`{channel.id}`)"          await self.send_log_message( -            Icons.hash_red, COLOUR_RED, +            Icons.hash_red, Colour(Colours.soft_red),              title, message          ) @@ -157,7 +154,7 @@ class ModLog:          message = ""          for item in sorted(changes): -            message += f"{BULLET_POINT} {item}\n" +            message += f"{Emojis.bullet} {item}\n"          if after.category:              message = f"**{after.category}/#{after.name} (`{after.id}`)**\n{message}" @@ -174,7 +171,7 @@ class ModLog:              return          await self.send_log_message( -            Icons.crown_green, COLOUR_GREEN, +            Icons.crown_green, Colour(Colours.soft_green),              "Role created", f"`{role.id}`"          ) @@ -183,7 +180,7 @@ class ModLog:              return          await self.send_log_message( -            Icons.crown_red, COLOUR_RED, +            Icons.crown_red, Colour(Colours.soft_red),              "Role removed", f"{role.name} (`{role.id}`)"          ) @@ -229,7 +226,7 @@ class ModLog:          message = ""          for item in sorted(changes): -            message += f"{BULLET_POINT} {item}\n" +            message += f"{Emojis.bullet} {item}\n"          message = f"**{after.name}** (`{after.id}`)\n{message}" @@ -277,7 +274,7 @@ class ModLog:          message = ""          for item in sorted(changes): -            message += f"{BULLET_POINT} {item}\n" +            message += f"{Emojis.bullet} {item}\n"          message = f"**{after.name}** (`{after.id}`)\n{message}" @@ -292,7 +289,7 @@ class ModLog:              return          await self.send_log_message( -            Icons.user_ban, COLOUR_RED, +            Icons.user_ban, Colour(Colours.soft_red),              "User banned", f"{member.name}#{member.discriminator} (`{member.id}`)",              thumbnail=member.avatar_url_as(static_format="png")          ) @@ -312,7 +309,7 @@ class ModLog:              message = f"{Emojis.new} {message}"          await self.send_log_message( -            Icons.sign_in, COLOUR_GREEN, +            Icons.sign_in, Colour(Colours.soft_green),              "User joined", message,              thumbnail=member.avatar_url_as(static_format="png")          ) @@ -322,7 +319,7 @@ class ModLog:              return          await self.send_log_message( -            Icons.sign_out, COLOUR_RED, +            Icons.sign_out, Colour(Colours.soft_red),              "User left", f"{member.name}#{member.discriminator} (`{member.id}`)",              thumbnail=member.avatar_url_as(static_format="png")          ) @@ -410,7 +407,7 @@ class ModLog:          message = ""          for item in sorted(changes): -            message += f"{BULLET_POINT} {item}\n" +            message += f"{Emojis.bullet} {item}\n"          message = f"**{after.name}#{after.discriminator}** (`{after.id}`)\n{message}" @@ -489,7 +486,7 @@ class ModLog:              response = f"**Attachments:** {len(message.attachments)}\n" + response          await self.send_log_message( -            Icons.message_delete, COLOUR_RED, +            Icons.message_delete, Colours.soft_red,              "Message deleted",              response,              channel_id=Channels.message_log @@ -528,7 +525,7 @@ class ModLog:              )          await self.send_log_message( -            Icons.message_delete, COLOUR_RED, +            Icons.message_delete, Colour(Colours.soft_red),              "Message deleted",              response,              channel_id=Channels.message_log diff --git a/bot/constants.py b/bot/constants.py index 205b09111..e8176b377 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -198,8 +198,16 @@ class Cooldowns(metaclass=YAMLGetter):      tags: int +class Colours(metaclass=YAMLGetter): +    section = "style" +    subsection = "colours" + +    soft_red: int +    soft_green: int + +  class Emojis(metaclass=YAMLGetter): -    section = "bot" +    section = "style"      subsection = "emojis"      defcon_disabled: str  # noqa: E704 @@ -210,12 +218,13 @@ class Emojis(metaclass=YAMLGetter):      red_chevron: str      white_chevron: str +    bullet: str      new: str      pencil: str  class Icons(metaclass=YAMLGetter): -    section = "bot" +    section = "style"      subsection = "icons"      crown_blurple: str @@ -245,6 +254,13 @@ class Icons(metaclass=YAMLGetter):      user_update: str +class CleanMessages(metaclass=YAMLGetter): +    section = "bot" +    subsection = "clean" + +    message_limit: int + +  class Channels(metaclass=YAMLGetter):      section = "guild"      subsection = "channels" @@ -332,6 +348,8 @@ class URLs(metaclass=YAMLGetter):      omdb: str      site: str      site_facts_api: str +    site_clean_api: str +    site_clean_logs: str      site_hiphopify_api: str      site_idioms_api: str      site_names_api: str diff --git a/config-default.yml b/config-default.yml index ee3e6a74e..05ff54cae 100644 --- a/config-default.yml +++ b/config-default.yml @@ -6,6 +6,16 @@ bot:          # Per channel, per tag.          tags: 60 +    clean: +        # Maximum number of messages to traverse for clean commands +        message_limit: 10000 + + +style: +    colours: +        soft_red: 0xcd6d6d +        soft_green: 0x68c290 +      emojis:          defcon_disabled: "<:defcondisabled:470326273952972810>"          defcon_enabled: "<:defconenabled:470326274213150730>" @@ -16,6 +26,7 @@ bot:          white_chevron: "<:whitechevron:418110396973711363>"          lemoneye2:     "<:lemoneye2:435193765582340098>" +        bullet:   "\u2022"          pencil:   "\u270F"          new:      "\U0001F195" @@ -117,10 +128,17 @@ urls:      site_schema: &SCHEMA "https://"      site_bigbrother_api:                !JOIN [*SCHEMA, *DOMAIN, "/bot/bigbrother"] +    site_clean_api:                     !JOIN [*SCHEMA, *DOMAIN, "/bot/clean"] +    site_clean_logs:                    !JOIN [*SCHEMA, *DOMAIN, "/bot/clean_logs"]      site_docs_api:                      !JOIN [*SCHEMA, *DOMAIN, "/bot/docs"]      site_facts_api:                     !JOIN [*SCHEMA, *DOMAIN, "/bot/snake_facts"]      site_hiphopify_api:                 !JOIN [*SCHEMA, *DOMAIN, "/bot/hiphopify"]      site_idioms_api:                    !JOIN [*SCHEMA, *DOMAIN, "/bot/snake_idioms"] +    site_infractions:                   !JOIN [*SCHEMA, *DOMAIN, "/bot/infractions"] +    site_infractions_user:              !JOIN [*SCHEMA, *DOMAIN, "/bot/infractions/user/{user_id}"] +    site_infractions_type:              !JOIN [*SCHEMA, *DOMAIN, "/bot/infractions/type/{infraction_type}"] +    site_infractions_by_id:             !JOIN [*SCHEMA, *DOMAIN, "/bot/infractions/id/{infraction_id}"] +    site_infractions_user_type_current: !JOIN [*SCHEMA, *DOMAIN, "/bot/infractions/user/{user_id}/{infraction_type}/current"]      site_names_api:                     !JOIN [*SCHEMA, *DOMAIN, "/bot/snake_names"]      site_off_topic_names_api:           !JOIN [*SCHEMA, *DOMAIN, "/bot/off-topic-names"]      site_quiz_api:                      !JOIN [*SCHEMA, *DOMAIN, "/bot/snake_quiz"] @@ -129,11 +147,6 @@ urls:      site_tags_api:                      !JOIN [*SCHEMA, *DOMAIN, "/bot/tags"]      site_user_api:                      !JOIN [*SCHEMA, *DOMAIN, "/bot/users"]      site_user_complete_api:             !JOIN [*SCHEMA, *DOMAIN, "/bot/users/complete"] -    site_infractions:                   !JOIN [*SCHEMA, *DOMAIN, "/bot/infractions"] -    site_infractions_user:              !JOIN [*SCHEMA, *DOMAIN, "/bot/infractions/user/{user_id}"] -    site_infractions_type:              !JOIN [*SCHEMA, *DOMAIN, "/bot/infractions/type/{infraction_type}"] -    site_infractions_by_id:             !JOIN [*SCHEMA, *DOMAIN, "/bot/infractions/id/{infraction_id}"] -    site_infractions_user_type_current: !JOIN [*SCHEMA, *DOMAIN, "/bot/infractions/user/{user_id}/{infraction_type}/current"]      # Env vars      deploy: !ENV "DEPLOY_URL"  |