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" |