From 0fca2445e2979d6e4bebf6a974c974a5ddd14fbe Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 14 Jun 2020 17:29:43 -0700 Subject: Move extensions into sub-directories --- bot/cogs/alias.py | 2 +- bot/cogs/antimalware.py | 98 ---- bot/cogs/antispam.py | 288 ----------- bot/cogs/backend/__init__.py | 0 bot/cogs/backend/config_verifier.py | 40 ++ bot/cogs/backend/error_handler.py | 287 +++++++++++ bot/cogs/backend/logging.py | 42 ++ bot/cogs/backend/sync/__init__.py | 7 + bot/cogs/backend/sync/cog.py | 180 +++++++ bot/cogs/backend/sync/syncers.py | 347 +++++++++++++ bot/cogs/bot.py | 385 --------------- bot/cogs/clean.py | 272 ---------- bot/cogs/config_verifier.py | 40 -- bot/cogs/defcon.py | 258 ---------- bot/cogs/doc.py | 511 ------------------- bot/cogs/error_handler.py | 287 ----------- bot/cogs/eval.py | 202 -------- bot/cogs/extensions.py | 236 --------- bot/cogs/filter_lists.py | 273 ---------- bot/cogs/filtering.py | 575 ---------------------- bot/cogs/filters/__init__.py | 0 bot/cogs/filters/antimalware.py | 98 ++++ bot/cogs/filters/antispam.py | 288 +++++++++++ bot/cogs/filters/filter_lists.py | 273 ++++++++++ bot/cogs/filters/filtering.py | 575 ++++++++++++++++++++++ bot/cogs/filters/security.py | 31 ++ bot/cogs/filters/token_remover.py | 182 +++++++ bot/cogs/filters/webhook_remover.py | 84 ++++ bot/cogs/help.py | 375 -------------- bot/cogs/info/__init__.py | 0 bot/cogs/info/doc.py | 511 +++++++++++++++++++ bot/cogs/info/help.py | 375 ++++++++++++++ bot/cogs/info/information.py | 422 ++++++++++++++++ bot/cogs/info/python_news.py | 232 +++++++++ bot/cogs/info/reddit.py | 304 ++++++++++++ bot/cogs/info/site.py | 146 ++++++ bot/cogs/info/source.py | 141 ++++++ bot/cogs/info/stats.py | 129 +++++ bot/cogs/info/tags.py | 277 +++++++++++ bot/cogs/info/wolfram.py | 280 +++++++++++ bot/cogs/information.py | 422 ---------------- bot/cogs/jams.py | 150 ------ bot/cogs/logging.py | 42 -- bot/cogs/moderation/__init__.py | 6 +- bot/cogs/moderation/defcon.py | 258 ++++++++++ bot/cogs/moderation/infraction/__init__.py | 0 bot/cogs/moderation/infraction/infractions.py | 370 ++++++++++++++ bot/cogs/moderation/infraction/management.py | 305 ++++++++++++ bot/cogs/moderation/infraction/scheduler.py | 463 +++++++++++++++++ bot/cogs/moderation/infraction/superstarify.py | 239 +++++++++ bot/cogs/moderation/infraction/utils.py | 201 ++++++++ bot/cogs/moderation/infractions.py | 370 -------------- bot/cogs/moderation/management.py | 305 ------------ bot/cogs/moderation/scheduler.py | 463 ----------------- bot/cogs/moderation/superstarify.py | 239 --------- bot/cogs/moderation/utils.py | 201 -------- bot/cogs/moderation/verification.py | 191 +++++++ bot/cogs/moderation/watchchannels/__init__.py | 9 + bot/cogs/moderation/watchchannels/bigbrother.py | 165 +++++++ bot/cogs/moderation/watchchannels/talentpool.py | 264 ++++++++++ bot/cogs/moderation/watchchannels/watchchannel.py | 348 +++++++++++++ bot/cogs/python_news.py | 232 --------- bot/cogs/reddit.py | 304 ------------ bot/cogs/reminders.py | 427 ---------------- bot/cogs/security.py | 31 -- bot/cogs/site.py | 146 ------ bot/cogs/snekbox.py | 349 ------------- bot/cogs/source.py | 141 ------ bot/cogs/stats.py | 129 ----- bot/cogs/sync/__init__.py | 7 - bot/cogs/sync/cog.py | 180 ------- bot/cogs/sync/syncers.py | 347 ------------- bot/cogs/tags.py | 277 ----------- bot/cogs/token_remover.py | 182 ------- bot/cogs/utils.py | 265 ---------- bot/cogs/utils/__init__.py | 0 bot/cogs/utils/bot.py | 385 +++++++++++++++ bot/cogs/utils/clean.py | 272 ++++++++++ bot/cogs/utils/eval.py | 202 ++++++++ bot/cogs/utils/extensions.py | 236 +++++++++ bot/cogs/utils/jams.py | 150 ++++++ bot/cogs/utils/reminders.py | 427 ++++++++++++++++ bot/cogs/utils/snekbox.py | 349 +++++++++++++ bot/cogs/utils/utils.py | 265 ++++++++++ bot/cogs/verification.py | 191 ------- bot/cogs/watchchannels/__init__.py | 9 - bot/cogs/watchchannels/bigbrother.py | 165 ------- bot/cogs/watchchannels/talentpool.py | 264 ---------- bot/cogs/watchchannels/watchchannel.py | 348 ------------- bot/cogs/webhook_remover.py | 84 ---- bot/cogs/wolfram.py | 280 ----------- tests/bot/cogs/moderation/test_infractions.py | 2 +- tests/bot/cogs/sync/test_base.py | 2 +- tests/bot/cogs/sync/test_cog.py | 4 +- tests/bot/cogs/sync/test_roles.py | 2 +- tests/bot/cogs/sync/test_users.py | 2 +- tests/bot/cogs/test_antimalware.py | 2 +- tests/bot/cogs/test_antispam.py | 2 +- tests/bot/cogs/test_information.py | 2 +- tests/bot/cogs/test_security.py | 2 +- tests/bot/cogs/test_snekbox.py | 4 +- tests/bot/cogs/test_token_remover.py | 4 +- 102 files changed, 10368 insertions(+), 10368 deletions(-) delete mode 100644 bot/cogs/antimalware.py delete mode 100644 bot/cogs/antispam.py create mode 100644 bot/cogs/backend/__init__.py create mode 100644 bot/cogs/backend/config_verifier.py create mode 100644 bot/cogs/backend/error_handler.py create mode 100644 bot/cogs/backend/logging.py create mode 100644 bot/cogs/backend/sync/__init__.py create mode 100644 bot/cogs/backend/sync/cog.py create mode 100644 bot/cogs/backend/sync/syncers.py delete mode 100644 bot/cogs/bot.py delete mode 100644 bot/cogs/clean.py delete mode 100644 bot/cogs/config_verifier.py delete mode 100644 bot/cogs/defcon.py delete mode 100644 bot/cogs/doc.py delete mode 100644 bot/cogs/error_handler.py delete mode 100644 bot/cogs/eval.py delete mode 100644 bot/cogs/extensions.py delete mode 100644 bot/cogs/filter_lists.py delete mode 100644 bot/cogs/filtering.py create mode 100644 bot/cogs/filters/__init__.py create mode 100644 bot/cogs/filters/antimalware.py create mode 100644 bot/cogs/filters/antispam.py create mode 100644 bot/cogs/filters/filter_lists.py create mode 100644 bot/cogs/filters/filtering.py create mode 100644 bot/cogs/filters/security.py create mode 100644 bot/cogs/filters/token_remover.py create mode 100644 bot/cogs/filters/webhook_remover.py delete mode 100644 bot/cogs/help.py create mode 100644 bot/cogs/info/__init__.py create mode 100644 bot/cogs/info/doc.py create mode 100644 bot/cogs/info/help.py create mode 100644 bot/cogs/info/information.py create mode 100644 bot/cogs/info/python_news.py create mode 100644 bot/cogs/info/reddit.py create mode 100644 bot/cogs/info/site.py create mode 100644 bot/cogs/info/source.py create mode 100644 bot/cogs/info/stats.py create mode 100644 bot/cogs/info/tags.py create mode 100644 bot/cogs/info/wolfram.py delete mode 100644 bot/cogs/information.py delete mode 100644 bot/cogs/jams.py delete mode 100644 bot/cogs/logging.py create mode 100644 bot/cogs/moderation/defcon.py create mode 100644 bot/cogs/moderation/infraction/__init__.py create mode 100644 bot/cogs/moderation/infraction/infractions.py create mode 100644 bot/cogs/moderation/infraction/management.py create mode 100644 bot/cogs/moderation/infraction/scheduler.py create mode 100644 bot/cogs/moderation/infraction/superstarify.py create mode 100644 bot/cogs/moderation/infraction/utils.py delete mode 100644 bot/cogs/moderation/infractions.py delete mode 100644 bot/cogs/moderation/management.py delete mode 100644 bot/cogs/moderation/scheduler.py delete mode 100644 bot/cogs/moderation/superstarify.py delete mode 100644 bot/cogs/moderation/utils.py create mode 100644 bot/cogs/moderation/verification.py create mode 100644 bot/cogs/moderation/watchchannels/__init__.py create mode 100644 bot/cogs/moderation/watchchannels/bigbrother.py create mode 100644 bot/cogs/moderation/watchchannels/talentpool.py create mode 100644 bot/cogs/moderation/watchchannels/watchchannel.py delete mode 100644 bot/cogs/python_news.py delete mode 100644 bot/cogs/reddit.py delete mode 100644 bot/cogs/reminders.py delete mode 100644 bot/cogs/security.py delete mode 100644 bot/cogs/site.py delete mode 100644 bot/cogs/snekbox.py delete mode 100644 bot/cogs/source.py delete mode 100644 bot/cogs/stats.py delete mode 100644 bot/cogs/sync/__init__.py delete mode 100644 bot/cogs/sync/cog.py delete mode 100644 bot/cogs/sync/syncers.py delete mode 100644 bot/cogs/tags.py delete mode 100644 bot/cogs/token_remover.py delete mode 100644 bot/cogs/utils.py create mode 100644 bot/cogs/utils/__init__.py create mode 100644 bot/cogs/utils/bot.py create mode 100644 bot/cogs/utils/clean.py create mode 100644 bot/cogs/utils/eval.py create mode 100644 bot/cogs/utils/extensions.py create mode 100644 bot/cogs/utils/jams.py create mode 100644 bot/cogs/utils/reminders.py create mode 100644 bot/cogs/utils/snekbox.py create mode 100644 bot/cogs/utils/utils.py delete mode 100644 bot/cogs/verification.py delete mode 100644 bot/cogs/watchchannels/__init__.py delete mode 100644 bot/cogs/watchchannels/bigbrother.py delete mode 100644 bot/cogs/watchchannels/talentpool.py delete mode 100644 bot/cogs/watchchannels/watchchannel.py delete mode 100644 bot/cogs/webhook_remover.py delete mode 100644 bot/cogs/wolfram.py diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index 55c7efe65..3c5a35c24 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -8,7 +8,7 @@ from discord.ext.commands import ( ) from bot.bot import Bot -from bot.cogs.extensions import Extension +from bot.cogs.utils.extensions import Extension from bot.converters import FetchedMember, TagNameConverter from bot.pagination import LinePaginator diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py deleted file mode 100644 index c76bd2c60..000000000 --- a/bot/cogs/antimalware.py +++ /dev/null @@ -1,98 +0,0 @@ -import logging -import typing as t -from os.path import splitext - -from discord import Embed, Message, NotFound -from discord.ext.commands import Cog - -from bot.bot import Bot -from bot.constants import Channels, STAFF_ROLES, URLs - -log = logging.getLogger(__name__) - -PY_EMBED_DESCRIPTION = ( - "It looks like you tried to attach a Python file - " - f"please use a code-pasting service such as {URLs.site_schema}{URLs.site_paste}" -) - -TXT_EMBED_DESCRIPTION = ( - "**Uh-oh!** It looks like your message got zapped by our spam filter. " - "We currently don't allow `.txt` attachments, so here are some tips to help you travel safely: \n\n" - "• If you attempted to send a message longer than 2000 characters, try shortening your message " - "to fit within the character limit or use a pasting service (see below) \n\n" - "• If you tried to show someone your code, you can use codeblocks \n(run `!code-blocks` in " - "{cmd_channel_mention} for more information) or use a pasting service like: " - f"\n\n{URLs.site_schema}{URLs.site_paste}" -) - -DISALLOWED_EMBED_DESCRIPTION = ( - "It looks like you tried to attach file type(s) that we do not allow ({blocked_extensions_str}). " - "We currently allow the following file types: **{joined_whitelist}**.\n\n" - "Feel free to ask in {meta_channel_mention} if you think this is a mistake." -) - - -class AntiMalware(Cog): - """Delete messages which contain attachments with non-whitelisted file extensions.""" - - def __init__(self, bot: Bot): - self.bot = bot - - def _get_whitelisted_file_formats(self) -> list: - """Get the file formats currently on the whitelist.""" - return self.bot.filter_list_cache['FILE_FORMAT.True'].keys() - - def _get_disallowed_extensions(self, message: Message) -> t.Iterable[str]: - """Get an iterable containing all the disallowed extensions of attachments.""" - file_extensions = {splitext(attachment.filename.lower())[1] for attachment in message.attachments} - extensions_blocked = file_extensions - set(self._get_whitelisted_file_formats()) - return extensions_blocked - - @Cog.listener() - async def on_message(self, message: Message) -> None: - """Identify messages with prohibited attachments.""" - # Return when message don't have attachment and don't moderate DMs - if not message.attachments or not message.guild: - return - - # Check if user is staff, if is, return - # Since we only care that roles exist to iterate over, check for the attr rather than a User/Member instance - if hasattr(message.author, "roles") and any(role.id in STAFF_ROLES for role in message.author.roles): - return - - embed = Embed() - extensions_blocked = self._get_disallowed_extensions(message) - blocked_extensions_str = ', '.join(extensions_blocked) - if ".py" in extensions_blocked: - # Short-circuit on *.py files to provide a pastebin link - embed.description = PY_EMBED_DESCRIPTION - elif ".txt" in extensions_blocked: - # Work around Discord AutoConversion of messages longer than 2000 chars to .txt - cmd_channel = self.bot.get_channel(Channels.bot_commands) - embed.description = TXT_EMBED_DESCRIPTION.format(cmd_channel_mention=cmd_channel.mention) - elif extensions_blocked: - meta_channel = self.bot.get_channel(Channels.meta) - embed.description = DISALLOWED_EMBED_DESCRIPTION.format( - joined_whitelist=', '.join(self._get_whitelisted_file_formats()), - blocked_extensions_str=blocked_extensions_str, - meta_channel_mention=meta_channel.mention, - ) - - if embed.description: - log.info( - f"User '{message.author}' ({message.author.id}) uploaded blacklisted file(s): {blocked_extensions_str}", - extra={"attachment_list": [attachment.filename for attachment in message.attachments]} - ) - - await message.channel.send(f"Hey {message.author.mention}!", embed=embed) - - # Delete the offending message: - try: - await message.delete() - except NotFound: - log.info(f"Tried to delete message `{message.id}`, but message could not be found.") - - -def setup(bot: Bot) -> None: - """Load the AntiMalware cog.""" - bot.add_cog(AntiMalware(bot)) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py deleted file mode 100644 index 0bcca578d..000000000 --- a/bot/cogs/antispam.py +++ /dev/null @@ -1,288 +0,0 @@ -import asyncio -import logging -from collections.abc import Mapping -from dataclasses import dataclass, field -from datetime import datetime, timedelta -from operator import itemgetter -from typing import Dict, Iterable, List, Set - -from discord import Colour, Member, Message, NotFound, Object, TextChannel -from discord.ext.commands import Cog - -from bot import rules -from bot.bot import Bot -from bot.cogs.moderation import ModLog -from bot.constants import ( - AntiSpam as AntiSpamConfig, Channels, - Colours, DEBUG_MODE, Event, Filter, - Guild as GuildConfig, Icons, - STAFF_ROLES, -) -from bot.converters import Duration -from bot.utils.messages import send_attachments - - -log = logging.getLogger(__name__) - -RULE_FUNCTION_MAPPING = { - 'attachments': rules.apply_attachments, - 'burst': rules.apply_burst, - 'burst_shared': rules.apply_burst_shared, - 'chars': rules.apply_chars, - 'discord_emojis': rules.apply_discord_emojis, - 'duplicates': rules.apply_duplicates, - 'links': rules.apply_links, - 'mentions': rules.apply_mentions, - 'newlines': rules.apply_newlines, - 'role_mentions': rules.apply_role_mentions -} - - -@dataclass -class DeletionContext: - """Represents a Deletion Context for a single spam event.""" - - channel: TextChannel - members: Dict[int, Member] = field(default_factory=dict) - rules: Set[str] = field(default_factory=set) - messages: Dict[int, Message] = field(default_factory=dict) - attachments: List[List[str]] = field(default_factory=list) - - async def add(self, rule_name: str, members: Iterable[Member], messages: Iterable[Message]) -> None: - """Adds new rule violation events to the deletion context.""" - self.rules.add(rule_name) - - for member in members: - if member.id not in self.members: - self.members[member.id] = member - - for message in messages: - if message.id not in self.messages: - self.messages[message.id] = message - - # Re-upload attachments - destination = message.guild.get_channel(Channels.attachment_log) - urls = await send_attachments(message, destination, link_large=False) - self.attachments.append(urls) - - async def upload_messages(self, actor_id: int, modlog: ModLog) -> None: - """Method that takes care of uploading the queue and posting modlog alert.""" - triggered_by_users = ", ".join(f"{m} (`{m.id}`)" for m in self.members.values()) - - mod_alert_message = ( - f"**Triggered by:** {triggered_by_users}\n" - f"**Channel:** {self.channel.mention}\n" - f"**Rules:** {', '.join(rule for rule in self.rules)}\n" - ) - - # For multiple messages or those with excessive newlines, use the logs API - if len(self.messages) > 1 or 'newlines' in self.rules: - url = await modlog.upload_log(self.messages.values(), actor_id, self.attachments) - mod_alert_message += f"A complete log of the offending messages can be found [here]({url})" - else: - mod_alert_message += "Message:\n" - [message] = self.messages.values() - content = message.clean_content - remaining_chars = 2040 - len(mod_alert_message) - - if len(content) > remaining_chars: - content = content[:remaining_chars] + "..." - - mod_alert_message += f"{content}" - - *_, last_message = self.messages.values() - await modlog.send_log_message( - icon_url=Icons.filtering, - colour=Colour(Colours.soft_red), - title="Spam detected!", - text=mod_alert_message, - thumbnail=last_message.author.avatar_url_as(static_format="png"), - channel_id=Channels.mod_alerts, - ping_everyone=AntiSpamConfig.ping_everyone - ) - - -class AntiSpam(Cog): - """Cog that controls our anti-spam measures.""" - - def __init__(self, bot: Bot, validation_errors: Dict[str, str]) -> None: - self.bot = bot - self.validation_errors = validation_errors - role_id = AntiSpamConfig.punishment['role_id'] - self.muted_role = Object(role_id) - self.expiration_date_converter = Duration() - - self.message_deletion_queue = dict() - - self.bot.loop.create_task(self.alert_on_validation_error()) - - @property - def mod_log(self) -> ModLog: - """Allows for easy access of the ModLog cog.""" - return self.bot.get_cog("ModLog") - - async def alert_on_validation_error(self) -> None: - """Unloads the cog and alerts admins if configuration validation failed.""" - await self.bot.wait_until_guild_available() - if self.validation_errors: - body = "**The following errors were encountered:**\n" - body += "\n".join(f"- {error}" for error in self.validation_errors.values()) - body += "\n\n**The cog has been unloaded.**" - - await self.mod_log.send_log_message( - title="Error: AntiSpam configuration validation failed!", - text=body, - ping_everyone=True, - icon_url=Icons.token_removed, - colour=Colour.red() - ) - - self.bot.remove_cog(self.__class__.__name__) - return - - @Cog.listener() - async def on_message(self, message: Message) -> None: - """Applies the antispam rules to each received message.""" - if ( - not message.guild - or message.guild.id != GuildConfig.id - or message.author.bot - or (message.channel.id in Filter.channel_whitelist and not DEBUG_MODE) - or (any(role.id in STAFF_ROLES for role in message.author.roles) and not DEBUG_MODE) - ): - return - - # Fetch the rule configuration with the highest rule interval. - max_interval_config = max( - AntiSpamConfig.rules.values(), - key=itemgetter('interval') - ) - max_interval = max_interval_config['interval'] - - # Store history messages since `interval` seconds ago in a list to prevent unnecessary API calls. - earliest_relevant_at = datetime.utcnow() - timedelta(seconds=max_interval) - relevant_messages = [ - msg async for msg in message.channel.history(after=earliest_relevant_at, oldest_first=False) - if not msg.author.bot - ] - - for rule_name in AntiSpamConfig.rules: - rule_config = AntiSpamConfig.rules[rule_name] - rule_function = RULE_FUNCTION_MAPPING[rule_name] - - # Create a list of messages that were sent in the interval that the rule cares about. - latest_interesting_stamp = datetime.utcnow() - timedelta(seconds=rule_config['interval']) - messages_for_rule = [ - msg for msg in relevant_messages if msg.created_at > latest_interesting_stamp - ] - result = await rule_function(message, messages_for_rule, rule_config) - - # If the rule returns `None`, that means the message didn't violate it. - # If it doesn't, it returns a tuple in the form `(str, Iterable[discord.Member])` - # which contains the reason for why the message violated the rule and - # an iterable of all members that violated the rule. - if result is not None: - self.bot.stats.incr(f"mod_alerts.{rule_name}") - reason, members, relevant_messages = result - full_reason = f"`{rule_name}` rule: {reason}" - - # If there's no spam event going on for this channel, start a new Message Deletion Context - channel = message.channel - if channel.id not in self.message_deletion_queue: - log.trace(f"Creating queue for channel `{channel.id}`") - self.message_deletion_queue[message.channel.id] = DeletionContext(channel) - self.bot.loop.create_task(self._process_deletion_context(message.channel.id)) - - # Add the relevant of this trigger to the Deletion Context - await self.message_deletion_queue[message.channel.id].add( - rule_name=rule_name, - members=members, - messages=relevant_messages - ) - - for member in members: - - # Fire it off as a background task to ensure - # that the sleep doesn't block further tasks - self.bot.loop.create_task( - self.punish(message, member, full_reason) - ) - - await self.maybe_delete_messages(channel, relevant_messages) - break - - async def punish(self, msg: Message, member: Member, reason: str) -> None: - """Punishes the given member for triggering an antispam rule.""" - if not any(role.id == self.muted_role.id for role in member.roles): - remove_role_after = AntiSpamConfig.punishment['remove_after'] - - # Get context and make sure the bot becomes the actor of infraction by patching the `author` attributes - context = await self.bot.get_context(msg) - context.author = self.bot.user - context.message.author = self.bot.user - - # Since we're going to invoke the tempmute command directly, we need to manually call the converter. - dt_remove_role_after = await self.expiration_date_converter.convert(context, f"{remove_role_after}S") - await context.invoke( - self.bot.get_command('tempmute'), - member, - dt_remove_role_after, - reason=reason - ) - - async def maybe_delete_messages(self, channel: TextChannel, messages: List[Message]) -> None: - """Cleans the messages if cleaning is configured.""" - if AntiSpamConfig.clean_offending: - # If we have more than one message, we can use bulk delete. - if len(messages) > 1: - message_ids = [message.id for message in messages] - self.mod_log.ignore(Event.message_delete, *message_ids) - await channel.delete_messages(messages) - - # Otherwise, the bulk delete endpoint will throw up. - # Delete the message directly instead. - else: - self.mod_log.ignore(Event.message_delete, messages[0].id) - try: - await messages[0].delete() - except NotFound: - log.info(f"Tried to delete message `{messages[0].id}`, but message could not be found.") - - async def _process_deletion_context(self, context_id: int) -> None: - """Processes the Deletion Context queue.""" - log.trace("Sleeping before processing message deletion queue.") - await asyncio.sleep(10) - - if context_id not in self.message_deletion_queue: - log.error(f"Started processing deletion queue for context `{context_id}`, but it was not found!") - return - - deletion_context = self.message_deletion_queue.pop(context_id) - await deletion_context.upload_messages(self.bot.user.id, self.mod_log) - - -def validate_config(rules_: Mapping = AntiSpamConfig.rules) -> Dict[str, str]: - """Validates the antispam configs.""" - validation_errors = {} - for name, config in rules_.items(): - if name not in RULE_FUNCTION_MAPPING: - log.error( - f"Unrecognized antispam rule `{name}`. " - f"Valid rules are: {', '.join(RULE_FUNCTION_MAPPING)}" - ) - validation_errors[name] = f"`{name}` is not recognized as an antispam rule." - continue - for required_key in ('interval', 'max'): - if required_key not in config: - log.error( - f"`{required_key}` is required but was not " - f"set in rule `{name}`'s configuration." - ) - validation_errors[name] = f"Key `{required_key}` is required but not set for rule `{name}`" - return validation_errors - - -def setup(bot: Bot) -> None: - """Validate the AntiSpam configs and load the AntiSpam cog.""" - validation_errors = validate_config() - bot.add_cog(AntiSpam(bot, validation_errors)) diff --git a/bot/cogs/backend/__init__.py b/bot/cogs/backend/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bot/cogs/backend/config_verifier.py b/bot/cogs/backend/config_verifier.py new file mode 100644 index 000000000..d72c6c22e --- /dev/null +++ b/bot/cogs/backend/config_verifier.py @@ -0,0 +1,40 @@ +import logging + +from discord.ext.commands import Cog + +from bot import constants +from bot.bot import Bot + + +log = logging.getLogger(__name__) + + +class ConfigVerifier(Cog): + """Verify config on startup.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.channel_verify_task = self.bot.loop.create_task(self.verify_channels()) + + async def verify_channels(self) -> None: + """ + Verify channels. + + If any channels in config aren't present in server, log them in a warning. + """ + await self.bot.wait_until_guild_available() + server = self.bot.get_guild(constants.Guild.id) + + server_channel_ids = {channel.id for channel in server.channels} + invalid_channels = [ + channel_name for channel_name, channel_id in constants.Channels + if channel_id not in server_channel_ids + ] + + if invalid_channels: + log.warning(f"Configured channels do not exist in server: {', '.join(invalid_channels)}.") + + +def setup(bot: Bot) -> None: + """Load the ConfigVerifier cog.""" + bot.add_cog(ConfigVerifier(bot)) diff --git a/bot/cogs/backend/error_handler.py b/bot/cogs/backend/error_handler.py new file mode 100644 index 000000000..f9d4de638 --- /dev/null +++ b/bot/cogs/backend/error_handler.py @@ -0,0 +1,287 @@ +import contextlib +import logging +import typing as t + +from discord import Embed +from discord.ext.commands import Cog, Context, errors +from sentry_sdk import push_scope + +from bot.api import ResponseCodeError +from bot.bot import Bot +from bot.constants import Channels, Colours +from bot.converters import TagNameConverter +from bot.utils.checks import InWhitelistCheckFailure + +log = logging.getLogger(__name__) + + +class ErrorHandler(Cog): + """Handles errors emitted from commands.""" + + def __init__(self, bot: Bot): + self.bot = bot + + def _get_error_embed(self, title: str, body: str) -> Embed: + """Return an embed that contains the exception.""" + return Embed( + title=title, + colour=Colours.soft_red, + description=body + ) + + @Cog.listener() + async def on_command_error(self, ctx: Context, e: errors.CommandError) -> None: + """ + Provide generic command error handling. + + Error handling is deferred to any local error handler, if present. This is done by + checking for the presence of a `handled` attribute on the error. + + Error handling emits a single error message in the invoking context `ctx` and a log message, + prioritised as follows: + + 1. If the name fails to match a command: + * If it matches shh+ or unshh+, the channel is silenced or unsilenced respectively. + Otherwise if it matches a tag, the tag is invoked + * If CommandNotFound is raised when invoking the tag (determined by the presence of the + `invoked_from_error_handler` attribute), this error is treated as being unexpected + and therefore sends an error message + * Commands in the verification channel are ignored + 2. UserInputError: see `handle_user_input_error` + 3. CheckFailure: see `handle_check_failure` + 4. CommandOnCooldown: send an error message in the invoking context + 5. ResponseCodeError: see `handle_api_error` + 6. Otherwise, if not a DisabledCommand, handling is deferred to `handle_unexpected_error` + """ + command = ctx.command + + if hasattr(e, "handled"): + log.trace(f"Command {command} had its error already handled locally; ignoring.") + return + + if isinstance(e, errors.CommandNotFound) and not hasattr(ctx, "invoked_from_error_handler"): + if await self.try_silence(ctx): + return + if ctx.channel.id != Channels.verification: + # Try to look for a tag with the command's name + await self.try_get_tag(ctx) + return # Exit early to avoid logging. + elif isinstance(e, errors.UserInputError): + await self.handle_user_input_error(ctx, e) + elif isinstance(e, errors.CheckFailure): + await self.handle_check_failure(ctx, e) + elif isinstance(e, errors.CommandOnCooldown): + await ctx.send(e) + elif isinstance(e, errors.CommandInvokeError): + if isinstance(e.original, ResponseCodeError): + await self.handle_api_error(ctx, e.original) + else: + await self.handle_unexpected_error(ctx, e.original) + return # Exit early to avoid logging. + elif not isinstance(e, errors.DisabledCommand): + # ConversionError, MaxConcurrencyReached, ExtensionError + await self.handle_unexpected_error(ctx, e) + return # Exit early to avoid logging. + + log.debug( + f"Command {command} invoked by {ctx.message.author} with error " + f"{e.__class__.__name__}: {e}" + ) + + @staticmethod + def get_help_command(ctx: Context) -> t.Coroutine: + """Return a prepared `help` command invocation coroutine.""" + if ctx.command: + return ctx.send_help(ctx.command) + + return ctx.send_help() + + async def try_silence(self, ctx: Context) -> bool: + """ + Attempt to invoke the silence or unsilence command if invoke with matches a pattern. + + Respecting the checks if: + * invoked with `shh+` silence channel for amount of h's*2 with max of 15. + * invoked with `unshh+` unsilence channel + Return bool depending on success of command. + """ + command = ctx.invoked_with.lower() + silence_command = self.bot.get_command("silence") + ctx.invoked_from_error_handler = True + try: + if not await silence_command.can_run(ctx): + log.debug("Cancelling attempt to invoke silence/unsilence due to failed checks.") + return False + except errors.CommandError: + log.debug("Cancelling attempt to invoke silence/unsilence due to failed checks.") + return False + if command.startswith("shh"): + await ctx.invoke(silence_command, duration=min(command.count("h")*2, 15)) + return True + elif command.startswith("unshh"): + await ctx.invoke(self.bot.get_command("unsilence")) + return True + return False + + async def try_get_tag(self, ctx: Context) -> None: + """ + Attempt to display a tag by interpreting the command name as a tag name. + + The invocation of tags get respects its checks. Any CommandErrors raised will be handled + by `on_command_error`, but the `invoked_from_error_handler` attribute will be added to + the context to prevent infinite recursion in the case of a CommandNotFound exception. + """ + tags_get_command = self.bot.get_command("tags get") + ctx.invoked_from_error_handler = True + + log_msg = "Cancelling attempt to fall back to a tag due to failed checks." + try: + if not await tags_get_command.can_run(ctx): + log.debug(log_msg) + return + except errors.CommandError as tag_error: + log.debug(log_msg) + await self.on_command_error(ctx, tag_error) + return + + try: + tag_name = await TagNameConverter.convert(ctx, ctx.invoked_with) + except errors.BadArgument: + log.debug( + f"{ctx.author} tried to use an invalid command " + f"and the fallback tag failed validation in TagNameConverter." + ) + else: + with contextlib.suppress(ResponseCodeError): + await ctx.invoke(tags_get_command, tag_name=tag_name) + # Return to not raise the exception + return + + async def handle_user_input_error(self, ctx: Context, e: errors.UserInputError) -> None: + """ + Send an error message in `ctx` for UserInputError, sometimes invoking the help command too. + + * MissingRequiredArgument: send an error message with arg name and the help command + * TooManyArguments: send an error message and the help command + * BadArgument: send an error message and the help command + * BadUnionArgument: send an error message including the error produced by the last converter + * ArgumentParsingError: send an error message + * Other: send an error message and the help command + """ + prepared_help_command = self.get_help_command(ctx) + + if isinstance(e, errors.MissingRequiredArgument): + embed = self._get_error_embed("Missing required argument", e.param.name) + await ctx.send(embed=embed) + await prepared_help_command + self.bot.stats.incr("errors.missing_required_argument") + elif isinstance(e, errors.TooManyArguments): + embed = self._get_error_embed("Too many arguments", str(e)) + await ctx.send(embed=embed) + await prepared_help_command + self.bot.stats.incr("errors.too_many_arguments") + elif isinstance(e, errors.BadArgument): + embed = self._get_error_embed("Bad argument", str(e)) + await ctx.send(embed=embed) + await prepared_help_command + self.bot.stats.incr("errors.bad_argument") + elif isinstance(e, errors.BadUnionArgument): + embed = self._get_error_embed("Bad argument", f"{e}\n{e.errors[-1]}") + await ctx.send(embed=embed) + self.bot.stats.incr("errors.bad_union_argument") + elif isinstance(e, errors.ArgumentParsingError): + embed = self._get_error_embed("Argument parsing error", str(e)) + await ctx.send(embed=embed) + self.bot.stats.incr("errors.argument_parsing_error") + else: + embed = self._get_error_embed( + "Input error", + "Something about your input seems off. Check the arguments and try again." + ) + await ctx.send(embed=embed) + await prepared_help_command + self.bot.stats.incr("errors.other_user_input_error") + + @staticmethod + async def handle_check_failure(ctx: Context, e: errors.CheckFailure) -> None: + """ + Send an error message in `ctx` for certain types of CheckFailure. + + The following types are handled: + + * BotMissingPermissions + * BotMissingRole + * BotMissingAnyRole + * NoPrivateMessage + * InWhitelistCheckFailure + """ + bot_missing_errors = ( + errors.BotMissingPermissions, + errors.BotMissingRole, + errors.BotMissingAnyRole + ) + + if isinstance(e, bot_missing_errors): + ctx.bot.stats.incr("errors.bot_permission_error") + await ctx.send( + "Sorry, it looks like I don't have the permissions or roles I need to do that." + ) + elif isinstance(e, (InWhitelistCheckFailure, errors.NoPrivateMessage)): + ctx.bot.stats.incr("errors.wrong_channel_or_dm_error") + await ctx.send(e) + + @staticmethod + async def handle_api_error(ctx: Context, e: ResponseCodeError) -> None: + """Send an error message in `ctx` for ResponseCodeError and log it.""" + if e.status == 404: + await ctx.send("There does not seem to be anything matching your query.") + log.debug(f"API responded with 404 for command {ctx.command}") + ctx.bot.stats.incr("errors.api_error_404") + elif e.status == 400: + content = await e.response.json() + log.debug(f"API responded with 400 for command {ctx.command}: %r.", content) + await ctx.send("According to the API, your request is malformed.") + ctx.bot.stats.incr("errors.api_error_400") + elif 500 <= e.status < 600: + await ctx.send("Sorry, there seems to be an internal issue with the API.") + log.warning(f"API responded with {e.status} for command {ctx.command}") + ctx.bot.stats.incr("errors.api_internal_server_error") + else: + await ctx.send(f"Got an unexpected status code from the API (`{e.status}`).") + log.warning(f"Unexpected API response for command {ctx.command}: {e.status}") + ctx.bot.stats.incr(f"errors.api_error_{e.status}") + + @staticmethod + async def handle_unexpected_error(ctx: Context, e: errors.CommandError) -> None: + """Send a generic error message in `ctx` and log the exception as an error with exc_info.""" + await ctx.send( + f"Sorry, an unexpected error occurred. Please let us know!\n\n" + f"```{e.__class__.__name__}: {e}```" + ) + + ctx.bot.stats.incr("errors.unexpected") + + with push_scope() as scope: + scope.user = { + "id": ctx.author.id, + "username": str(ctx.author) + } + + scope.set_tag("command", ctx.command.qualified_name) + scope.set_tag("message_id", ctx.message.id) + scope.set_tag("channel_id", ctx.channel.id) + + scope.set_extra("full_message", ctx.message.content) + + if ctx.guild is not None: + scope.set_extra( + "jump_to", + f"https://discordapp.com/channels/{ctx.guild.id}/{ctx.channel.id}/{ctx.message.id}" + ) + + log.error(f"Error executing command invoked by {ctx.message.author}: {ctx.message.content}", exc_info=e) + + +def setup(bot: Bot) -> None: + """Load the ErrorHandler cog.""" + bot.add_cog(ErrorHandler(bot)) diff --git a/bot/cogs/backend/logging.py b/bot/cogs/backend/logging.py new file mode 100644 index 000000000..94fa2b139 --- /dev/null +++ b/bot/cogs/backend/logging.py @@ -0,0 +1,42 @@ +import logging + +from discord import Embed +from discord.ext.commands import Cog + +from bot.bot import Bot +from bot.constants import Channels, DEBUG_MODE + + +log = logging.getLogger(__name__) + + +class Logging(Cog): + """Debug logging module.""" + + def __init__(self, bot: Bot): + self.bot = bot + + self.bot.loop.create_task(self.startup_greeting()) + + async def startup_greeting(self) -> None: + """Announce our presence to the configured devlog channel.""" + await self.bot.wait_until_guild_available() + log.info("Bot connected!") + + embed = Embed(description="Connected!") + embed.set_author( + name="Python Bot", + url="https://github.com/python-discord/bot", + icon_url=( + "https://raw.githubusercontent.com/" + "python-discord/branding/master/logos/logo_circle/logo_circle_large.png" + ) + ) + + if not DEBUG_MODE: + await self.bot.get_channel(Channels.dev_log).send(embed=embed) + + +def setup(bot: Bot) -> None: + """Load the Logging cog.""" + bot.add_cog(Logging(bot)) diff --git a/bot/cogs/backend/sync/__init__.py b/bot/cogs/backend/sync/__init__.py new file mode 100644 index 000000000..fe7df4e9b --- /dev/null +++ b/bot/cogs/backend/sync/__init__.py @@ -0,0 +1,7 @@ +from bot.bot import Bot +from .cog import Sync + + +def setup(bot: Bot) -> None: + """Load the Sync cog.""" + bot.add_cog(Sync(bot)) diff --git a/bot/cogs/backend/sync/cog.py b/bot/cogs/backend/sync/cog.py new file mode 100644 index 000000000..274845a50 --- /dev/null +++ b/bot/cogs/backend/sync/cog.py @@ -0,0 +1,180 @@ +import logging +from typing import Any, Dict + +from discord import Member, Role, User +from discord.ext import commands +from discord.ext.commands import Cog, Context + +from bot import constants +from bot.api import ResponseCodeError +from bot.bot import Bot +from . import syncers + +log = logging.getLogger(__name__) + + +class Sync(Cog): + """Captures relevant events and sends them to the site.""" + + def __init__(self, bot: Bot) -> None: + self.bot = bot + self.role_syncer = syncers.RoleSyncer(self.bot) + self.user_syncer = syncers.UserSyncer(self.bot) + + self.bot.loop.create_task(self.sync_guild()) + + async def sync_guild(self) -> None: + """Syncs the roles/users of the guild with the database.""" + await self.bot.wait_until_guild_available() + + guild = self.bot.get_guild(constants.Guild.id) + if guild is None: + return + + for syncer in (self.role_syncer, self.user_syncer): + await syncer.sync(guild) + + async def patch_user(self, user_id: int, json: Dict[str, Any], ignore_404: bool = False) -> None: + """Send a PATCH request to partially update a user in the database.""" + try: + await self.bot.api_client.patch(f"bot/users/{user_id}", json=json) + except ResponseCodeError as e: + if e.response.status != 404: + raise + if not ignore_404: + log.warning("Unable to update user, got 404. Assuming race condition from join event.") + + @Cog.listener() + async def on_guild_role_create(self, role: Role) -> None: + """Adds newly create role to the database table over the API.""" + if role.guild.id != constants.Guild.id: + return + + await self.bot.api_client.post( + 'bot/roles', + json={ + 'colour': role.colour.value, + 'id': role.id, + 'name': role.name, + 'permissions': role.permissions.value, + 'position': role.position, + } + ) + + @Cog.listener() + async def on_guild_role_delete(self, role: Role) -> None: + """Deletes role from the database when it's deleted from the guild.""" + if role.guild.id != constants.Guild.id: + return + + await self.bot.api_client.delete(f'bot/roles/{role.id}') + + @Cog.listener() + async def on_guild_role_update(self, before: Role, after: Role) -> None: + """Syncs role with the database if any of the stored attributes were updated.""" + if after.guild.id != constants.Guild.id: + return + + was_updated = ( + before.name != after.name + or before.colour != after.colour + or before.permissions != after.permissions + or before.position != after.position + ) + + if was_updated: + await self.bot.api_client.put( + f'bot/roles/{after.id}', + json={ + 'colour': after.colour.value, + 'id': after.id, + 'name': after.name, + 'permissions': after.permissions.value, + 'position': after.position, + } + ) + + @Cog.listener() + async def on_member_join(self, member: Member) -> None: + """ + Adds a new user or updates existing user to the database when a member joins the guild. + + If the joining member is a user that is already known to the database (i.e., a user that + previously left), it will update the user's information. If the user is not yet known by + the database, the user is added. + """ + if member.guild.id != constants.Guild.id: + return + + packed = { + 'discriminator': int(member.discriminator), + 'id': member.id, + 'in_guild': True, + 'name': member.name, + 'roles': sorted(role.id for role in member.roles) + } + + got_error = False + + try: + # First try an update of the user to set the `in_guild` field and other + # fields that may have changed since the last time we've seen them. + await self.bot.api_client.put(f'bot/users/{member.id}', json=packed) + + except ResponseCodeError as e: + # If we didn't get 404, something else broke - propagate it up. + if e.response.status != 404: + raise + + got_error = True # yikes + + if got_error: + # If we got `404`, the user is new. Create them. + await self.bot.api_client.post('bot/users', json=packed) + + @Cog.listener() + async def on_member_remove(self, member: Member) -> None: + """Set the in_guild field to False when a member leaves the guild.""" + if member.guild.id != constants.Guild.id: + return + + await self.patch_user(member.id, json={"in_guild": False}) + + @Cog.listener() + async def on_member_update(self, before: Member, after: Member) -> None: + """Update the roles of the member in the database if a change is detected.""" + if after.guild.id != constants.Guild.id: + return + + if before.roles != after.roles: + updated_information = {"roles": sorted(role.id for role in after.roles)} + await self.patch_user(after.id, json=updated_information) + + @Cog.listener() + async def on_user_update(self, before: User, after: User) -> None: + """Update the user information in the database if a relevant change is detected.""" + attrs = ("name", "discriminator") + if any(getattr(before, attr) != getattr(after, attr) for attr in attrs): + updated_information = { + "name": after.name, + "discriminator": int(after.discriminator), + } + # A 404 likely means the user is in another guild. + await self.patch_user(after.id, json=updated_information, ignore_404=True) + + @commands.group(name='sync') + @commands.has_permissions(administrator=True) + async def sync_group(self, ctx: Context) -> None: + """Run synchronizations between the bot and site manually.""" + + @sync_group.command(name='roles') + @commands.has_permissions(administrator=True) + async def sync_roles_command(self, ctx: Context) -> None: + """Manually synchronise the guild's roles with the roles on the site.""" + await self.role_syncer.sync(ctx.guild, ctx) + + @sync_group.command(name='users') + @commands.has_permissions(administrator=True) + async def sync_users_command(self, ctx: Context) -> None: + """Manually synchronise the guild's users with the users on the site.""" + await self.user_syncer.sync(ctx.guild, ctx) diff --git a/bot/cogs/backend/sync/syncers.py b/bot/cogs/backend/sync/syncers.py new file mode 100644 index 000000000..f7ba811bc --- /dev/null +++ b/bot/cogs/backend/sync/syncers.py @@ -0,0 +1,347 @@ +import abc +import asyncio +import logging +import typing as t +from collections import namedtuple +from functools import partial + +import discord +from discord import Guild, HTTPException, Member, Message, Reaction, User +from discord.ext.commands import Context + +from bot import constants +from bot.api import ResponseCodeError +from bot.bot import Bot + +log = logging.getLogger(__name__) + +# These objects are declared as namedtuples because tuples are hashable, +# something that we make use of when diffing site roles against guild roles. +_Role = namedtuple('Role', ('id', 'name', 'colour', 'permissions', 'position')) +_User = namedtuple('User', ('id', 'name', 'discriminator', 'roles', 'in_guild')) +_Diff = namedtuple('Diff', ('created', 'updated', 'deleted')) + + +class Syncer(abc.ABC): + """Base class for synchronising the database with objects in the Discord cache.""" + + _CORE_DEV_MENTION = f"<@&{constants.Roles.core_developers}> " + _REACTION_EMOJIS = (constants.Emojis.check_mark, constants.Emojis.cross_mark) + + def __init__(self, bot: Bot) -> None: + self.bot = bot + + @property + @abc.abstractmethod + def name(self) -> str: + """The name of the syncer; used in output messages and logging.""" + raise NotImplementedError # pragma: no cover + + async def _send_prompt(self, message: t.Optional[Message] = None) -> t.Optional[Message]: + """ + Send a prompt to confirm or abort a sync using reactions and return the sent message. + + If a message is given, it is edited to display the prompt and reactions. Otherwise, a new + message is sent to the dev-core channel and mentions the core developers role. If the + channel cannot be retrieved, return None. + """ + log.trace(f"Sending {self.name} sync confirmation prompt.") + + msg_content = ( + f'Possible cache issue while syncing {self.name}s. ' + f'More than {constants.Sync.max_diff} {self.name}s were changed. ' + f'React to confirm or abort the sync.' + ) + + # Send to core developers if it's an automatic sync. + if not message: + log.trace("Message not provided for confirmation; creating a new one in dev-core.") + channel = self.bot.get_channel(constants.Channels.dev_core) + + if not channel: + log.debug("Failed to get the dev-core channel from cache; attempting to fetch it.") + try: + channel = await self.bot.fetch_channel(constants.Channels.dev_core) + except HTTPException: + log.exception( + f"Failed to fetch channel for sending sync confirmation prompt; " + f"aborting {self.name} sync." + ) + return None + + allowed_roles = [discord.Object(constants.Roles.core_developers)] + message = await channel.send( + f"{self._CORE_DEV_MENTION}{msg_content}", + allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles) + ) + else: + await message.edit(content=msg_content) + + # Add the initial reactions. + log.trace(f"Adding reactions to {self.name} syncer confirmation prompt.") + for emoji in self._REACTION_EMOJIS: + await message.add_reaction(emoji) + + return message + + def _reaction_check( + self, + author: Member, + message: Message, + reaction: Reaction, + user: t.Union[Member, User] + ) -> bool: + """ + Return True if the `reaction` is a valid confirmation or abort reaction on `message`. + + If the `author` of the prompt is a bot, then a reaction by any core developer will be + considered valid. Otherwise, the author of the reaction (`user`) will have to be the + `author` of the prompt. + """ + # For automatic syncs, check for the core dev role instead of an exact author + has_role = any(constants.Roles.core_developers == role.id for role in user.roles) + return ( + reaction.message.id == message.id + and not user.bot + and (has_role if author.bot else user == author) + and str(reaction.emoji) in self._REACTION_EMOJIS + ) + + async def _wait_for_confirmation(self, author: Member, message: Message) -> bool: + """ + Wait for a confirmation reaction by `author` on `message` and return True if confirmed. + + Uses the `_reaction_check` function to determine if a reaction is valid. + + If there is no reaction within `bot.constants.Sync.confirm_timeout` seconds, return False. + To acknowledge the reaction (or lack thereof), `message` will be edited. + """ + # Preserve the core-dev role mention in the message edits so users aren't confused about + # where notifications came from. + mention = self._CORE_DEV_MENTION if author.bot else "" + + reaction = None + try: + log.trace(f"Waiting for a reaction to the {self.name} syncer confirmation prompt.") + reaction, _ = await self.bot.wait_for( + 'reaction_add', + check=partial(self._reaction_check, author, message), + timeout=constants.Sync.confirm_timeout + ) + except asyncio.TimeoutError: + # reaction will remain none thus sync will be aborted in the finally block below. + log.debug(f"The {self.name} syncer confirmation prompt timed out.") + + if str(reaction) == constants.Emojis.check_mark: + log.trace(f"The {self.name} syncer was confirmed.") + await message.edit(content=f':ok_hand: {mention}{self.name} sync will proceed.') + return True + else: + log.info(f"The {self.name} syncer was aborted or timed out!") + await message.edit( + content=f':warning: {mention}{self.name} sync aborted or timed out!' + ) + return False + + @abc.abstractmethod + async def _get_diff(self, guild: Guild) -> _Diff: + """Return the difference between the cache of `guild` and the database.""" + raise NotImplementedError # pragma: no cover + + @abc.abstractmethod + async def _sync(self, diff: _Diff) -> None: + """Perform the API calls for synchronisation.""" + raise NotImplementedError # pragma: no cover + + async def _get_confirmation_result( + self, + diff_size: int, + author: Member, + message: t.Optional[Message] = None + ) -> t.Tuple[bool, t.Optional[Message]]: + """ + Prompt for confirmation and return a tuple of the result and the prompt message. + + `diff_size` is the size of the diff of the sync. If it is greater than + `bot.constants.Sync.max_diff`, the prompt will be sent. The `author` is the invoked of the + sync and the `message` is an extant message to edit to display the prompt. + + If confirmed or no confirmation was needed, the result is True. The returned message will + either be the given `message` or a new one which was created when sending the prompt. + """ + log.trace(f"Determining if confirmation prompt should be sent for {self.name} syncer.") + if diff_size > constants.Sync.max_diff: + message = await self._send_prompt(message) + if not message: + return False, None # Couldn't get channel. + + confirmed = await self._wait_for_confirmation(author, message) + if not confirmed: + return False, message # Sync aborted. + + return True, message + + async def sync(self, guild: Guild, ctx: t.Optional[Context] = None) -> None: + """ + Synchronise the database with the cache of `guild`. + + If the differences between the cache and the database are greater than + `bot.constants.Sync.max_diff`, then a confirmation prompt will be sent to the dev-core + channel. The confirmation can be optionally redirect to `ctx` instead. + """ + log.info(f"Starting {self.name} syncer.") + + message = None + author = self.bot.user + if ctx: + message = await ctx.send(f"📊 Synchronising {self.name}s.") + author = ctx.author + + diff = await self._get_diff(guild) + diff_dict = diff._asdict() # Ugly method for transforming the NamedTuple into a dict + totals = {k: len(v) for k, v in diff_dict.items() if v is not None} + diff_size = sum(totals.values()) + + confirmed, message = await self._get_confirmation_result(diff_size, author, message) + if not confirmed: + return + + # Preserve the core-dev role mention in the message edits so users aren't confused about + # where notifications came from. + mention = self._CORE_DEV_MENTION if author.bot else "" + + try: + await self._sync(diff) + except ResponseCodeError as e: + log.exception(f"{self.name} syncer failed!") + + # Don't show response text because it's probably some really long HTML. + results = f"status {e.status}\n```{e.response_json or 'See log output for details'}```" + content = f":x: {mention}Synchronisation of {self.name}s failed: {results}" + else: + results = ", ".join(f"{name} `{total}`" for name, total in totals.items()) + log.info(f"{self.name} syncer finished: {results}.") + content = f":ok_hand: {mention}Synchronisation of {self.name}s complete: {results}" + + if message: + await message.edit(content=content) + + +class RoleSyncer(Syncer): + """Synchronise the database with roles in the cache.""" + + name = "role" + + async def _get_diff(self, guild: Guild) -> _Diff: + """Return the difference of roles between the cache of `guild` and the database.""" + log.trace("Getting the diff for roles.") + roles = await self.bot.api_client.get('bot/roles') + + # Pack DB roles and guild roles into one common, hashable format. + # They're hashable so that they're easily comparable with sets later. + db_roles = {_Role(**role_dict) for role_dict in roles} + guild_roles = { + _Role( + id=role.id, + name=role.name, + colour=role.colour.value, + permissions=role.permissions.value, + position=role.position, + ) + for role in guild.roles + } + + guild_role_ids = {role.id for role in guild_roles} + api_role_ids = {role.id for role in db_roles} + new_role_ids = guild_role_ids - api_role_ids + deleted_role_ids = api_role_ids - guild_role_ids + + # New roles are those which are on the cached guild but not on the + # DB guild, going by the role ID. We need to send them in for creation. + roles_to_create = {role for role in guild_roles if role.id in new_role_ids} + roles_to_update = guild_roles - db_roles - roles_to_create + roles_to_delete = {role for role in db_roles if role.id in deleted_role_ids} + + return _Diff(roles_to_create, roles_to_update, roles_to_delete) + + async def _sync(self, diff: _Diff) -> None: + """Synchronise the database with the role cache of `guild`.""" + log.trace("Syncing created roles...") + for role in diff.created: + await self.bot.api_client.post('bot/roles', json=role._asdict()) + + log.trace("Syncing updated roles...") + for role in diff.updated: + await self.bot.api_client.put(f'bot/roles/{role.id}', json=role._asdict()) + + log.trace("Syncing deleted roles...") + for role in diff.deleted: + await self.bot.api_client.delete(f'bot/roles/{role.id}') + + +class UserSyncer(Syncer): + """Synchronise the database with users in the cache.""" + + name = "user" + + async def _get_diff(self, guild: Guild) -> _Diff: + """Return the difference of users between the cache of `guild` and the database.""" + log.trace("Getting the diff for users.") + users = await self.bot.api_client.get('bot/users') + + # Pack DB roles and guild roles into one common, hashable format. + # They're hashable so that they're easily comparable with sets later. + db_users = { + user_dict['id']: _User( + roles=tuple(sorted(user_dict.pop('roles'))), + **user_dict + ) + for user_dict in users + } + guild_users = { + member.id: _User( + id=member.id, + name=member.name, + discriminator=int(member.discriminator), + roles=tuple(sorted(role.id for role in member.roles)), + in_guild=True + ) + for member in guild.members + } + + users_to_create = set() + users_to_update = set() + + for db_user in db_users.values(): + guild_user = guild_users.get(db_user.id) + if guild_user is not None: + if db_user != guild_user: + users_to_update.add(guild_user) + + elif db_user.in_guild: + # The user is known in the DB but not the guild, and the + # DB currently specifies that the user is a member of the guild. + # This means that the user has left since the last sync. + # Update the `in_guild` attribute of the user on the site + # to signify that the user left. + new_api_user = db_user._replace(in_guild=False) + users_to_update.add(new_api_user) + + new_user_ids = set(guild_users.keys()) - set(db_users.keys()) + for user_id in new_user_ids: + # The user is known on the guild but not on the API. This means + # that the user has joined since the last sync. Create it. + new_user = guild_users[user_id] + users_to_create.add(new_user) + + return _Diff(users_to_create, users_to_update, None) + + async def _sync(self, diff: _Diff) -> None: + """Synchronise the database with the user cache of `guild`.""" + log.trace("Syncing created users...") + for user in diff.created: + await self.bot.api_client.post('bot/users', json=user._asdict()) + + log.trace("Syncing updated users...") + for user in diff.updated: + await self.bot.api_client.put(f'bot/users/{user.id}', json=user._asdict()) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py deleted file mode 100644 index 79510739c..000000000 --- a/bot/cogs/bot.py +++ /dev/null @@ -1,385 +0,0 @@ -import ast -import logging -import re -import time -from typing import Optional, Tuple - -from discord import Embed, Message, RawMessageUpdateEvent, TextChannel -from discord.ext.commands import Cog, Context, command, group - -from bot.bot import Bot -from bot.cogs.token_remover import TokenRemover -from bot.constants import Categories, Channels, DEBUG_MODE, Guild, MODERATION_ROLES, Roles, URLs -from bot.decorators import with_role -from bot.utils.messages import wait_for_deletion - -log = logging.getLogger(__name__) - -RE_MARKDOWN = re.compile(r'([*_~`|>])') - - -class BotCog(Cog, name="Bot"): - """Bot information commands.""" - - def __init__(self, bot: Bot): - self.bot = bot - - # Stores allowed channels plus epoch time since last call. - self.channel_cooldowns = { - Channels.python_discussion: 0, - } - - # These channels will also work, but will not be subject to cooldown - self.channel_whitelist = ( - Channels.bot_commands, - ) - - # Stores improperly formatted Python codeblock message ids and the corresponding bot message - self.codeblock_message_ids = {} - - @group(invoke_without_command=True, name="bot", hidden=True) - @with_role(Roles.verified) - async def botinfo_group(self, ctx: Context) -> None: - """Bot informational commands.""" - await ctx.send_help(ctx.command) - - @botinfo_group.command(name='about', aliases=('info',), hidden=True) - @with_role(Roles.verified) - async def about_command(self, ctx: Context) -> None: - """Get information about the bot.""" - embed = Embed( - description="A utility bot designed just for the Python server! Try `!help` for more info.", - url="https://github.com/python-discord/bot" - ) - - embed.add_field(name="Total Users", value=str(len(self.bot.get_guild(Guild.id).members))) - embed.set_author( - name="Python Bot", - url="https://github.com/python-discord/bot", - icon_url=URLs.bot_avatar - ) - - await ctx.send(embed=embed) - - @command(name='echo', aliases=('print',)) - @with_role(*MODERATION_ROLES) - async def echo_command(self, ctx: Context, channel: Optional[TextChannel], *, text: str) -> None: - """Repeat the given message in either a specified channel or the current channel.""" - if channel is None: - await ctx.send(text) - else: - await channel.send(text) - - @command(name='embed') - @with_role(*MODERATION_ROLES) - async def embed_command(self, ctx: Context, channel: Optional[TextChannel], *, text: str) -> None: - """Send the input within an embed to either a specified channel or the current channel.""" - embed = Embed(description=text) - - if channel is None: - await ctx.send(embed=embed) - else: - await channel.send(embed=embed) - - def codeblock_stripping(self, msg: str, bad_ticks: bool) -> Optional[Tuple[Tuple[str, ...], str]]: - """ - Strip msg in order to find Python code. - - Tries to strip out Python code out of msg and returns the stripped block or - None if the block is a valid Python codeblock. - """ - if msg.count("\n") >= 3: - # Filtering valid Python codeblocks and exiting if a valid Python codeblock is found. - if re.search("```(?:py|python)\n(.*?)```", msg, re.IGNORECASE | re.DOTALL) and not bad_ticks: - log.trace( - "Someone wrote a message that was already a " - "valid Python syntax highlighted code block. No action taken." - ) - return None - - else: - # Stripping backticks from every line of the message. - log.trace(f"Stripping backticks from message.\n\n{msg}\n\n") - content = "" - for line in msg.splitlines(keepends=True): - content += line.strip("`") - - content = content.strip() - - # Remove "Python" or "Py" from start of the message if it exists. - log.trace(f"Removing 'py' or 'python' from message.\n\n{content}\n\n") - pycode = False - if content.lower().startswith("python"): - content = content[6:] - pycode = True - elif content.lower().startswith("py"): - content = content[2:] - pycode = True - - if pycode: - content = content.splitlines(keepends=True) - - # Check if there might be code in the first line, and preserve it. - first_line = content[0] - if " " in content[0]: - first_space = first_line.index(" ") - content[0] = first_line[first_space:] - content = "".join(content) - - # If there's no code we can just get rid of the first line. - else: - content = "".join(content[1:]) - - # Strip it again to remove any leading whitespace. This is neccessary - # if the first line of the message looked like ```python - old = content.strip() - - # Strips REPL code out of the message if there is any. - content, repl_code = self.repl_stripping(old) - if old != content: - return (content, old), repl_code - - # Try to apply indentation fixes to the code. - content = self.fix_indentation(content) - - # Check if the code contains backticks, if it does ignore the message. - if "`" in content: - log.trace("Detected ` inside the code, won't reply") - return None - else: - log.trace(f"Returning message.\n\n{content}\n\n") - return (content,), repl_code - - def fix_indentation(self, msg: str) -> str: - """Attempts to fix badly indented code.""" - def unindent(code: str, skip_spaces: int = 0) -> str: - """Unindents all code down to the number of spaces given in skip_spaces.""" - final = "" - current = code[0] - leading_spaces = 0 - - # Get numbers of spaces before code in the first line. - while current == " ": - current = code[leading_spaces + 1] - leading_spaces += 1 - leading_spaces -= skip_spaces - - # If there are any, remove that number of spaces from every line. - if leading_spaces > 0: - for line in code.splitlines(keepends=True): - line = line[leading_spaces:] - final += line - return final - else: - return code - - # Apply fix for "all lines are overindented" case. - msg = unindent(msg) - - # If the first line does not end with a colon, we can be - # certain the next line will be on the same indentation level. - # - # If it does end with a colon, we will need to indent all successive - # lines one additional level. - first_line = msg.splitlines()[0] - code = "".join(msg.splitlines(keepends=True)[1:]) - if not first_line.endswith(":"): - msg = f"{first_line}\n{unindent(code)}" - else: - msg = f"{first_line}\n{unindent(code, 4)}" - return msg - - def repl_stripping(self, msg: str) -> Tuple[str, bool]: - """ - Strip msg in order to extract Python code out of REPL output. - - Tries to strip out REPL Python code out of msg and returns the stripped msg. - - Returns True for the boolean if REPL code was found in the input msg. - """ - final = "" - for line in msg.splitlines(keepends=True): - if line.startswith(">>>") or line.startswith("..."): - final += line[4:] - log.trace(f"Formatted: \n\n{msg}\n\n to \n\n{final}\n\n") - if not final: - log.trace(f"Found no REPL code in \n\n{msg}\n\n") - return msg, False - else: - log.trace(f"Found REPL code in \n\n{msg}\n\n") - return final.rstrip(), True - - def has_bad_ticks(self, msg: Message) -> bool: - """Check to see if msg contains ticks that aren't '`'.""" - not_backticks = [ - "'''", '"""', "\u00b4\u00b4\u00b4", "\u2018\u2018\u2018", "\u2019\u2019\u2019", - "\u2032\u2032\u2032", "\u201c\u201c\u201c", "\u201d\u201d\u201d", "\u2033\u2033\u2033", - "\u3003\u3003\u3003" - ] - - return msg.content[:3] in not_backticks - - @Cog.listener() - async def on_message(self, msg: Message) -> None: - """ - Detect poorly formatted Python code in new messages. - - If poorly formatted code is detected, send the user a helpful message explaining how to do - properly formatted Python syntax highlighting codeblocks. - """ - is_help_channel = ( - getattr(msg.channel, "category", None) - and msg.channel.category.id in (Categories.help_available, Categories.help_in_use) - ) - parse_codeblock = ( - ( - is_help_channel - or msg.channel.id in self.channel_cooldowns - or msg.channel.id in self.channel_whitelist - ) - and not msg.author.bot - and len(msg.content.splitlines()) > 3 - and not TokenRemover.find_token_in_message(msg) - ) - - if parse_codeblock: # no token in the msg - on_cooldown = (time.time() - self.channel_cooldowns.get(msg.channel.id, 0)) < 300 - if not on_cooldown or DEBUG_MODE: - try: - if self.has_bad_ticks(msg): - ticks = msg.content[:3] - content = self.codeblock_stripping(f"```{msg.content[3:-3]}```", True) - if content is None: - return - - content, repl_code = content - - if len(content) == 2: - content = content[1] - else: - content = content[0] - - space_left = 204 - if len(content) >= space_left: - current_length = 0 - lines_walked = 0 - for line in content.splitlines(keepends=True): - if current_length + len(line) > space_left or lines_walked == 10: - break - current_length += len(line) - lines_walked += 1 - content = content[:current_length] + "#..." - content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) - howto = ( - "It looks like you are trying to paste code into this channel.\n\n" - "You seem to be using the wrong symbols to indicate where the codeblock should start. " - f"The correct symbols would be \\`\\`\\`, not `{ticks}`.\n\n" - "**Here is an example of how it should look:**\n" - f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" - "**This will result in the following:**\n" - f"```python\n{content}\n```" - ) - - else: - howto = "" - content = self.codeblock_stripping(msg.content, False) - if content is None: - return - - content, repl_code = content - # Attempts to parse the message into an AST node. - # Invalid Python code will raise a SyntaxError. - tree = ast.parse(content[0]) - - # Multiple lines of single words could be interpreted as expressions. - # This check is to avoid all nodes being parsed as expressions. - # (e.g. words over multiple lines) - if not all(isinstance(node, ast.Expr) for node in tree.body) or repl_code: - # Shorten the code to 10 lines and/or 204 characters. - space_left = 204 - if content and repl_code: - content = content[1] - else: - content = content[0] - - if len(content) >= space_left: - current_length = 0 - lines_walked = 0 - for line in content.splitlines(keepends=True): - if current_length + len(line) > space_left or lines_walked == 10: - break - current_length += len(line) - lines_walked += 1 - content = content[:current_length] + "#..." - - content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) - howto += ( - "It looks like you're trying to paste code into this channel.\n\n" - "Discord has support for Markdown, which allows you to post code with full " - "syntax highlighting. Please use these whenever you paste code, as this " - "helps improve the legibility and makes it easier for us to help you.\n\n" - f"**To do this, use the following method:**\n" - f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" - "**This will result in the following:**\n" - f"```python\n{content}\n```" - ) - - log.debug(f"{msg.author} posted something that needed to be put inside python code " - "blocks. Sending the user some instructions.") - else: - log.trace("The code consists only of expressions, not sending instructions") - - if howto != "": - # Increase amount of codeblock correction in stats - self.bot.stats.incr("codeblock_corrections") - howto_embed = Embed(description=howto) - bot_message = await msg.channel.send(f"Hey {msg.author.mention}!", embed=howto_embed) - self.codeblock_message_ids[msg.id] = bot_message.id - - self.bot.loop.create_task( - wait_for_deletion(bot_message, user_ids=(msg.author.id,), client=self.bot) - ) - else: - return - - if msg.channel.id not in self.channel_whitelist: - self.channel_cooldowns[msg.channel.id] = time.time() - - except SyntaxError: - log.trace( - f"{msg.author} posted in a help channel, and when we tried to parse it as Python code, " - "ast.parse raised a SyntaxError. This probably just means it wasn't Python code. " - f"The message that was posted was:\n\n{msg.content}\n\n" - ) - - @Cog.listener() - async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None: - """Check to see if an edited message (previously called out) still contains poorly formatted code.""" - if ( - # Checks to see if the message was called out by the bot - payload.message_id not in self.codeblock_message_ids - # Makes sure that there is content in the message - or payload.data.get("content") is None - # Makes sure there's a channel id in the message payload - or payload.data.get("channel_id") is None - ): - return - - # Retrieve channel and message objects for use later - channel = self.bot.get_channel(int(payload.data.get("channel_id"))) - user_message = await channel.fetch_message(payload.message_id) - - # Checks to see if the user has corrected their codeblock. If it's fixed, has_fixed_codeblock will be None - has_fixed_codeblock = self.codeblock_stripping(payload.data.get("content"), self.has_bad_ticks(user_message)) - - # If the message is fixed, delete the bot message and the entry from the id dictionary - if has_fixed_codeblock is None: - bot_message = await channel.fetch_message(self.codeblock_message_ids[payload.message_id]) - await bot_message.delete() - del self.codeblock_message_ids[payload.message_id] - log.trace("User's incorrect code block has been fixed. Removing bot formatting message.") - - -def setup(bot: Bot) -> None: - """Load the Bot cog.""" - bot.add_cog(BotCog(bot)) diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py deleted file mode 100644 index f436e531a..000000000 --- a/bot/cogs/clean.py +++ /dev/null @@ -1,272 +0,0 @@ -import logging -import random -import re -from typing import Iterable, Optional - -from discord import Colour, Embed, Message, TextChannel, User -from discord.ext import commands -from discord.ext.commands import Cog, Context, group - -from bot.bot import Bot -from bot.cogs.moderation import ModLog -from bot.constants import ( - Channels, CleanMessages, Colours, Event, Icons, MODERATION_ROLES, NEGATIVE_REPLIES -) -from bot.decorators import with_role - -log = logging.getLogger(__name__) - - -class Clean(Cog): - """ - A cog that allows messages to be deleted in bulk, while applying various filters. - - You can delete messages sent by a specific user, messages sent by bots, all messages, or messages that match a - specific regular expression. - - The deleted messages are saved and uploaded to the database via an API endpoint, and a URL is returned which can be - used to view the messages in the Discord dark theme style. - """ - - def __init__(self, bot: Bot): - self.bot = bot - self.cleaning = False - - @property - def mod_log(self) -> ModLog: - """Get currently loaded ModLog cog instance.""" - return self.bot.get_cog("ModLog") - - async def _clean_messages( - self, - amount: int, - ctx: Context, - channels: Iterable[TextChannel], - bots_only: bool = False, - user: User = None, - regex: Optional[str] = None, - until_message: Optional[Message] = None, - ) -> None: - """A helper function that does the actual message cleaning.""" - def predicate_bots_only(message: Message) -> bool: - """Return 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) -> bool: - """Check if the regex provided in _clean_messages matches the message content or any embed attributes.""" - 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="Please wait for the currently ongoing clean operation to complete." - ) - 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 - - # Default to using the invoking context's channel - if not channels: - channels = [ctx.channel] - - # Delete the invocation first - self.mod_log.ignore(Event.message_delete, ctx.message.id) - await ctx.message.delete() - - messages = [] - message_ids = [] - self.cleaning = True - - # Find the IDs of the messages to delete. IDs are needed in order to ignore mod log events. - for channel in channels: - async for message in channel.history(limit=amount): - - # If at any point the cancel command is invoked, we should stop. - if not self.cleaning: - return - - # If we are looking for specific message. - if until_message: - - # we could use ID's here however in case if the message we are looking for gets deleted, - # we won't have a way to figure that out thus checking for datetime should be more reliable - if message.created_at < until_message.created_at: - # means we have found the message until which we were supposed to be deleting. - break - - # Since we will be using `delete_messages` method of a TextChannel and we need message objects to - # use it as well as to send logs we will start appending messages here instead adding them from - # purge. - messages.append(message) - - # If the message passes predicate, let's save it. - if predicate is None or predicate(message): - message_ids.append(message.id) - - self.cleaning = False - - # Now let's delete the actual messages with purge. - self.mod_log.ignore(Event.message_delete, *message_ids) - for channel in channels: - if until_message: - for i in range(0, len(messages), 100): - # while purge automatically handles the amount of messages - # delete_messages only allows for up to 100 messages at once - # thus we need to paginate the amount to always be <= 100 - await channel.delete_messages(messages[i:i + 100]) - else: - messages += await channel.purge(limit=amount, check=predicate) - - # Reverse the list to restore chronological order - if messages: - messages = reversed(messages) - log_url = await self.mod_log.upload_log(messages, ctx.author.id) - 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) - return - - # Build the embed and send it - target_channels = ", ".join(channel.mention for channel in channels) - - message = ( - f"**{len(message_ids)}** messages deleted in {target_channels} by **{ctx.author.name}**\n\n" - f"A log of the deleted messages can be found [here]({log_url})." - ) - - await self.mod_log.send_log_message( - icon_url=Icons.message_bulk_delete, - colour=Colour(Colours.soft_red), - title="Bulk message delete", - text=message, - channel_id=Channels.mod_log, - ) - - @group(invoke_without_command=True, name="clean", aliases=["purge"]) - @with_role(*MODERATION_ROLES) - async def clean_group(self, ctx: Context) -> None: - """Commands for cleaning messages in channels.""" - await ctx.send_help(ctx.command) - - @clean_group.command(name="user", aliases=["users"]) - @with_role(*MODERATION_ROLES) - async def clean_user( - self, - ctx: Context, - user: User, - amount: Optional[int] = 10, - channels: commands.Greedy[TextChannel] = None - ) -> None: - """Delete messages posted by the provided user, stop cleaning after traversing `amount` messages.""" - await self._clean_messages(amount, ctx, user=user, channels=channels) - - @clean_group.command(name="all", aliases=["everything"]) - @with_role(*MODERATION_ROLES) - async def clean_all( - self, - ctx: Context, - amount: Optional[int] = 10, - channels: commands.Greedy[TextChannel] = None - ) -> None: - """Delete all messages, regardless of poster, stop cleaning after traversing `amount` messages.""" - await self._clean_messages(amount, ctx, channels=channels) - - @clean_group.command(name="bots", aliases=["bot"]) - @with_role(*MODERATION_ROLES) - async def clean_bots( - self, - ctx: Context, - amount: Optional[int] = 10, - channels: commands.Greedy[TextChannel] = None - ) -> None: - """Delete all messages posted by a bot, stop cleaning after traversing `amount` messages.""" - await self._clean_messages(amount, ctx, bots_only=True, channels=channels) - - @clean_group.command(name="regex", aliases=["word", "expression"]) - @with_role(*MODERATION_ROLES) - async def clean_regex( - self, - ctx: Context, - regex: str, - amount: Optional[int] = 10, - channels: commands.Greedy[TextChannel] = None - ) -> None: - """Delete all messages that match a certain regex, stop cleaning after traversing `amount` messages.""" - await self._clean_messages(amount, ctx, regex=regex, channels=channels) - - @clean_group.command(name="message", aliases=["messages"]) - @with_role(*MODERATION_ROLES) - async def clean_message(self, ctx: Context, message: Message) -> None: - """Delete all messages until certain message, stop cleaning after hitting the `message`.""" - await self._clean_messages( - CleanMessages.message_limit, - ctx, - channels=[message.channel], - until_message=message - ) - - @clean_group.command(name="stop", aliases=["cancel", "abort"]) - @with_role(*MODERATION_ROLES) - async def clean_cancel(self, ctx: Context) -> None: - """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, delete_after=10) - - -def setup(bot: Bot) -> None: - """Load the Clean cog.""" - bot.add_cog(Clean(bot)) diff --git a/bot/cogs/config_verifier.py b/bot/cogs/config_verifier.py deleted file mode 100644 index d72c6c22e..000000000 --- a/bot/cogs/config_verifier.py +++ /dev/null @@ -1,40 +0,0 @@ -import logging - -from discord.ext.commands import Cog - -from bot import constants -from bot.bot import Bot - - -log = logging.getLogger(__name__) - - -class ConfigVerifier(Cog): - """Verify config on startup.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.channel_verify_task = self.bot.loop.create_task(self.verify_channels()) - - async def verify_channels(self) -> None: - """ - Verify channels. - - If any channels in config aren't present in server, log them in a warning. - """ - await self.bot.wait_until_guild_available() - server = self.bot.get_guild(constants.Guild.id) - - server_channel_ids = {channel.id for channel in server.channels} - invalid_channels = [ - channel_name for channel_name, channel_id in constants.Channels - if channel_id not in server_channel_ids - ] - - if invalid_channels: - log.warning(f"Configured channels do not exist in server: {', '.join(invalid_channels)}.") - - -def setup(bot: Bot) -> None: - """Load the ConfigVerifier cog.""" - bot.add_cog(ConfigVerifier(bot)) diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py deleted file mode 100644 index 4c0ad5914..000000000 --- a/bot/cogs/defcon.py +++ /dev/null @@ -1,258 +0,0 @@ -from __future__ import annotations - -import logging -from collections import namedtuple -from datetime import datetime, timedelta -from enum import Enum - -from discord import Colour, Embed, Member -from discord.ext.commands import Cog, Context, group - -from bot.bot import Bot -from bot.cogs.moderation import ModLog -from bot.constants import Channels, Colours, Emojis, Event, Icons, Roles -from bot.decorators import with_role - -log = logging.getLogger(__name__) - -REJECTION_MESSAGE = """ -Hi, {user} - Thanks for your interest in our server! - -Due to a current (or detected) cyberattack on our community, we've limited access to the server for new accounts. Since -your account is relatively new, we're unable to provide access to the server at this time. - -Even so, thanks for joining! We're very excited at the possibility of having you here, and we hope that this situation -will be resolved soon. In the meantime, please feel free to peruse the resources on our site at -, and have a nice day! -""" - -BASE_CHANNEL_TOPIC = "Python Discord Defense Mechanism" - - -class Action(Enum): - """Defcon Action.""" - - ActionInfo = namedtuple('LogInfoDetails', ['icon', 'color', 'template']) - - ENABLED = ActionInfo(Icons.defcon_enabled, Colours.soft_green, "**Days:** {days}\n\n") - DISABLED = ActionInfo(Icons.defcon_disabled, Colours.soft_red, "") - UPDATED = ActionInfo(Icons.defcon_updated, Colour.blurple(), "**Days:** {days}\n\n") - - -class Defcon(Cog): - """Time-sensitive server defense mechanisms.""" - - days = None # type: timedelta - enabled = False # type: bool - - def __init__(self, bot: Bot): - self.bot = bot - self.channel = None - self.days = timedelta(days=0) - - self.bot.loop.create_task(self.sync_settings()) - - @property - def mod_log(self) -> ModLog: - """Get currently loaded ModLog cog instance.""" - return self.bot.get_cog("ModLog") - - async def sync_settings(self) -> None: - """On cog load, try to synchronize DEFCON settings to the API.""" - await self.bot.wait_until_guild_available() - self.channel = await self.bot.fetch_channel(Channels.defcon) - - try: - response = await self.bot.api_client.get('bot/bot-settings/defcon') - data = response['data'] - - except Exception: # Yikes! - log.exception("Unable to get DEFCON settings!") - await self.bot.get_channel(Channels.dev_log).send( - f"<@&{Roles.admins}> **WARNING**: Unable to get DEFCON settings!" - ) - - else: - if data["enabled"]: - self.enabled = True - self.days = timedelta(days=data["days"]) - log.info(f"DEFCON enabled: {self.days.days} days") - - else: - self.enabled = False - self.days = timedelta(days=0) - log.info("DEFCON disabled") - - await self.update_channel_topic() - - @Cog.listener() - async def on_member_join(self, member: Member) -> None: - """If DEFCON is enabled, check newly joining users to see if they meet the account age threshold.""" - if self.enabled and self.days.days > 0: - now = datetime.utcnow() - - if now - member.created_at < self.days: - log.info(f"Rejecting user {member}: Account is too new and DEFCON is enabled") - - message_sent = False - - try: - await member.send(REJECTION_MESSAGE.format(user=member.mention)) - - message_sent = True - except Exception: - log.exception(f"Unable to send rejection message to user: {member}") - - await member.kick(reason="DEFCON active, user is too new") - self.bot.stats.incr("defcon.leaves") - - message = ( - f"{member} (`{member.id}`) was denied entry because their account is too new." - ) - - if not message_sent: - message = f"{message}\n\nUnable to send rejection message via DM; they probably have DMs disabled." - - await self.mod_log.send_log_message( - Icons.defcon_denied, Colours.soft_red, "Entry denied", - message, member.avatar_url_as(static_format="png") - ) - - @group(name='defcon', aliases=('dc',), invoke_without_command=True) - @with_role(Roles.admins, Roles.owners) - async def defcon_group(self, ctx: Context) -> None: - """Check the DEFCON status or run a subcommand.""" - await ctx.send_help(ctx.command) - - async def _defcon_action(self, ctx: Context, days: int, action: Action) -> None: - """Providing a structured way to do an defcon action.""" - try: - response = await self.bot.api_client.get('bot/bot-settings/defcon') - data = response['data'] - - if "enable_date" in data and action is Action.DISABLED: - enabled = datetime.fromisoformat(data["enable_date"]) - - delta = datetime.now() - enabled - - self.bot.stats.timing("defcon.enabled", delta) - except Exception: - pass - - error = None - try: - await self.bot.api_client.put( - 'bot/bot-settings/defcon', - json={ - 'name': 'defcon', - 'data': { - # TODO: retrieve old days count - 'days': days, - 'enabled': action is not Action.DISABLED, - 'enable_date': datetime.now().isoformat() - } - } - ) - except Exception as err: - log.exception("Unable to update DEFCON settings.") - error = err - finally: - await ctx.send(self.build_defcon_msg(action, error)) - await self.send_defcon_log(action, ctx.author, error) - - self.bot.stats.gauge("defcon.threshold", days) - - @defcon_group.command(name='enable', aliases=('on', 'e')) - @with_role(Roles.admins, Roles.owners) - async def enable_command(self, ctx: Context) -> None: - """ - Enable DEFCON mode. Useful in a pinch, but be sure you know what you're doing! - - Currently, this just adds an account age requirement. Use !defcon days to set how old an account must be, - in days. - """ - self.enabled = True - await self._defcon_action(ctx, days=0, action=Action.ENABLED) - await self.update_channel_topic() - - @defcon_group.command(name='disable', aliases=('off', 'd')) - @with_role(Roles.admins, Roles.owners) - async def disable_command(self, ctx: Context) -> None: - """Disable DEFCON mode. Useful in a pinch, but be sure you know what you're doing!""" - self.enabled = False - await self._defcon_action(ctx, days=0, action=Action.DISABLED) - await self.update_channel_topic() - - @defcon_group.command(name='status', aliases=('s',)) - @with_role(Roles.admins, Roles.owners) - async def status_command(self, ctx: Context) -> None: - """Check the current status of DEFCON mode.""" - embed = Embed( - colour=Colour.blurple(), title="DEFCON Status", - description=f"**Enabled:** {self.enabled}\n" - f"**Days:** {self.days.days}" - ) - - await ctx.send(embed=embed) - - @defcon_group.command(name='days') - @with_role(Roles.admins, Roles.owners) - async def days_command(self, ctx: Context, days: int) -> None: - """Set how old an account must be to join the server, in days, with DEFCON mode enabled.""" - self.days = timedelta(days=days) - self.enabled = True - await self._defcon_action(ctx, days=days, action=Action.UPDATED) - await self.update_channel_topic() - - async def update_channel_topic(self) -> None: - """Update the #defcon channel topic with the current DEFCON status.""" - if self.enabled: - day_str = "days" if self.days.days > 1 else "day" - new_topic = f"{BASE_CHANNEL_TOPIC}\n(Status: Enabled, Threshold: {self.days.days} {day_str})" - else: - new_topic = f"{BASE_CHANNEL_TOPIC}\n(Status: Disabled)" - - self.mod_log.ignore(Event.guild_channel_update, Channels.defcon) - await self.channel.edit(topic=new_topic) - - def build_defcon_msg(self, action: Action, e: Exception = None) -> str: - """Build in-channel response string for DEFCON action.""" - if action is Action.ENABLED: - msg = f"{Emojis.defcon_enabled} DEFCON enabled.\n\n" - elif action is Action.DISABLED: - msg = f"{Emojis.defcon_disabled} DEFCON disabled.\n\n" - elif action is Action.UPDATED: - msg = ( - f"{Emojis.defcon_updated} DEFCON days updated; accounts must be {self.days.days} " - f"day{'s' if self.days.days > 1 else ''} old to join the server.\n\n" - ) - - if e: - msg += ( - "**There was a problem updating the site** - This setting may be reverted when the bot restarts.\n\n" - f"```py\n{e}\n```" - ) - - return msg - - async def send_defcon_log(self, action: Action, actor: Member, e: Exception = None) -> None: - """Send log message for DEFCON action.""" - info = action.value - log_msg: str = ( - f"**Staffer:** {actor.mention} {actor} (`{actor.id}`)\n" - f"{info.template.format(days=self.days.days)}" - ) - status_msg = f"DEFCON {action.name.lower()}" - - if e: - log_msg += ( - "**There was a problem updating the site** - This setting may be reverted when the bot restarts.\n\n" - f"```py\n{e}\n```" - ) - - await self.mod_log.send_log_message(info.icon, info.color, status_msg, log_msg) - - -def setup(bot: Bot) -> None: - """Load the Defcon cog.""" - bot.add_cog(Defcon(bot)) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py deleted file mode 100644 index 204cffb37..000000000 --- a/bot/cogs/doc.py +++ /dev/null @@ -1,511 +0,0 @@ -import asyncio -import functools -import logging -import re -import textwrap -from collections import OrderedDict -from contextlib import suppress -from types import SimpleNamespace -from typing import Any, Callable, Optional, Tuple - -import discord -from bs4 import BeautifulSoup -from bs4.element import PageElement, Tag -from discord.errors import NotFound -from discord.ext import commands -from markdownify import MarkdownConverter -from requests import ConnectTimeout, ConnectionError, HTTPError -from sphinx.ext import intersphinx -from urllib3.exceptions import ProtocolError - -from bot.bot import Bot -from bot.constants import MODERATION_ROLES, RedirectOutput -from bot.converters import ValidPythonIdentifier, ValidURL -from bot.decorators import with_role -from bot.pagination import LinePaginator - - -log = logging.getLogger(__name__) -logging.getLogger('urllib3').setLevel(logging.WARNING) - -# Since Intersphinx is intended to be used with Sphinx, -# we need to mock its configuration. -SPHINX_MOCK_APP = SimpleNamespace( - config=SimpleNamespace( - intersphinx_timeout=3, - tls_verify=True, - user_agent="python3:python-discord/bot:1.0.0" - ) -) - -NO_OVERRIDE_GROUPS = ( - "2to3fixer", - "token", - "label", - "pdbcommand", - "term", -) -NO_OVERRIDE_PACKAGES = ( - "python", -) - -SEARCH_END_TAG_ATTRS = ( - "data", - "function", - "class", - "exception", - "seealso", - "section", - "rubric", - "sphinxsidebar", -) -UNWANTED_SIGNATURE_SYMBOLS_RE = re.compile(r"\[source]|\\\\|¶") -WHITESPACE_AFTER_NEWLINES_RE = re.compile(r"(?<=\n\n)(\s+)") - -FAILED_REQUEST_RETRY_AMOUNT = 3 -NOT_FOUND_DELETE_DELAY = RedirectOutput.delete_delay - - -def async_cache(max_size: int = 128, arg_offset: int = 0) -> Callable: - """ - LRU cache implementation for coroutines. - - Once the cache exceeds the maximum size, keys are deleted in FIFO order. - - An offset may be optionally provided to be applied to the coroutine's arguments when creating the cache key. - """ - # Assign the cache to the function itself so we can clear it from outside. - async_cache.cache = OrderedDict() - - def decorator(function: Callable) -> Callable: - """Define the async_cache decorator.""" - @functools.wraps(function) - async def wrapper(*args) -> Any: - """Decorator wrapper for the caching logic.""" - key = ':'.join(args[arg_offset:]) - - value = async_cache.cache.get(key) - if value is None: - if len(async_cache.cache) > max_size: - async_cache.cache.popitem(last=False) - - async_cache.cache[key] = await function(*args) - return async_cache.cache[key] - return wrapper - return decorator - - -class DocMarkdownConverter(MarkdownConverter): - """Subclass markdownify's MarkdownCoverter to provide custom conversion methods.""" - - def convert_code(self, el: PageElement, text: str) -> str: - """Undo `markdownify`s underscore escaping.""" - return f"`{text}`".replace('\\', '') - - def convert_pre(self, el: PageElement, text: str) -> str: - """Wrap any codeblocks in `py` for syntax highlighting.""" - code = ''.join(el.strings) - return f"```py\n{code}```" - - -def markdownify(html: str) -> DocMarkdownConverter: - """Create a DocMarkdownConverter object from the input html.""" - return DocMarkdownConverter(bullets='•').convert(html) - - -class InventoryURL(commands.Converter): - """ - Represents an Intersphinx inventory URL. - - This converter checks whether intersphinx accepts the given inventory URL, and raises - `BadArgument` if that is not the case. - - Otherwise, it simply passes through the given URL. - """ - - @staticmethod - async def convert(ctx: commands.Context, url: str) -> str: - """Convert url to Intersphinx inventory URL.""" - try: - intersphinx.fetch_inventory(SPHINX_MOCK_APP, '', url) - except AttributeError: - raise commands.BadArgument(f"Failed to fetch Intersphinx inventory from URL `{url}`.") - except ConnectionError: - if url.startswith('https'): - raise commands.BadArgument( - f"Cannot establish a connection to `{url}`. Does it support HTTPS?" - ) - raise commands.BadArgument(f"Cannot connect to host with URL `{url}`.") - except ValueError: - raise commands.BadArgument( - f"Failed to read Intersphinx inventory from URL `{url}`. " - "Are you sure that it's a valid inventory file?" - ) - return url - - -class Doc(commands.Cog): - """A set of commands for querying & displaying documentation.""" - - def __init__(self, bot: Bot): - self.base_urls = {} - self.bot = bot - self.inventories = {} - self.renamed_symbols = set() - - self.bot.loop.create_task(self.init_refresh_inventory()) - - async def init_refresh_inventory(self) -> None: - """Refresh documentation inventory on cog initialization.""" - await self.bot.wait_until_guild_available() - await self.refresh_inventory() - - async def update_single( - self, package_name: str, base_url: str, inventory_url: str - ) -> None: - """ - Rebuild the inventory for a single package. - - Where: - * `package_name` is the package name to use, appears in the log - * `base_url` is the root documentation URL for the specified package, used to build - absolute paths that link to specific symbols - * `inventory_url` is the absolute URL to the intersphinx inventory, fetched by running - `intersphinx.fetch_inventory` in an executor on the bot's event loop - """ - self.base_urls[package_name] = base_url - - package = await self._fetch_inventory(inventory_url) - if not package: - return None - - for group, value in package.items(): - for symbol, (package_name, _version, relative_doc_url, _) in value.items(): - absolute_doc_url = base_url + relative_doc_url - - if symbol in self.inventories: - group_name = group.split(":")[1] - symbol_base_url = self.inventories[symbol].split("/", 3)[2] - if ( - group_name in NO_OVERRIDE_GROUPS - or any(package in symbol_base_url for package in NO_OVERRIDE_PACKAGES) - ): - - symbol = f"{group_name}.{symbol}" - # If renamed `symbol` already exists, add library name in front to differentiate between them. - if symbol in self.renamed_symbols: - # Split `package_name` because of packages like Pillow that have spaces in them. - symbol = f"{package_name.split()[0]}.{symbol}" - - self.inventories[symbol] = absolute_doc_url - self.renamed_symbols.add(symbol) - continue - - self.inventories[symbol] = absolute_doc_url - - log.trace(f"Fetched inventory for {package_name}.") - - async def refresh_inventory(self) -> None: - """Refresh internal documentation inventory.""" - log.debug("Refreshing documentation inventory...") - - # Clear the old base URLS and inventories to ensure - # that we start from a fresh local dataset. - # Also, reset the cache used for fetching documentation. - self.base_urls.clear() - self.inventories.clear() - self.renamed_symbols.clear() - async_cache.cache = OrderedDict() - - # Run all coroutines concurrently - since each of them performs a HTTP - # request, this speeds up fetching the inventory data heavily. - coros = [ - self.update_single( - package["package"], package["base_url"], package["inventory_url"] - ) for package in await self.bot.api_client.get('bot/documentation-links') - ] - await asyncio.gather(*coros) - - async def get_symbol_html(self, symbol: str) -> Optional[Tuple[list, str]]: - """ - Given a Python symbol, return its signature and description. - - The first tuple element is the signature of the given symbol as a markup-free string, and - the second tuple element is the description of the given symbol with HTML markup included. - - If the given symbol is a module, returns a tuple `(None, str)` - else if the symbol could not be found, returns `None`. - """ - url = self.inventories.get(symbol) - if url is None: - return None - - async with self.bot.http_session.get(url) as response: - html = await response.text(encoding='utf-8') - - # Find the signature header and parse the relevant parts. - symbol_id = url.split('#')[-1] - soup = BeautifulSoup(html, 'lxml') - symbol_heading = soup.find(id=symbol_id) - search_html = str(soup) - - if symbol_heading is None: - return None - - if symbol_id == f"module-{symbol}": - # Get page content from the module headerlink to the - # first tag that has its class in `SEARCH_END_TAG_ATTRS` - start_tag = symbol_heading.find("a", attrs={"class": "headerlink"}) - if start_tag is None: - return [], "" - - end_tag = start_tag.find_next(self._match_end_tag) - if end_tag is None: - return [], "" - - description_start_index = search_html.find(str(start_tag.parent)) + len(str(start_tag.parent)) - description_end_index = search_html.find(str(end_tag)) - description = search_html[description_start_index:description_end_index] - signatures = None - - else: - signatures = [] - description = str(symbol_heading.find_next_sibling("dd")) - description_pos = search_html.find(description) - # Get text of up to 3 signatures, remove unwanted symbols - for element in [symbol_heading] + symbol_heading.find_next_siblings("dt", limit=2): - signature = UNWANTED_SIGNATURE_SYMBOLS_RE.sub("", element.text) - if signature and search_html.find(str(element)) < description_pos: - signatures.append(signature) - - return signatures, description.replace('¶', '') - - @async_cache(arg_offset=1) - async def get_symbol_embed(self, symbol: str) -> Optional[discord.Embed]: - """ - Attempt to scrape and fetch the data for the given `symbol`, and build an embed from its contents. - - If the symbol is known, an Embed with documentation about it is returned. - """ - scraped_html = await self.get_symbol_html(symbol) - if scraped_html is None: - return None - - signatures = scraped_html[0] - permalink = self.inventories[symbol] - description = markdownify(scraped_html[1]) - - # Truncate the description of the embed to the last occurrence - # of a double newline (interpreted as a paragraph) before index 1000. - if len(description) > 1000: - shortened = description[:1000] - description_cutoff = shortened.rfind('\n\n', 100) - if description_cutoff == -1: - # Search the shortened version for cutoff points in decreasing desirability, - # cutoff at 1000 if none are found. - for string in (". ", ", ", ",", " "): - description_cutoff = shortened.rfind(string) - if description_cutoff != -1: - break - else: - description_cutoff = 1000 - description = description[:description_cutoff] - - # If there is an incomplete code block, cut it out - if description.count("```") % 2: - codeblock_start = description.rfind('```py') - description = description[:codeblock_start].rstrip() - description += f"... [read more]({permalink})" - - description = WHITESPACE_AFTER_NEWLINES_RE.sub('', description) - if signatures is None: - # If symbol is a module, don't show signature. - embed_description = description - - elif not signatures: - # It's some "meta-page", for example: - # https://docs.djangoproject.com/en/dev/ref/views/#module-django.views - embed_description = "This appears to be a generic page not tied to a specific symbol." - - else: - embed_description = "".join(f"```py\n{textwrap.shorten(signature, 500)}```" for signature in signatures) - embed_description += f"\n{description}" - - embed = discord.Embed( - title=f'`{symbol}`', - url=permalink, - description=embed_description - ) - # Show all symbols with the same name that were renamed in the footer. - embed.set_footer( - text=", ".join(renamed for renamed in self.renamed_symbols - {symbol} if renamed.endswith(f".{symbol}")) - ) - return embed - - @commands.group(name='docs', aliases=('doc', 'd'), invoke_without_command=True) - async def docs_group(self, ctx: commands.Context, symbol: commands.clean_content = None) -> None: - """Lookup documentation for Python symbols.""" - await ctx.invoke(self.get_command, symbol) - - @docs_group.command(name='get', aliases=('g',)) - async def get_command(self, ctx: commands.Context, symbol: commands.clean_content = None) -> None: - """ - Return a documentation embed for a given symbol. - - If no symbol is given, return a list of all available inventories. - - Examples: - !docs - !docs aiohttp - !docs aiohttp.ClientSession - !docs get aiohttp.ClientSession - """ - if symbol is None: - inventory_embed = discord.Embed( - title=f"All inventories (`{len(self.base_urls)}` total)", - colour=discord.Colour.blue() - ) - - lines = sorted(f"• [`{name}`]({url})" for name, url in self.base_urls.items()) - if self.base_urls: - await LinePaginator.paginate(lines, ctx, inventory_embed, max_size=400, empty=False) - - else: - inventory_embed.description = "Hmmm, seems like there's nothing here yet." - await ctx.send(embed=inventory_embed) - - else: - # Fetching documentation for a symbol (at least for the first time, since - # caching is used) takes quite some time, so let's send typing to indicate - # that we got the command, but are still working on it. - async with ctx.typing(): - doc_embed = await self.get_symbol_embed(symbol) - - if doc_embed is None: - error_embed = discord.Embed( - description=f"Sorry, I could not find any documentation for `{symbol}`.", - colour=discord.Colour.red() - ) - error_message = await ctx.send(embed=error_embed) - with suppress(NotFound): - await error_message.delete(delay=NOT_FOUND_DELETE_DELAY) - await ctx.message.delete(delay=NOT_FOUND_DELETE_DELAY) - else: - await ctx.send(embed=doc_embed) - - @docs_group.command(name='set', aliases=('s',)) - @with_role(*MODERATION_ROLES) - async def set_command( - self, ctx: commands.Context, package_name: ValidPythonIdentifier, - base_url: ValidURL, inventory_url: InventoryURL - ) -> None: - """ - Adds a new documentation metadata object to the site's database. - - The database will update the object, should an existing item with the specified `package_name` already exist. - - Example: - !docs set \ - python \ - https://docs.python.org/3/ \ - https://docs.python.org/3/objects.inv - """ - body = { - 'package': package_name, - 'base_url': base_url, - 'inventory_url': inventory_url - } - await self.bot.api_client.post('bot/documentation-links', json=body) - - log.info( - f"User @{ctx.author} ({ctx.author.id}) added a new documentation package:\n" - f"Package name: {package_name}\n" - f"Base url: {base_url}\n" - f"Inventory URL: {inventory_url}" - ) - - # Rebuilding the inventory can take some time, so lets send out a - # typing event to show that the Bot is still working. - async with ctx.typing(): - await self.refresh_inventory() - await ctx.send(f"Added package `{package_name}` to database and refreshed inventory.") - - @docs_group.command(name='delete', aliases=('remove', 'rm', 'd')) - @with_role(*MODERATION_ROLES) - async def delete_command(self, ctx: commands.Context, package_name: ValidPythonIdentifier) -> None: - """ - Removes the specified package from the database. - - Examples: - !docs delete aiohttp - """ - await self.bot.api_client.delete(f'bot/documentation-links/{package_name}') - - async with ctx.typing(): - # Rebuild the inventory to ensure that everything - # that was from this package is properly deleted. - await self.refresh_inventory() - await ctx.send(f"Successfully deleted `{package_name}` and refreshed inventory.") - - @docs_group.command(name="refresh", aliases=("rfsh", "r")) - @with_role(*MODERATION_ROLES) - async def refresh_command(self, ctx: commands.Context) -> None: - """Refresh inventories and send differences to channel.""" - old_inventories = set(self.base_urls) - with ctx.typing(): - await self.refresh_inventory() - # Get differences of added and removed inventories - added = ', '.join(inv for inv in self.base_urls if inv not in old_inventories) - if added: - added = f"+ {added}" - - removed = ', '.join(inv for inv in old_inventories if inv not in self.base_urls) - if removed: - removed = f"- {removed}" - - embed = discord.Embed( - title="Inventories refreshed", - description=f"```diff\n{added}\n{removed}```" if added or removed else "" - ) - await ctx.send(embed=embed) - - async def _fetch_inventory(self, inventory_url: str) -> Optional[dict]: - """Get and return inventory from `inventory_url`. If fetching fails, return None.""" - fetch_func = functools.partial(intersphinx.fetch_inventory, SPHINX_MOCK_APP, '', inventory_url) - for retry in range(1, FAILED_REQUEST_RETRY_AMOUNT+1): - try: - package = await self.bot.loop.run_in_executor(None, fetch_func) - except ConnectTimeout: - log.error( - f"Fetching of inventory {inventory_url} timed out," - f" trying again. ({retry}/{FAILED_REQUEST_RETRY_AMOUNT})" - ) - except ProtocolError: - log.error( - f"Connection lost while fetching inventory {inventory_url}," - f" trying again. ({retry}/{FAILED_REQUEST_RETRY_AMOUNT})" - ) - except HTTPError as e: - log.error(f"Fetching of inventory {inventory_url} failed with status code {e.response.status_code}.") - return None - except ConnectionError: - log.error(f"Couldn't establish connection to inventory {inventory_url}.") - return None - else: - return package - log.error(f"Fetching of inventory {inventory_url} failed.") - return None - - @staticmethod - def _match_end_tag(tag: Tag) -> bool: - """Matches `tag` if its class value is in `SEARCH_END_TAG_ATTRS` or the tag is table.""" - for attr in SEARCH_END_TAG_ATTRS: - if attr in tag.get("class", ()): - return True - - return tag.name == "table" - - -def setup(bot: Bot) -> None: - """Load the Doc cog.""" - bot.add_cog(Doc(bot)) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py deleted file mode 100644 index f9d4de638..000000000 --- a/bot/cogs/error_handler.py +++ /dev/null @@ -1,287 +0,0 @@ -import contextlib -import logging -import typing as t - -from discord import Embed -from discord.ext.commands import Cog, Context, errors -from sentry_sdk import push_scope - -from bot.api import ResponseCodeError -from bot.bot import Bot -from bot.constants import Channels, Colours -from bot.converters import TagNameConverter -from bot.utils.checks import InWhitelistCheckFailure - -log = logging.getLogger(__name__) - - -class ErrorHandler(Cog): - """Handles errors emitted from commands.""" - - def __init__(self, bot: Bot): - self.bot = bot - - def _get_error_embed(self, title: str, body: str) -> Embed: - """Return an embed that contains the exception.""" - return Embed( - title=title, - colour=Colours.soft_red, - description=body - ) - - @Cog.listener() - async def on_command_error(self, ctx: Context, e: errors.CommandError) -> None: - """ - Provide generic command error handling. - - Error handling is deferred to any local error handler, if present. This is done by - checking for the presence of a `handled` attribute on the error. - - Error handling emits a single error message in the invoking context `ctx` and a log message, - prioritised as follows: - - 1. If the name fails to match a command: - * If it matches shh+ or unshh+, the channel is silenced or unsilenced respectively. - Otherwise if it matches a tag, the tag is invoked - * If CommandNotFound is raised when invoking the tag (determined by the presence of the - `invoked_from_error_handler` attribute), this error is treated as being unexpected - and therefore sends an error message - * Commands in the verification channel are ignored - 2. UserInputError: see `handle_user_input_error` - 3. CheckFailure: see `handle_check_failure` - 4. CommandOnCooldown: send an error message in the invoking context - 5. ResponseCodeError: see `handle_api_error` - 6. Otherwise, if not a DisabledCommand, handling is deferred to `handle_unexpected_error` - """ - command = ctx.command - - if hasattr(e, "handled"): - log.trace(f"Command {command} had its error already handled locally; ignoring.") - return - - if isinstance(e, errors.CommandNotFound) and not hasattr(ctx, "invoked_from_error_handler"): - if await self.try_silence(ctx): - return - if ctx.channel.id != Channels.verification: - # Try to look for a tag with the command's name - await self.try_get_tag(ctx) - return # Exit early to avoid logging. - elif isinstance(e, errors.UserInputError): - await self.handle_user_input_error(ctx, e) - elif isinstance(e, errors.CheckFailure): - await self.handle_check_failure(ctx, e) - elif isinstance(e, errors.CommandOnCooldown): - await ctx.send(e) - elif isinstance(e, errors.CommandInvokeError): - if isinstance(e.original, ResponseCodeError): - await self.handle_api_error(ctx, e.original) - else: - await self.handle_unexpected_error(ctx, e.original) - return # Exit early to avoid logging. - elif not isinstance(e, errors.DisabledCommand): - # ConversionError, MaxConcurrencyReached, ExtensionError - await self.handle_unexpected_error(ctx, e) - return # Exit early to avoid logging. - - log.debug( - f"Command {command} invoked by {ctx.message.author} with error " - f"{e.__class__.__name__}: {e}" - ) - - @staticmethod - def get_help_command(ctx: Context) -> t.Coroutine: - """Return a prepared `help` command invocation coroutine.""" - if ctx.command: - return ctx.send_help(ctx.command) - - return ctx.send_help() - - async def try_silence(self, ctx: Context) -> bool: - """ - Attempt to invoke the silence or unsilence command if invoke with matches a pattern. - - Respecting the checks if: - * invoked with `shh+` silence channel for amount of h's*2 with max of 15. - * invoked with `unshh+` unsilence channel - Return bool depending on success of command. - """ - command = ctx.invoked_with.lower() - silence_command = self.bot.get_command("silence") - ctx.invoked_from_error_handler = True - try: - if not await silence_command.can_run(ctx): - log.debug("Cancelling attempt to invoke silence/unsilence due to failed checks.") - return False - except errors.CommandError: - log.debug("Cancelling attempt to invoke silence/unsilence due to failed checks.") - return False - if command.startswith("shh"): - await ctx.invoke(silence_command, duration=min(command.count("h")*2, 15)) - return True - elif command.startswith("unshh"): - await ctx.invoke(self.bot.get_command("unsilence")) - return True - return False - - async def try_get_tag(self, ctx: Context) -> None: - """ - Attempt to display a tag by interpreting the command name as a tag name. - - The invocation of tags get respects its checks. Any CommandErrors raised will be handled - by `on_command_error`, but the `invoked_from_error_handler` attribute will be added to - the context to prevent infinite recursion in the case of a CommandNotFound exception. - """ - tags_get_command = self.bot.get_command("tags get") - ctx.invoked_from_error_handler = True - - log_msg = "Cancelling attempt to fall back to a tag due to failed checks." - try: - if not await tags_get_command.can_run(ctx): - log.debug(log_msg) - return - except errors.CommandError as tag_error: - log.debug(log_msg) - await self.on_command_error(ctx, tag_error) - return - - try: - tag_name = await TagNameConverter.convert(ctx, ctx.invoked_with) - except errors.BadArgument: - log.debug( - f"{ctx.author} tried to use an invalid command " - f"and the fallback tag failed validation in TagNameConverter." - ) - else: - with contextlib.suppress(ResponseCodeError): - await ctx.invoke(tags_get_command, tag_name=tag_name) - # Return to not raise the exception - return - - async def handle_user_input_error(self, ctx: Context, e: errors.UserInputError) -> None: - """ - Send an error message in `ctx` for UserInputError, sometimes invoking the help command too. - - * MissingRequiredArgument: send an error message with arg name and the help command - * TooManyArguments: send an error message and the help command - * BadArgument: send an error message and the help command - * BadUnionArgument: send an error message including the error produced by the last converter - * ArgumentParsingError: send an error message - * Other: send an error message and the help command - """ - prepared_help_command = self.get_help_command(ctx) - - if isinstance(e, errors.MissingRequiredArgument): - embed = self._get_error_embed("Missing required argument", e.param.name) - await ctx.send(embed=embed) - await prepared_help_command - self.bot.stats.incr("errors.missing_required_argument") - elif isinstance(e, errors.TooManyArguments): - embed = self._get_error_embed("Too many arguments", str(e)) - await ctx.send(embed=embed) - await prepared_help_command - self.bot.stats.incr("errors.too_many_arguments") - elif isinstance(e, errors.BadArgument): - embed = self._get_error_embed("Bad argument", str(e)) - await ctx.send(embed=embed) - await prepared_help_command - self.bot.stats.incr("errors.bad_argument") - elif isinstance(e, errors.BadUnionArgument): - embed = self._get_error_embed("Bad argument", f"{e}\n{e.errors[-1]}") - await ctx.send(embed=embed) - self.bot.stats.incr("errors.bad_union_argument") - elif isinstance(e, errors.ArgumentParsingError): - embed = self._get_error_embed("Argument parsing error", str(e)) - await ctx.send(embed=embed) - self.bot.stats.incr("errors.argument_parsing_error") - else: - embed = self._get_error_embed( - "Input error", - "Something about your input seems off. Check the arguments and try again." - ) - await ctx.send(embed=embed) - await prepared_help_command - self.bot.stats.incr("errors.other_user_input_error") - - @staticmethod - async def handle_check_failure(ctx: Context, e: errors.CheckFailure) -> None: - """ - Send an error message in `ctx` for certain types of CheckFailure. - - The following types are handled: - - * BotMissingPermissions - * BotMissingRole - * BotMissingAnyRole - * NoPrivateMessage - * InWhitelistCheckFailure - """ - bot_missing_errors = ( - errors.BotMissingPermissions, - errors.BotMissingRole, - errors.BotMissingAnyRole - ) - - if isinstance(e, bot_missing_errors): - ctx.bot.stats.incr("errors.bot_permission_error") - await ctx.send( - "Sorry, it looks like I don't have the permissions or roles I need to do that." - ) - elif isinstance(e, (InWhitelistCheckFailure, errors.NoPrivateMessage)): - ctx.bot.stats.incr("errors.wrong_channel_or_dm_error") - await ctx.send(e) - - @staticmethod - async def handle_api_error(ctx: Context, e: ResponseCodeError) -> None: - """Send an error message in `ctx` for ResponseCodeError and log it.""" - if e.status == 404: - await ctx.send("There does not seem to be anything matching your query.") - log.debug(f"API responded with 404 for command {ctx.command}") - ctx.bot.stats.incr("errors.api_error_404") - elif e.status == 400: - content = await e.response.json() - log.debug(f"API responded with 400 for command {ctx.command}: %r.", content) - await ctx.send("According to the API, your request is malformed.") - ctx.bot.stats.incr("errors.api_error_400") - elif 500 <= e.status < 600: - await ctx.send("Sorry, there seems to be an internal issue with the API.") - log.warning(f"API responded with {e.status} for command {ctx.command}") - ctx.bot.stats.incr("errors.api_internal_server_error") - else: - await ctx.send(f"Got an unexpected status code from the API (`{e.status}`).") - log.warning(f"Unexpected API response for command {ctx.command}: {e.status}") - ctx.bot.stats.incr(f"errors.api_error_{e.status}") - - @staticmethod - async def handle_unexpected_error(ctx: Context, e: errors.CommandError) -> None: - """Send a generic error message in `ctx` and log the exception as an error with exc_info.""" - await ctx.send( - f"Sorry, an unexpected error occurred. Please let us know!\n\n" - f"```{e.__class__.__name__}: {e}```" - ) - - ctx.bot.stats.incr("errors.unexpected") - - with push_scope() as scope: - scope.user = { - "id": ctx.author.id, - "username": str(ctx.author) - } - - scope.set_tag("command", ctx.command.qualified_name) - scope.set_tag("message_id", ctx.message.id) - scope.set_tag("channel_id", ctx.channel.id) - - scope.set_extra("full_message", ctx.message.content) - - if ctx.guild is not None: - scope.set_extra( - "jump_to", - f"https://discordapp.com/channels/{ctx.guild.id}/{ctx.channel.id}/{ctx.message.id}" - ) - - log.error(f"Error executing command invoked by {ctx.message.author}: {ctx.message.content}", exc_info=e) - - -def setup(bot: Bot) -> None: - """Load the ErrorHandler cog.""" - bot.add_cog(ErrorHandler(bot)) diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py deleted file mode 100644 index eb8bfb1cf..000000000 --- a/bot/cogs/eval.py +++ /dev/null @@ -1,202 +0,0 @@ -import contextlib -import inspect -import logging -import pprint -import re -import textwrap -import traceback -from io import StringIO -from typing import Any, Optional, Tuple - -import discord -from discord.ext.commands import Cog, Context, group - -from bot.bot import Bot -from bot.constants import Roles -from bot.decorators import with_role -from bot.interpreter import Interpreter - -log = logging.getLogger(__name__) - - -class CodeEval(Cog): - """Owner and admin feature that evaluates code and returns the result to the channel.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.env = {} - self.ln = 0 - self.stdout = StringIO() - - self.interpreter = Interpreter(bot) - - def _format(self, inp: str, out: Any) -> Tuple[str, Optional[discord.Embed]]: - """Format the eval output into a string & attempt to format it into an Embed.""" - self._ = out - - res = "" - - # Erase temp input we made - if inp.startswith("_ = "): - inp = inp[4:] - - # Get all non-empty lines - lines = [line for line in inp.split("\n") if line.strip()] - if len(lines) != 1: - lines += [""] - - # Create the input dialog - for i, line in enumerate(lines): - if i == 0: - # Start dialog - start = f"In [{self.ln}]: " - - else: - # Indent the 3 dots correctly; - # Normally, it's something like - # In [X]: - # ...: - # - # But if it's - # In [XX]: - # ...: - # - # You can see it doesn't look right. - # This code simply indents the dots - # far enough to align them. - # we first `str()` the line number - # then we get the length - # and use `str.rjust()` - # to indent it. - start = "...: ".rjust(len(str(self.ln)) + 7) - - if i == len(lines) - 2: - if line.startswith("return"): - line = line[6:].strip() - - # Combine everything - res += (start + line + "\n") - - self.stdout.seek(0) - text = self.stdout.read() - self.stdout.close() - self.stdout = StringIO() - - if text: - res += (text + "\n") - - if out is None: - # No output, return the input statement - return (res, None) - - res += f"Out[{self.ln}]: " - - if isinstance(out, discord.Embed): - # We made an embed? Send that as embed - res += "" - res = (res, out) - - else: - if (isinstance(out, str) and out.startswith("Traceback (most recent call last):\n")): - # Leave out the traceback message - out = "\n" + "\n".join(out.split("\n")[1:]) - - if isinstance(out, str): - pretty = out - else: - pretty = pprint.pformat(out, compact=True, width=60) - - if pretty != str(out): - # We're using the pretty version, start on the next line - res += "\n" - - if pretty.count("\n") > 20: - # Text too long, shorten - li = pretty.split("\n") - - pretty = ("\n".join(li[:3]) # First 3 lines - + "\n ...\n" # Ellipsis to indicate removed lines - + "\n".join(li[-3:])) # last 3 lines - - # Add the output - res += pretty - res = (res, None) - - return res # Return (text, embed) - - async def _eval(self, ctx: Context, code: str) -> Optional[discord.Message]: - """Eval the input code string & send an embed to the invoking context.""" - self.ln += 1 - - if code.startswith("exit"): - self.ln = 0 - self.env = {} - return await ctx.send("```Reset history!```") - - env = { - "message": ctx.message, - "author": ctx.message.author, - "channel": ctx.channel, - "guild": ctx.guild, - "ctx": ctx, - "self": self, - "bot": self.bot, - "inspect": inspect, - "discord": discord, - "contextlib": contextlib - } - - self.env.update(env) - - # Ignore this code, it works - code_ = """ -async def func(): # (None,) -> Any - try: - with contextlib.redirect_stdout(self.stdout): -{0} - if '_' in locals(): - if inspect.isawaitable(_): - _ = await _ - return _ - finally: - self.env.update(locals()) -""".format(textwrap.indent(code, ' ')) - - try: - exec(code_, self.env) # noqa: B102,S102 - func = self.env['func'] - res = await func() - - except Exception: - res = traceback.format_exc() - - out, embed = self._format(code, res) - await ctx.send(f"```py\n{out}```", embed=embed) - - @group(name='internal', aliases=('int',)) - @with_role(Roles.owners, Roles.admins) - async def internal_group(self, ctx: Context) -> None: - """Internal commands. Top secret!""" - if not ctx.invoked_subcommand: - await ctx.send_help(ctx.command) - - @internal_group.command(name='eval', aliases=('e',)) - @with_role(Roles.admins, Roles.owners) - async def eval(self, ctx: Context, *, code: str) -> None: - """Run eval in a REPL-like format.""" - code = code.strip("`") - if re.match('py(thon)?\n', code): - code = "\n".join(code.split("\n")[1:]) - - if not re.search( # Check if it's an expression - r"^(return|import|for|while|def|class|" - r"from|exit|[a-zA-Z0-9]+\s*=)", code, re.M) and len( - code.split("\n")) == 1: - code = "_ = " + code - - await self._eval(ctx, code) - - -def setup(bot: Bot) -> None: - """Load the CodeEval cog.""" - bot.add_cog(CodeEval(bot)) diff --git a/bot/cogs/extensions.py b/bot/cogs/extensions.py deleted file mode 100644 index 365f198ff..000000000 --- a/bot/cogs/extensions.py +++ /dev/null @@ -1,236 +0,0 @@ -import functools -import logging -import typing as t -from enum import Enum -from pkgutil import iter_modules - -from discord import Colour, Embed -from discord.ext import commands -from discord.ext.commands import Context, group - -from bot.bot import Bot -from bot.constants import Emojis, MODERATION_ROLES, Roles, URLs -from bot.pagination import LinePaginator -from bot.utils.checks import with_role_check - -log = logging.getLogger(__name__) - -UNLOAD_BLACKLIST = {"bot.cogs.extensions", "bot.cogs.modlog"} -EXTENSIONS = frozenset( - ext.name - for ext in iter_modules(("bot/cogs",), "bot.cogs.") - if ext.name[-1] != "_" -) - - -class Action(Enum): - """Represents an action to perform on an extension.""" - - # Need to be partial otherwise they are considered to be function definitions. - LOAD = functools.partial(Bot.load_extension) - UNLOAD = functools.partial(Bot.unload_extension) - RELOAD = functools.partial(Bot.reload_extension) - - -class Extension(commands.Converter): - """ - Fully qualify the name of an extension and ensure it exists. - - The * and ** values bypass this when used with the reload command. - """ - - async def convert(self, ctx: Context, argument: str) -> str: - """Fully qualify the name of an extension and ensure it exists.""" - # Special values to reload all extensions - if argument == "*" or argument == "**": - return argument - - argument = argument.lower() - - if "." not in argument: - argument = f"bot.cogs.{argument}" - - if argument in EXTENSIONS: - return argument - else: - raise commands.BadArgument(f":x: Could not find the extension `{argument}`.") - - -class Extensions(commands.Cog): - """Extension management commands.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @group(name="extensions", aliases=("ext", "exts", "c", "cogs"), invoke_without_command=True) - async def extensions_group(self, ctx: Context) -> None: - """Load, unload, reload, and list loaded extensions.""" - await ctx.send_help(ctx.command) - - @extensions_group.command(name="load", aliases=("l",)) - async def load_command(self, ctx: Context, *extensions: Extension) -> None: - r""" - Load extensions given their fully qualified or unqualified names. - - If '\*' or '\*\*' is given as the name, all unloaded extensions will be loaded. - """ # noqa: W605 - if not extensions: - await ctx.send_help(ctx.command) - return - - if "*" in extensions or "**" in extensions: - extensions = set(EXTENSIONS) - set(self.bot.extensions.keys()) - - msg = self.batch_manage(Action.LOAD, *extensions) - await ctx.send(msg) - - @extensions_group.command(name="unload", aliases=("ul",)) - async def unload_command(self, ctx: Context, *extensions: Extension) -> None: - r""" - Unload currently loaded extensions given their fully qualified or unqualified names. - - If '\*' or '\*\*' is given as the name, all loaded extensions will be unloaded. - """ # noqa: W605 - if not extensions: - await ctx.send_help(ctx.command) - return - - blacklisted = "\n".join(UNLOAD_BLACKLIST & set(extensions)) - - if blacklisted: - msg = f":x: The following extension(s) may not be unloaded:```{blacklisted}```" - else: - if "*" in extensions or "**" in extensions: - extensions = set(self.bot.extensions.keys()) - UNLOAD_BLACKLIST - - msg = self.batch_manage(Action.UNLOAD, *extensions) - - await ctx.send(msg) - - @extensions_group.command(name="reload", aliases=("r",)) - async def reload_command(self, ctx: Context, *extensions: Extension) -> None: - r""" - Reload extensions given their fully qualified or unqualified names. - - If an extension fails to be reloaded, it will be rolled-back to the prior working state. - - If '\*' is given as the name, all currently loaded extensions will be reloaded. - If '\*\*' is given as the name, all extensions, including unloaded ones, will be reloaded. - """ # noqa: W605 - if not extensions: - await ctx.send_help(ctx.command) - return - - if "**" in extensions: - extensions = EXTENSIONS - elif "*" in extensions: - extensions = set(self.bot.extensions.keys()) | set(extensions) - extensions.remove("*") - - msg = self.batch_manage(Action.RELOAD, *extensions) - - await ctx.send(msg) - - @extensions_group.command(name="list", aliases=("all",)) - async def list_command(self, ctx: Context) -> None: - """ - Get a list of all extensions, including their loaded status. - - Grey indicates that the extension is unloaded. - Green indicates that the extension is currently loaded. - """ - embed = Embed() - lines = [] - - embed.colour = Colour.blurple() - embed.set_author( - name="Extensions List", - url=URLs.github_bot_repo, - icon_url=URLs.bot_avatar - ) - - for ext in sorted(list(EXTENSIONS)): - if ext in self.bot.extensions: - status = Emojis.status_online - else: - status = Emojis.status_offline - - ext = ext.rsplit(".", 1)[1] - lines.append(f"{status} {ext}") - - log.debug(f"{ctx.author} requested a list of all cogs. Returning a paginated list.") - await LinePaginator.paginate(lines, ctx, embed, max_size=300, empty=False) - - def batch_manage(self, action: Action, *extensions: str) -> str: - """ - Apply an action to multiple extensions and return a message with the results. - - If only one extension is given, it is deferred to `manage()`. - """ - if len(extensions) == 1: - msg, _ = self.manage(action, extensions[0]) - return msg - - verb = action.name.lower() - failures = {} - - for extension in extensions: - _, error = self.manage(action, extension) - if error: - failures[extension] = error - - emoji = ":x:" if failures else ":ok_hand:" - msg = f"{emoji} {len(extensions) - len(failures)} / {len(extensions)} extensions {verb}ed." - - if failures: - failures = "\n".join(f"{ext}\n {err}" for ext, err in failures.items()) - msg += f"\nFailures:```{failures}```" - - log.debug(f"Batch {verb}ed extensions.") - - return msg - - def manage(self, action: Action, ext: str) -> t.Tuple[str, t.Optional[str]]: - """Apply an action to an extension and return the status message and any error message.""" - verb = action.name.lower() - error_msg = None - - try: - action.value(self.bot, ext) - except (commands.ExtensionAlreadyLoaded, commands.ExtensionNotLoaded): - if action is Action.RELOAD: - # When reloading, just load the extension if it was not loaded. - return self.manage(Action.LOAD, ext) - - msg = f":x: Extension `{ext}` is already {verb}ed." - log.debug(msg[4:]) - except Exception as e: - if hasattr(e, "original"): - e = e.original - - log.exception(f"Extension '{ext}' failed to {verb}.") - - error_msg = f"{e.__class__.__name__}: {e}" - msg = f":x: Failed to {verb} extension `{ext}`:\n```{error_msg}```" - else: - msg = f":ok_hand: Extension successfully {verb}ed: `{ext}`." - log.debug(msg[10:]) - - return msg, error_msg - - # This cannot be static (must have a __func__ attribute). - def cog_check(self, ctx: Context) -> bool: - """Only allow moderators and core developers to invoke the commands in this cog.""" - return with_role_check(ctx, *MODERATION_ROLES, Roles.core_developers) - - # This cannot be static (must have a __func__ attribute). - async def cog_command_error(self, ctx: Context, error: Exception) -> None: - """Handle BadArgument errors locally to prevent the help command from showing.""" - if isinstance(error, commands.BadArgument): - await ctx.send(str(error)) - error.handled = True - - -def setup(bot: Bot) -> None: - """Load the Extensions cog.""" - bot.add_cog(Extensions(bot)) diff --git a/bot/cogs/filter_lists.py b/bot/cogs/filter_lists.py deleted file mode 100644 index c15adc461..000000000 --- a/bot/cogs/filter_lists.py +++ /dev/null @@ -1,273 +0,0 @@ -import logging -from typing import Optional - -from discord import Colour, Embed -from discord.ext.commands import BadArgument, Cog, Context, IDConverter, group - -from bot import constants -from bot.api import ResponseCodeError -from bot.bot import Bot -from bot.converters import ValidDiscordServerInvite, ValidFilterListType -from bot.pagination import LinePaginator -from bot.utils.checks import with_role_check - -log = logging.getLogger(__name__) - - -class FilterLists(Cog): - """Commands for blacklisting and whitelisting things.""" - - methods_with_filterlist_types = [ - "allow_add", - "allow_delete", - "allow_get", - "deny_add", - "deny_delete", - "deny_get", - ] - - def __init__(self, bot: Bot) -> None: - self.bot = bot - self.bot.loop.create_task(self._amend_docstrings()) - - async def _amend_docstrings(self) -> None: - """Add the valid FilterList types to the docstrings, so they'll appear in !help invocations.""" - await self.bot.wait_until_guild_available() - - # Add valid filterlist types to the docstrings - valid_types = await ValidFilterListType.get_valid_types(self.bot) - valid_types = [f"`{type_.lower()}`" for type_ in valid_types] - - for method_name in self.methods_with_filterlist_types: - command = getattr(self, method_name) - command.help = ( - f"{command.help}\n\nValid **list_type** values are {', '.join(valid_types)}." - ) - - async def _add_data( - self, - ctx: Context, - allowed: bool, - list_type: ValidFilterListType, - content: str, - comment: Optional[str] = None, - ) -> None: - """Add an item to a filterlist.""" - allow_type = "whitelist" if allowed else "blacklist" - - # If this is a server invite, we gotta validate it. - if list_type == "GUILD_INVITE": - guild_data = await self._validate_guild_invite(ctx, content) - content = guild_data.get("id") - - # Unless the user has specified another comment, let's - # use the server name as the comment so that the list - # of guild IDs will be more easily readable when we - # display it. - if not comment: - comment = guild_data.get("name") - - # If it's a file format, let's make sure it has a leading dot. - elif list_type == "FILE_FORMAT" and not content.startswith("."): - content = f".{content}" - - # Try to add the item to the database - log.trace(f"Trying to add the {content} item to the {list_type} {allow_type}") - payload = { - "allowed": allowed, - "type": list_type, - "content": content, - "comment": comment, - } - - try: - item = await self.bot.api_client.post( - "bot/filter-lists", - json=payload - ) - except ResponseCodeError as e: - if e.status == 400: - await ctx.message.add_reaction("❌") - log.debug( - f"{ctx.author} tried to add data to a {allow_type}, but the API returned 400, " - "probably because the request violated the UniqueConstraint." - ) - raise BadArgument( - f"Unable to add the item to the {allow_type}. " - "The item probably already exists. Keep in mind that a " - "blacklist and a whitelist for the same item cannot co-exist, " - "and we do not permit any duplicates." - ) - raise - - # Insert the item into the cache - self.bot.insert_item_into_filter_list_cache(item) - await ctx.message.add_reaction("✅") - - async def _delete_data(self, ctx: Context, allowed: bool, list_type: ValidFilterListType, content: str) -> None: - """Remove an item from a filterlist.""" - allow_type = "whitelist" if allowed else "blacklist" - - # If this is a server invite, we need to convert it. - if list_type == "GUILD_INVITE" and not IDConverter()._get_id_match(content): - guild_data = await self._validate_guild_invite(ctx, content) - content = guild_data.get("id") - - # If it's a file format, let's make sure it has a leading dot. - elif list_type == "FILE_FORMAT" and not content.startswith("."): - content = f".{content}" - - # Find the content and delete it. - log.trace(f"Trying to delete the {content} item from the {list_type} {allow_type}") - item = self.bot.filter_list_cache[f"{list_type}.{allowed}"].get(content) - - if item is not None: - try: - await self.bot.api_client.delete( - f"bot/filter-lists/{item['id']}" - ) - del self.bot.filter_list_cache[f"{list_type}.{allowed}"][content] - await ctx.message.add_reaction("✅") - except ResponseCodeError as e: - log.debug( - f"{ctx.author} tried to delete an item with the id {item['id']}, but " - f"the API raised an unexpected error: {e}" - ) - await ctx.message.add_reaction("❌") - else: - await ctx.message.add_reaction("❌") - - async def _list_all_data(self, ctx: Context, allowed: bool, list_type: ValidFilterListType) -> None: - """Paginate and display all items in a filterlist.""" - allow_type = "whitelist" if allowed else "blacklist" - result = self.bot.filter_list_cache[f"{list_type}.{allowed}"] - - # Build a list of lines we want to show in the paginator - lines = [] - for content, metadata in result.items(): - line = f"• `{content}`" - - if comment := metadata.get("comment"): - line += f" - {comment}" - - lines.append(line) - lines = sorted(lines) - - # Build the embed - list_type_plural = list_type.lower().replace("_", " ").title() + "s" - embed = Embed( - title=f"{allow_type.title()}ed {list_type_plural} ({len(result)} total)", - colour=Colour.blue() - ) - log.trace(f"Trying to list {len(result)} items from the {list_type.lower()} {allow_type}") - - if result: - await LinePaginator.paginate(lines, ctx, embed, max_lines=15, empty=False) - else: - embed.description = "Hmmm, seems like there's nothing here yet." - await ctx.send(embed=embed) - await ctx.message.add_reaction("❌") - - async def _sync_data(self, ctx: Context) -> None: - """Syncs the filterlists with the API.""" - try: - log.trace("Attempting to sync FilterList cache with data from the API.") - await self.bot.cache_filter_list_data() - await ctx.message.add_reaction("✅") - except ResponseCodeError as e: - log.debug( - f"{ctx.author} tried to sync FilterList cache data but " - f"the API raised an unexpected error: {e}" - ) - await ctx.message.add_reaction("❌") - - @staticmethod - async def _validate_guild_invite(ctx: Context, invite: str) -> dict: - """ - Validates a guild invite, and returns the guild info as a dict. - - Will raise a BadArgument if the guild invite is invalid. - """ - log.trace(f"Attempting to validate whether or not {invite} is a guild invite.") - validator = ValidDiscordServerInvite() - guild_data = await validator.convert(ctx, invite) - - # If we make it this far without raising a BadArgument, the invite is - # valid. Let's return a dict of guild information. - log.trace(f"{invite} validated as server invite. Converting to ID.") - return guild_data - - @group(aliases=("allowlist", "allow", "al", "wl")) - async def whitelist(self, ctx: Context) -> None: - """Group for whitelisting commands.""" - if not ctx.invoked_subcommand: - await ctx.send_help(ctx.command) - - @group(aliases=("denylist", "deny", "bl", "dl")) - async def blacklist(self, ctx: Context) -> None: - """Group for blacklisting commands.""" - if not ctx.invoked_subcommand: - await ctx.send_help(ctx.command) - - @whitelist.command(name="add", aliases=("a", "set")) - async def allow_add( - self, - ctx: Context, - list_type: ValidFilterListType, - content: str, - *, - comment: Optional[str] = None, - ) -> None: - """Add an item to the specified allowlist.""" - await self._add_data(ctx, True, list_type, content, comment) - - @blacklist.command(name="add", aliases=("a", "set")) - async def deny_add( - self, - ctx: Context, - list_type: ValidFilterListType, - content: str, - *, - comment: Optional[str] = None, - ) -> None: - """Add an item to the specified denylist.""" - await self._add_data(ctx, False, list_type, content, comment) - - @whitelist.command(name="remove", aliases=("delete", "rm",)) - async def allow_delete(self, ctx: Context, list_type: ValidFilterListType, content: str) -> None: - """Remove an item from the specified allowlist.""" - await self._delete_data(ctx, True, list_type, content) - - @blacklist.command(name="remove", aliases=("delete", "rm",)) - async def deny_delete(self, ctx: Context, list_type: ValidFilterListType, content: str) -> None: - """Remove an item from the specified denylist.""" - await self._delete_data(ctx, False, list_type, content) - - @whitelist.command(name="get", aliases=("list", "ls", "fetch", "show")) - async def allow_get(self, ctx: Context, list_type: ValidFilterListType) -> None: - """Get the contents of a specified allowlist.""" - await self._list_all_data(ctx, True, list_type) - - @blacklist.command(name="get", aliases=("list", "ls", "fetch", "show")) - async def deny_get(self, ctx: Context, list_type: ValidFilterListType) -> None: - """Get the contents of a specified denylist.""" - await self._list_all_data(ctx, False, list_type) - - @whitelist.command(name="sync", aliases=("s",)) - async def allow_sync(self, ctx: Context) -> None: - """Syncs both allowlists and denylists with the API.""" - await self._sync_data(ctx) - - @blacklist.command(name="sync", aliases=("s",)) - async def deny_sync(self, ctx: Context) -> None: - """Syncs both allowlists and denylists with the API.""" - await self._sync_data(ctx) - - def cog_check(self, ctx: Context) -> bool: - """Only allow moderators to invoke the commands in this cog.""" - return with_role_check(ctx, *constants.MODERATION_ROLES) - - -def setup(bot: Bot) -> None: - """Load the FilterLists cog.""" - bot.add_cog(FilterLists(bot)) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py deleted file mode 100644 index 93cc1c655..000000000 --- a/bot/cogs/filtering.py +++ /dev/null @@ -1,575 +0,0 @@ -import asyncio -import logging -import re -from datetime import datetime, timedelta -from typing import List, Mapping, Optional, Tuple, Union - -import dateutil -import discord.errors -from dateutil.relativedelta import relativedelta -from discord import Colour, HTTPException, Member, Message, NotFound, TextChannel -from discord.ext.commands import Cog -from discord.utils import escape_markdown - -from bot.bot import Bot -from bot.cogs.moderation import ModLog -from bot.constants import ( - Channels, Colours, - Filter, Icons, URLs -) -from bot.utils.redis_cache import RedisCache -from bot.utils.regex import INVITE_RE -from bot.utils.scheduling import Scheduler - -log = logging.getLogger(__name__) - -# Regular expressions -SPOILER_RE = re.compile(r"(\|\|.+?\|\|)", re.DOTALL) -URL_RE = re.compile(r"(https?://[^\s]+)", flags=re.IGNORECASE) -ZALGO_RE = re.compile(r"[\u0300-\u036F\u0489]") - -# Other constants. -DAYS_BETWEEN_ALERTS = 3 -OFFENSIVE_MSG_DELETE_TIME = timedelta(days=Filter.offensive_msg_delete_days) - - -class Filtering(Cog): - """Filtering out invites, blacklisting domains, and warning us of certain regular expressions.""" - - # Redis cache mapping a user ID to the last timestamp a bad nickname alert was sent - name_alerts = RedisCache() - - def __init__(self, bot: Bot): - self.bot = bot - self.scheduler = Scheduler(self.__class__.__name__) - self.name_lock = asyncio.Lock() - - staff_mistake_str = "If you believe this was a mistake, please let staff know!" - self.filters = { - "filter_zalgo": { - "enabled": Filter.filter_zalgo, - "function": self._has_zalgo, - "type": "filter", - "content_only": True, - "user_notification": Filter.notify_user_zalgo, - "notification_msg": ( - "Your post has been removed for abusing Unicode character rendering (aka Zalgo text). " - f"{staff_mistake_str}" - ), - "schedule_deletion": False - }, - "filter_invites": { - "enabled": Filter.filter_invites, - "function": self._has_invites, - "type": "filter", - "content_only": True, - "user_notification": Filter.notify_user_invites, - "notification_msg": ( - f"Per Rule 6, your invite link has been removed. {staff_mistake_str}\n\n" - r"Our server rules can be found here: " - ), - "schedule_deletion": False - }, - "filter_domains": { - "enabled": Filter.filter_domains, - "function": self._has_urls, - "type": "filter", - "content_only": True, - "user_notification": Filter.notify_user_domains, - "notification_msg": ( - f"Your URL has been removed because it matched a blacklisted domain. {staff_mistake_str}" - ), - "schedule_deletion": False - }, - "watch_regex": { - "enabled": Filter.watch_regex, - "function": self._has_watch_regex_match, - "type": "watchlist", - "content_only": True, - "schedule_deletion": True - }, - "watch_rich_embeds": { - "enabled": Filter.watch_rich_embeds, - "function": self._has_rich_embed, - "type": "watchlist", - "content_only": False, - "schedule_deletion": False - } - } - - self.bot.loop.create_task(self.reschedule_offensive_msg_deletion()) - - def cog_unload(self) -> None: - """Cancel scheduled tasks.""" - self.scheduler.cancel_all() - - def _get_filterlist_items(self, list_type: str, *, allowed: bool) -> list: - """Fetch items from the filter_list_cache.""" - return self.bot.filter_list_cache[f"{list_type.upper()}.{allowed}"].keys() - - @staticmethod - def _expand_spoilers(text: str) -> str: - """Return a string containing all interpretations of a spoilered message.""" - split_text = SPOILER_RE.split(text) - return ''.join( - split_text[0::2] + split_text[1::2] + split_text - ) - - @property - def mod_log(self) -> ModLog: - """Get currently loaded ModLog cog instance.""" - return self.bot.get_cog("ModLog") - - @Cog.listener() - async def on_message(self, msg: Message) -> None: - """Invoke message filter for new messages.""" - await self._filter_message(msg) - - # Ignore webhook messages. - if msg.webhook_id is None: - await self.check_bad_words_in_name(msg.author) - - @Cog.listener() - async def on_message_edit(self, before: Message, after: Message) -> None: - """ - Invoke message filter for message edits. - - If there have been multiple edits, calculate the time delta from the previous edit. - """ - if not before.edited_at: - delta = relativedelta(after.edited_at, before.created_at).microseconds - else: - delta = relativedelta(after.edited_at, before.edited_at).microseconds - await self._filter_message(after, delta) - - def get_name_matches(self, name: str) -> List[re.Match]: - """Check bad words from passed string (name). Return list of matches.""" - matches = [] - watchlist_patterns = self._get_filterlist_items('filter_token', allowed=False) - for pattern in watchlist_patterns: - if match := re.search(pattern, name, flags=re.IGNORECASE): - matches.append(match) - return matches - - async def check_send_alert(self, member: Member) -> bool: - """When there is less than 3 days after last alert, return `False`, otherwise `True`.""" - if last_alert := await self.name_alerts.get(member.id): - last_alert = datetime.utcfromtimestamp(last_alert) - if datetime.utcnow() - timedelta(days=DAYS_BETWEEN_ALERTS) < last_alert: - log.trace(f"Last alert was too recent for {member}'s nickname.") - return False - - return True - - async def check_bad_words_in_name(self, member: Member) -> None: - """Send a mod alert every 3 days if a username still matches a watchlist pattern.""" - # Use lock to avoid race conditions - async with self.name_lock: - # Check whether the users display name contains any words in our blacklist - matches = self.get_name_matches(member.display_name) - - if not matches or not await self.check_send_alert(member): - return - - log.info(f"Sending bad nickname alert for '{member.display_name}' ({member.id}).") - - log_string = ( - f"**User:** {member.mention} (`{member.id}`)\n" - f"**Display Name:** {member.display_name}\n" - f"**Bad Matches:** {', '.join(match.group() for match in matches)}" - ) - - await self.mod_log.send_log_message( - icon_url=Icons.token_removed, - colour=Colours.soft_red, - title="Username filtering alert", - text=log_string, - channel_id=Channels.mod_alerts, - thumbnail=member.avatar_url - ) - - # Update time when alert sent - await self.name_alerts.set(member.id, datetime.utcnow().timestamp()) - - async def filter_eval(self, result: str, msg: Message) -> bool: - """ - Filter the result of an !eval to see if it violates any of our rules, and then respond accordingly. - - Also requires the original message, to check whether to filter and for mod logs. - Returns whether a filter was triggered or not. - """ - filter_triggered = False - # Should we filter this message? - if self._check_filter(msg): - for filter_name, _filter in self.filters.items(): - # Is this specific filter enabled in the config? - # We also do not need to worry about filters that take the full message, - # since all we have is an arbitrary string. - if _filter["enabled"] and _filter["content_only"]: - match = await _filter["function"](result) - - if match: - # If this is a filter (not a watchlist), we set the variable so we know - # that it has been triggered - if _filter["type"] == "filter": - filter_triggered = True - - # We do not have to check against DM channels since !eval cannot be used there. - channel_str = f"in {msg.channel.mention}" - - message_content, additional_embeds, additional_embeds_msg = self._add_stats( - filter_name, match, result - ) - - message = ( - f"The {filter_name} {_filter['type']} was triggered " - f"by **{msg.author}** " - f"(`{msg.author.id}`) {channel_str} using !eval with " - f"[the following message]({msg.jump_url}):\n\n" - f"{message_content}" - ) - - log.debug(message) - - # Send pretty mod log embed to mod-alerts - await self.mod_log.send_log_message( - icon_url=Icons.filtering, - colour=Colour(Colours.soft_red), - title=f"{_filter['type'].title()} triggered!", - text=message, - thumbnail=msg.author.avatar_url_as(static_format="png"), - channel_id=Channels.mod_alerts, - ping_everyone=Filter.ping_everyone, - additional_embeds=additional_embeds, - additional_embeds_msg=additional_embeds_msg - ) - - break # We don't want multiple filters to trigger - - return filter_triggered - - async def _filter_message(self, msg: Message, delta: Optional[int] = None) -> None: - """Filter the input message to see if it violates any of our rules, and then respond accordingly.""" - # Should we filter this message? - if self._check_filter(msg): - for filter_name, _filter in self.filters.items(): - # Is this specific filter enabled in the config? - if _filter["enabled"]: - # Double trigger check for the embeds filter - if filter_name == "watch_rich_embeds": - # If the edit delta is less than 0.001 seconds, then we're probably dealing - # with a double filter trigger. - if delta is not None and delta < 100: - continue - - # Does the filter only need the message content or the full message? - if _filter["content_only"]: - match = await _filter["function"](msg.content) - else: - match = await _filter["function"](msg) - - if match: - is_private = msg.channel.type is discord.ChannelType.private - - # If this is a filter (not a watchlist) and not in a DM, delete the message. - if _filter["type"] == "filter" and not is_private: - try: - # Embeds (can?) trigger both the `on_message` and `on_message_edit` - # event handlers, triggering filtering twice for the same message. - # - # If `on_message`-triggered filtering already deleted the message - # then `on_message_edit`-triggered filtering will raise exception - # since the message no longer exists. - # - # In addition, to avoid sending two notifications to the user, the - # logs, and mod_alert, we return if the message no longer exists. - await msg.delete() - except discord.errors.NotFound: - return - - # Notify the user if the filter specifies - if _filter["user_notification"]: - await self.notify_member(msg.author, _filter["notification_msg"], msg.channel) - - # If the message is classed as offensive, we store it in the site db and - # it will be deleted it after one week. - if _filter["schedule_deletion"] and not is_private: - delete_date = (msg.created_at + OFFENSIVE_MSG_DELETE_TIME).isoformat() - data = { - 'id': msg.id, - 'channel_id': msg.channel.id, - 'delete_date': delete_date - } - - await self.bot.api_client.post('bot/offensive-messages', json=data) - self.schedule_msg_delete(data) - log.trace(f"Offensive message {msg.id} will be deleted on {delete_date}") - - if is_private: - channel_str = "via DM" - else: - channel_str = f"in {msg.channel.mention}" - - message_content, additional_embeds, additional_embeds_msg = self._add_stats( - filter_name, match, msg.content - ) - - message = ( - f"The {filter_name} {_filter['type']} was triggered " - f"by **{msg.author}** " - f"(`{msg.author.id}`) {channel_str} with [the " - f"following message]({msg.jump_url}):\n\n" - f"{message_content}" - ) - - log.debug(message) - - # Send pretty mod log embed to mod-alerts - await self.mod_log.send_log_message( - icon_url=Icons.filtering, - colour=Colour(Colours.soft_red), - title=f"{_filter['type'].title()} triggered!", - text=message, - thumbnail=msg.author.avatar_url_as(static_format="png"), - channel_id=Channels.mod_alerts, - ping_everyone=Filter.ping_everyone if not is_private else False, - additional_embeds=additional_embeds, - additional_embeds_msg=additional_embeds_msg - ) - - break # We don't want multiple filters to trigger - - def _add_stats(self, name: str, match: Union[re.Match, dict, bool, List[discord.Embed]], content: str) -> Tuple[ - str, Optional[List[discord.Embed]], Optional[str] - ]: - """Adds relevant statistical information to the relevant filter and increments the bot's stats.""" - # Word and match stats for watch_regex - if name == "watch_regex": - surroundings = match.string[max(match.start() - 10, 0): match.end() + 10] - message_content = ( - f"**Match:** '{match[0]}'\n" - f"**Location:** '...{escape_markdown(surroundings)}...'\n" - f"\n**Original Message:**\n{escape_markdown(content)}" - ) - else: # Use original content - message_content = content - - additional_embeds = None - additional_embeds_msg = None - - self.bot.stats.incr(f"filters.{name}") - - # The function returns True for invalid invites. - # They have no data so additional embeds can't be created for them. - if name == "filter_invites" and match is not True: - additional_embeds = [] - for _, data in match.items(): - embed = discord.Embed(description=( - f"**Members:**\n{data['members']}\n" - f"**Active:**\n{data['active']}" - )) - embed.set_author(name=data["name"]) - embed.set_thumbnail(url=data["icon"]) - embed.set_footer(text=f"Guild ID: {data['id']}") - additional_embeds.append(embed) - additional_embeds_msg = "For the following guild(s):" - - elif name == "watch_rich_embeds": - additional_embeds = match - additional_embeds_msg = "With the following embed(s):" - - return message_content, additional_embeds, additional_embeds_msg - - @staticmethod - def _check_filter(msg: Message) -> bool: - """Check whitelists to see if we should filter this message.""" - role_whitelisted = False - - if type(msg.author) is Member: # Only Member has roles, not User. - for role in msg.author.roles: - if role.id in Filter.role_whitelist: - role_whitelisted = True - - return ( - msg.channel.id not in Filter.channel_whitelist # Channel not in whitelist - and not role_whitelisted # Role not in whitelist - and not msg.author.bot # Author not a bot - ) - - async def _has_watch_regex_match(self, text: str) -> Union[bool, re.Match]: - """ - Return True if `text` matches any regex from `word_watchlist` or `token_watchlist` configs. - - `word_watchlist`'s patterns are placed between word boundaries while `token_watchlist` is - matched as-is. Spoilers are expanded, if any, and URLs are ignored. - """ - if SPOILER_RE.search(text): - text = self._expand_spoilers(text) - - # Make sure it's not a URL - if URL_RE.search(text): - return False - - watchlist_patterns = self._get_filterlist_items('filter_token', allowed=False) - for pattern in watchlist_patterns: - match = re.search(pattern, text, flags=re.IGNORECASE) - if match: - return match - - async def _has_urls(self, text: str) -> bool: - """Returns True if the text contains one of the blacklisted URLs from the config file.""" - if not URL_RE.search(text): - return False - - text = text.lower() - domain_blacklist = self._get_filterlist_items("domain_name", allowed=False) - - for url in domain_blacklist: - if url.lower() in text: - return True - - return False - - @staticmethod - async def _has_zalgo(text: str) -> bool: - """ - Returns True if the text contains zalgo characters. - - Zalgo range is \u0300 – \u036F and \u0489. - """ - return bool(ZALGO_RE.search(text)) - - async def _has_invites(self, text: str) -> Union[dict, bool]: - """ - Checks if there's any invites in the text content that aren't in the guild whitelist. - - If any are detected, a dictionary of invite data is returned, with a key per invite. - If none are detected, False is returned. - - Attempts to catch some of common ways to try to cheat the system. - """ - # Remove backslashes to prevent escape character aroundfuckery like - # discord\.gg/gdudes-pony-farm - text = text.replace("\\", "") - - invites = INVITE_RE.findall(text) - invite_data = dict() - for invite in invites: - if invite in invite_data: - continue - - response = await self.bot.http_session.get( - f"{URLs.discord_invite_api}/{invite}", params={"with_counts": "true"} - ) - response = await response.json() - guild = response.get("guild") - if guild is None: - # Lack of a "guild" key in the JSON response indicates either an group DM invite, an - # expired invite, or an invalid invite. The API does not currently differentiate - # between invalid and expired invites - return True - - guild_id = guild.get("id") - guild_invite_whitelist = self._get_filterlist_items("guild_invite", allowed=True) - guild_invite_blacklist = self._get_filterlist_items("guild_invite", allowed=False) - - # Is this invite allowed? - guild_partnered_or_verified = ( - 'PARTNERED' in guild.get("features", []) - or 'VERIFIED' in guild.get("features", []) - ) - invite_not_allowed = ( - guild_id in guild_invite_blacklist # Blacklisted guilds are never permitted. - or guild_id not in guild_invite_whitelist # Whitelisted guilds are always permitted. - and not guild_partnered_or_verified # Otherwise guilds have to be Verified or Partnered. - ) - - if invite_not_allowed: - guild_icon_hash = guild["icon"] - guild_icon = ( - "https://cdn.discordapp.com/icons/" - f"{guild_id}/{guild_icon_hash}.png?size=512" - ) - - invite_data[invite] = { - "name": guild["name"], - "id": guild['id'], - "icon": guild_icon, - "members": response["approximate_member_count"], - "active": response["approximate_presence_count"] - } - - return invite_data if invite_data else False - - @staticmethod - async def _has_rich_embed(msg: Message) -> Union[bool, List[discord.Embed]]: - """Determines if `msg` contains any rich embeds not auto-generated from a URL.""" - if msg.embeds: - for embed in msg.embeds: - if embed.type == "rich": - urls = URL_RE.findall(msg.content) - if not embed.url or embed.url not in urls: - # If `embed.url` does not exist or if `embed.url` is not part of the content - # of the message, it's unlikely to be an auto-generated embed by Discord. - return msg.embeds - else: - log.trace( - "Found a rich embed sent by a regular user account, " - "but it was likely just an automatic URL embed." - ) - return False - return False - - async def notify_member(self, filtered_member: Member, reason: str, channel: TextChannel) -> None: - """ - Notify filtered_member about a moderation action with the reason str. - - First attempts to DM the user, fall back to in-channel notification if user has DMs disabled - """ - try: - await filtered_member.send(reason) - except discord.errors.Forbidden: - await channel.send(f"{filtered_member.mention} {reason}") - - def schedule_msg_delete(self, msg: dict) -> None: - """Delete an offensive message once its deletion date is reached.""" - delete_at = dateutil.parser.isoparse(msg['delete_date']).replace(tzinfo=None) - self.scheduler.schedule_at(delete_at, msg['id'], self.delete_offensive_msg(msg)) - - async def reschedule_offensive_msg_deletion(self) -> None: - """Get all the pending message deletion from the API and reschedule them.""" - await self.bot.wait_until_ready() - response = await self.bot.api_client.get('bot/offensive-messages',) - - now = datetime.utcnow() - - for msg in response: - delete_at = dateutil.parser.isoparse(msg['delete_date']).replace(tzinfo=None) - - if delete_at < now: - await self.delete_offensive_msg(msg) - else: - self.schedule_msg_delete(msg) - - async def delete_offensive_msg(self, msg: Mapping[str, str]) -> None: - """Delete an offensive message, and then delete it from the db.""" - try: - channel = self.bot.get_channel(msg['channel_id']) - if channel: - msg_obj = await channel.fetch_message(msg['id']) - await msg_obj.delete() - except NotFound: - log.info( - f"Tried to delete message {msg['id']}, but the message can't be found " - f"(it has been probably already deleted)." - ) - except HTTPException as e: - log.warning(f"Failed to delete message {msg['id']}: status {e.status}") - - await self.bot.api_client.delete(f'bot/offensive-messages/{msg["id"]}') - log.info(f"Deleted the offensive message with id {msg['id']}.") - - -def setup(bot: Bot) -> None: - """Load the Filtering cog.""" - bot.add_cog(Filtering(bot)) diff --git a/bot/cogs/filters/__init__.py b/bot/cogs/filters/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bot/cogs/filters/antimalware.py b/bot/cogs/filters/antimalware.py new file mode 100644 index 000000000..c76bd2c60 --- /dev/null +++ b/bot/cogs/filters/antimalware.py @@ -0,0 +1,98 @@ +import logging +import typing as t +from os.path import splitext + +from discord import Embed, Message, NotFound +from discord.ext.commands import Cog + +from bot.bot import Bot +from bot.constants import Channels, STAFF_ROLES, URLs + +log = logging.getLogger(__name__) + +PY_EMBED_DESCRIPTION = ( + "It looks like you tried to attach a Python file - " + f"please use a code-pasting service such as {URLs.site_schema}{URLs.site_paste}" +) + +TXT_EMBED_DESCRIPTION = ( + "**Uh-oh!** It looks like your message got zapped by our spam filter. " + "We currently don't allow `.txt` attachments, so here are some tips to help you travel safely: \n\n" + "• If you attempted to send a message longer than 2000 characters, try shortening your message " + "to fit within the character limit or use a pasting service (see below) \n\n" + "• If you tried to show someone your code, you can use codeblocks \n(run `!code-blocks` in " + "{cmd_channel_mention} for more information) or use a pasting service like: " + f"\n\n{URLs.site_schema}{URLs.site_paste}" +) + +DISALLOWED_EMBED_DESCRIPTION = ( + "It looks like you tried to attach file type(s) that we do not allow ({blocked_extensions_str}). " + "We currently allow the following file types: **{joined_whitelist}**.\n\n" + "Feel free to ask in {meta_channel_mention} if you think this is a mistake." +) + + +class AntiMalware(Cog): + """Delete messages which contain attachments with non-whitelisted file extensions.""" + + def __init__(self, bot: Bot): + self.bot = bot + + def _get_whitelisted_file_formats(self) -> list: + """Get the file formats currently on the whitelist.""" + return self.bot.filter_list_cache['FILE_FORMAT.True'].keys() + + def _get_disallowed_extensions(self, message: Message) -> t.Iterable[str]: + """Get an iterable containing all the disallowed extensions of attachments.""" + file_extensions = {splitext(attachment.filename.lower())[1] for attachment in message.attachments} + extensions_blocked = file_extensions - set(self._get_whitelisted_file_formats()) + return extensions_blocked + + @Cog.listener() + async def on_message(self, message: Message) -> None: + """Identify messages with prohibited attachments.""" + # Return when message don't have attachment and don't moderate DMs + if not message.attachments or not message.guild: + return + + # Check if user is staff, if is, return + # Since we only care that roles exist to iterate over, check for the attr rather than a User/Member instance + if hasattr(message.author, "roles") and any(role.id in STAFF_ROLES for role in message.author.roles): + return + + embed = Embed() + extensions_blocked = self._get_disallowed_extensions(message) + blocked_extensions_str = ', '.join(extensions_blocked) + if ".py" in extensions_blocked: + # Short-circuit on *.py files to provide a pastebin link + embed.description = PY_EMBED_DESCRIPTION + elif ".txt" in extensions_blocked: + # Work around Discord AutoConversion of messages longer than 2000 chars to .txt + cmd_channel = self.bot.get_channel(Channels.bot_commands) + embed.description = TXT_EMBED_DESCRIPTION.format(cmd_channel_mention=cmd_channel.mention) + elif extensions_blocked: + meta_channel = self.bot.get_channel(Channels.meta) + embed.description = DISALLOWED_EMBED_DESCRIPTION.format( + joined_whitelist=', '.join(self._get_whitelisted_file_formats()), + blocked_extensions_str=blocked_extensions_str, + meta_channel_mention=meta_channel.mention, + ) + + if embed.description: + log.info( + f"User '{message.author}' ({message.author.id}) uploaded blacklisted file(s): {blocked_extensions_str}", + extra={"attachment_list": [attachment.filename for attachment in message.attachments]} + ) + + await message.channel.send(f"Hey {message.author.mention}!", embed=embed) + + # Delete the offending message: + try: + await message.delete() + except NotFound: + log.info(f"Tried to delete message `{message.id}`, but message could not be found.") + + +def setup(bot: Bot) -> None: + """Load the AntiMalware cog.""" + bot.add_cog(AntiMalware(bot)) diff --git a/bot/cogs/filters/antispam.py b/bot/cogs/filters/antispam.py new file mode 100644 index 000000000..0bcca578d --- /dev/null +++ b/bot/cogs/filters/antispam.py @@ -0,0 +1,288 @@ +import asyncio +import logging +from collections.abc import Mapping +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from operator import itemgetter +from typing import Dict, Iterable, List, Set + +from discord import Colour, Member, Message, NotFound, Object, TextChannel +from discord.ext.commands import Cog + +from bot import rules +from bot.bot import Bot +from bot.cogs.moderation import ModLog +from bot.constants import ( + AntiSpam as AntiSpamConfig, Channels, + Colours, DEBUG_MODE, Event, Filter, + Guild as GuildConfig, Icons, + STAFF_ROLES, +) +from bot.converters import Duration +from bot.utils.messages import send_attachments + + +log = logging.getLogger(__name__) + +RULE_FUNCTION_MAPPING = { + 'attachments': rules.apply_attachments, + 'burst': rules.apply_burst, + 'burst_shared': rules.apply_burst_shared, + 'chars': rules.apply_chars, + 'discord_emojis': rules.apply_discord_emojis, + 'duplicates': rules.apply_duplicates, + 'links': rules.apply_links, + 'mentions': rules.apply_mentions, + 'newlines': rules.apply_newlines, + 'role_mentions': rules.apply_role_mentions +} + + +@dataclass +class DeletionContext: + """Represents a Deletion Context for a single spam event.""" + + channel: TextChannel + members: Dict[int, Member] = field(default_factory=dict) + rules: Set[str] = field(default_factory=set) + messages: Dict[int, Message] = field(default_factory=dict) + attachments: List[List[str]] = field(default_factory=list) + + async def add(self, rule_name: str, members: Iterable[Member], messages: Iterable[Message]) -> None: + """Adds new rule violation events to the deletion context.""" + self.rules.add(rule_name) + + for member in members: + if member.id not in self.members: + self.members[member.id] = member + + for message in messages: + if message.id not in self.messages: + self.messages[message.id] = message + + # Re-upload attachments + destination = message.guild.get_channel(Channels.attachment_log) + urls = await send_attachments(message, destination, link_large=False) + self.attachments.append(urls) + + async def upload_messages(self, actor_id: int, modlog: ModLog) -> None: + """Method that takes care of uploading the queue and posting modlog alert.""" + triggered_by_users = ", ".join(f"{m} (`{m.id}`)" for m in self.members.values()) + + mod_alert_message = ( + f"**Triggered by:** {triggered_by_users}\n" + f"**Channel:** {self.channel.mention}\n" + f"**Rules:** {', '.join(rule for rule in self.rules)}\n" + ) + + # For multiple messages or those with excessive newlines, use the logs API + if len(self.messages) > 1 or 'newlines' in self.rules: + url = await modlog.upload_log(self.messages.values(), actor_id, self.attachments) + mod_alert_message += f"A complete log of the offending messages can be found [here]({url})" + else: + mod_alert_message += "Message:\n" + [message] = self.messages.values() + content = message.clean_content + remaining_chars = 2040 - len(mod_alert_message) + + if len(content) > remaining_chars: + content = content[:remaining_chars] + "..." + + mod_alert_message += f"{content}" + + *_, last_message = self.messages.values() + await modlog.send_log_message( + icon_url=Icons.filtering, + colour=Colour(Colours.soft_red), + title="Spam detected!", + text=mod_alert_message, + thumbnail=last_message.author.avatar_url_as(static_format="png"), + channel_id=Channels.mod_alerts, + ping_everyone=AntiSpamConfig.ping_everyone + ) + + +class AntiSpam(Cog): + """Cog that controls our anti-spam measures.""" + + def __init__(self, bot: Bot, validation_errors: Dict[str, str]) -> None: + self.bot = bot + self.validation_errors = validation_errors + role_id = AntiSpamConfig.punishment['role_id'] + self.muted_role = Object(role_id) + self.expiration_date_converter = Duration() + + self.message_deletion_queue = dict() + + self.bot.loop.create_task(self.alert_on_validation_error()) + + @property + def mod_log(self) -> ModLog: + """Allows for easy access of the ModLog cog.""" + return self.bot.get_cog("ModLog") + + async def alert_on_validation_error(self) -> None: + """Unloads the cog and alerts admins if configuration validation failed.""" + await self.bot.wait_until_guild_available() + if self.validation_errors: + body = "**The following errors were encountered:**\n" + body += "\n".join(f"- {error}" for error in self.validation_errors.values()) + body += "\n\n**The cog has been unloaded.**" + + await self.mod_log.send_log_message( + title="Error: AntiSpam configuration validation failed!", + text=body, + ping_everyone=True, + icon_url=Icons.token_removed, + colour=Colour.red() + ) + + self.bot.remove_cog(self.__class__.__name__) + return + + @Cog.listener() + async def on_message(self, message: Message) -> None: + """Applies the antispam rules to each received message.""" + if ( + not message.guild + or message.guild.id != GuildConfig.id + or message.author.bot + or (message.channel.id in Filter.channel_whitelist and not DEBUG_MODE) + or (any(role.id in STAFF_ROLES for role in message.author.roles) and not DEBUG_MODE) + ): + return + + # Fetch the rule configuration with the highest rule interval. + max_interval_config = max( + AntiSpamConfig.rules.values(), + key=itemgetter('interval') + ) + max_interval = max_interval_config['interval'] + + # Store history messages since `interval` seconds ago in a list to prevent unnecessary API calls. + earliest_relevant_at = datetime.utcnow() - timedelta(seconds=max_interval) + relevant_messages = [ + msg async for msg in message.channel.history(after=earliest_relevant_at, oldest_first=False) + if not msg.author.bot + ] + + for rule_name in AntiSpamConfig.rules: + rule_config = AntiSpamConfig.rules[rule_name] + rule_function = RULE_FUNCTION_MAPPING[rule_name] + + # Create a list of messages that were sent in the interval that the rule cares about. + latest_interesting_stamp = datetime.utcnow() - timedelta(seconds=rule_config['interval']) + messages_for_rule = [ + msg for msg in relevant_messages if msg.created_at > latest_interesting_stamp + ] + result = await rule_function(message, messages_for_rule, rule_config) + + # If the rule returns `None`, that means the message didn't violate it. + # If it doesn't, it returns a tuple in the form `(str, Iterable[discord.Member])` + # which contains the reason for why the message violated the rule and + # an iterable of all members that violated the rule. + if result is not None: + self.bot.stats.incr(f"mod_alerts.{rule_name}") + reason, members, relevant_messages = result + full_reason = f"`{rule_name}` rule: {reason}" + + # If there's no spam event going on for this channel, start a new Message Deletion Context + channel = message.channel + if channel.id not in self.message_deletion_queue: + log.trace(f"Creating queue for channel `{channel.id}`") + self.message_deletion_queue[message.channel.id] = DeletionContext(channel) + self.bot.loop.create_task(self._process_deletion_context(message.channel.id)) + + # Add the relevant of this trigger to the Deletion Context + await self.message_deletion_queue[message.channel.id].add( + rule_name=rule_name, + members=members, + messages=relevant_messages + ) + + for member in members: + + # Fire it off as a background task to ensure + # that the sleep doesn't block further tasks + self.bot.loop.create_task( + self.punish(message, member, full_reason) + ) + + await self.maybe_delete_messages(channel, relevant_messages) + break + + async def punish(self, msg: Message, member: Member, reason: str) -> None: + """Punishes the given member for triggering an antispam rule.""" + if not any(role.id == self.muted_role.id for role in member.roles): + remove_role_after = AntiSpamConfig.punishment['remove_after'] + + # Get context and make sure the bot becomes the actor of infraction by patching the `author` attributes + context = await self.bot.get_context(msg) + context.author = self.bot.user + context.message.author = self.bot.user + + # Since we're going to invoke the tempmute command directly, we need to manually call the converter. + dt_remove_role_after = await self.expiration_date_converter.convert(context, f"{remove_role_after}S") + await context.invoke( + self.bot.get_command('tempmute'), + member, + dt_remove_role_after, + reason=reason + ) + + async def maybe_delete_messages(self, channel: TextChannel, messages: List[Message]) -> None: + """Cleans the messages if cleaning is configured.""" + if AntiSpamConfig.clean_offending: + # If we have more than one message, we can use bulk delete. + if len(messages) > 1: + message_ids = [message.id for message in messages] + self.mod_log.ignore(Event.message_delete, *message_ids) + await channel.delete_messages(messages) + + # Otherwise, the bulk delete endpoint will throw up. + # Delete the message directly instead. + else: + self.mod_log.ignore(Event.message_delete, messages[0].id) + try: + await messages[0].delete() + except NotFound: + log.info(f"Tried to delete message `{messages[0].id}`, but message could not be found.") + + async def _process_deletion_context(self, context_id: int) -> None: + """Processes the Deletion Context queue.""" + log.trace("Sleeping before processing message deletion queue.") + await asyncio.sleep(10) + + if context_id not in self.message_deletion_queue: + log.error(f"Started processing deletion queue for context `{context_id}`, but it was not found!") + return + + deletion_context = self.message_deletion_queue.pop(context_id) + await deletion_context.upload_messages(self.bot.user.id, self.mod_log) + + +def validate_config(rules_: Mapping = AntiSpamConfig.rules) -> Dict[str, str]: + """Validates the antispam configs.""" + validation_errors = {} + for name, config in rules_.items(): + if name not in RULE_FUNCTION_MAPPING: + log.error( + f"Unrecognized antispam rule `{name}`. " + f"Valid rules are: {', '.join(RULE_FUNCTION_MAPPING)}" + ) + validation_errors[name] = f"`{name}` is not recognized as an antispam rule." + continue + for required_key in ('interval', 'max'): + if required_key not in config: + log.error( + f"`{required_key}` is required but was not " + f"set in rule `{name}`'s configuration." + ) + validation_errors[name] = f"Key `{required_key}` is required but not set for rule `{name}`" + return validation_errors + + +def setup(bot: Bot) -> None: + """Validate the AntiSpam configs and load the AntiSpam cog.""" + validation_errors = validate_config() + bot.add_cog(AntiSpam(bot, validation_errors)) diff --git a/bot/cogs/filters/filter_lists.py b/bot/cogs/filters/filter_lists.py new file mode 100644 index 000000000..c15adc461 --- /dev/null +++ b/bot/cogs/filters/filter_lists.py @@ -0,0 +1,273 @@ +import logging +from typing import Optional + +from discord import Colour, Embed +from discord.ext.commands import BadArgument, Cog, Context, IDConverter, group + +from bot import constants +from bot.api import ResponseCodeError +from bot.bot import Bot +from bot.converters import ValidDiscordServerInvite, ValidFilterListType +from bot.pagination import LinePaginator +from bot.utils.checks import with_role_check + +log = logging.getLogger(__name__) + + +class FilterLists(Cog): + """Commands for blacklisting and whitelisting things.""" + + methods_with_filterlist_types = [ + "allow_add", + "allow_delete", + "allow_get", + "deny_add", + "deny_delete", + "deny_get", + ] + + def __init__(self, bot: Bot) -> None: + self.bot = bot + self.bot.loop.create_task(self._amend_docstrings()) + + async def _amend_docstrings(self) -> None: + """Add the valid FilterList types to the docstrings, so they'll appear in !help invocations.""" + await self.bot.wait_until_guild_available() + + # Add valid filterlist types to the docstrings + valid_types = await ValidFilterListType.get_valid_types(self.bot) + valid_types = [f"`{type_.lower()}`" for type_ in valid_types] + + for method_name in self.methods_with_filterlist_types: + command = getattr(self, method_name) + command.help = ( + f"{command.help}\n\nValid **list_type** values are {', '.join(valid_types)}." + ) + + async def _add_data( + self, + ctx: Context, + allowed: bool, + list_type: ValidFilterListType, + content: str, + comment: Optional[str] = None, + ) -> None: + """Add an item to a filterlist.""" + allow_type = "whitelist" if allowed else "blacklist" + + # If this is a server invite, we gotta validate it. + if list_type == "GUILD_INVITE": + guild_data = await self._validate_guild_invite(ctx, content) + content = guild_data.get("id") + + # Unless the user has specified another comment, let's + # use the server name as the comment so that the list + # of guild IDs will be more easily readable when we + # display it. + if not comment: + comment = guild_data.get("name") + + # If it's a file format, let's make sure it has a leading dot. + elif list_type == "FILE_FORMAT" and not content.startswith("."): + content = f".{content}" + + # Try to add the item to the database + log.trace(f"Trying to add the {content} item to the {list_type} {allow_type}") + payload = { + "allowed": allowed, + "type": list_type, + "content": content, + "comment": comment, + } + + try: + item = await self.bot.api_client.post( + "bot/filter-lists", + json=payload + ) + except ResponseCodeError as e: + if e.status == 400: + await ctx.message.add_reaction("❌") + log.debug( + f"{ctx.author} tried to add data to a {allow_type}, but the API returned 400, " + "probably because the request violated the UniqueConstraint." + ) + raise BadArgument( + f"Unable to add the item to the {allow_type}. " + "The item probably already exists. Keep in mind that a " + "blacklist and a whitelist for the same item cannot co-exist, " + "and we do not permit any duplicates." + ) + raise + + # Insert the item into the cache + self.bot.insert_item_into_filter_list_cache(item) + await ctx.message.add_reaction("✅") + + async def _delete_data(self, ctx: Context, allowed: bool, list_type: ValidFilterListType, content: str) -> None: + """Remove an item from a filterlist.""" + allow_type = "whitelist" if allowed else "blacklist" + + # If this is a server invite, we need to convert it. + if list_type == "GUILD_INVITE" and not IDConverter()._get_id_match(content): + guild_data = await self._validate_guild_invite(ctx, content) + content = guild_data.get("id") + + # If it's a file format, let's make sure it has a leading dot. + elif list_type == "FILE_FORMAT" and not content.startswith("."): + content = f".{content}" + + # Find the content and delete it. + log.trace(f"Trying to delete the {content} item from the {list_type} {allow_type}") + item = self.bot.filter_list_cache[f"{list_type}.{allowed}"].get(content) + + if item is not None: + try: + await self.bot.api_client.delete( + f"bot/filter-lists/{item['id']}" + ) + del self.bot.filter_list_cache[f"{list_type}.{allowed}"][content] + await ctx.message.add_reaction("✅") + except ResponseCodeError as e: + log.debug( + f"{ctx.author} tried to delete an item with the id {item['id']}, but " + f"the API raised an unexpected error: {e}" + ) + await ctx.message.add_reaction("❌") + else: + await ctx.message.add_reaction("❌") + + async def _list_all_data(self, ctx: Context, allowed: bool, list_type: ValidFilterListType) -> None: + """Paginate and display all items in a filterlist.""" + allow_type = "whitelist" if allowed else "blacklist" + result = self.bot.filter_list_cache[f"{list_type}.{allowed}"] + + # Build a list of lines we want to show in the paginator + lines = [] + for content, metadata in result.items(): + line = f"• `{content}`" + + if comment := metadata.get("comment"): + line += f" - {comment}" + + lines.append(line) + lines = sorted(lines) + + # Build the embed + list_type_plural = list_type.lower().replace("_", " ").title() + "s" + embed = Embed( + title=f"{allow_type.title()}ed {list_type_plural} ({len(result)} total)", + colour=Colour.blue() + ) + log.trace(f"Trying to list {len(result)} items from the {list_type.lower()} {allow_type}") + + if result: + await LinePaginator.paginate(lines, ctx, embed, max_lines=15, empty=False) + else: + embed.description = "Hmmm, seems like there's nothing here yet." + await ctx.send(embed=embed) + await ctx.message.add_reaction("❌") + + async def _sync_data(self, ctx: Context) -> None: + """Syncs the filterlists with the API.""" + try: + log.trace("Attempting to sync FilterList cache with data from the API.") + await self.bot.cache_filter_list_data() + await ctx.message.add_reaction("✅") + except ResponseCodeError as e: + log.debug( + f"{ctx.author} tried to sync FilterList cache data but " + f"the API raised an unexpected error: {e}" + ) + await ctx.message.add_reaction("❌") + + @staticmethod + async def _validate_guild_invite(ctx: Context, invite: str) -> dict: + """ + Validates a guild invite, and returns the guild info as a dict. + + Will raise a BadArgument if the guild invite is invalid. + """ + log.trace(f"Attempting to validate whether or not {invite} is a guild invite.") + validator = ValidDiscordServerInvite() + guild_data = await validator.convert(ctx, invite) + + # If we make it this far without raising a BadArgument, the invite is + # valid. Let's return a dict of guild information. + log.trace(f"{invite} validated as server invite. Converting to ID.") + return guild_data + + @group(aliases=("allowlist", "allow", "al", "wl")) + async def whitelist(self, ctx: Context) -> None: + """Group for whitelisting commands.""" + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) + + @group(aliases=("denylist", "deny", "bl", "dl")) + async def blacklist(self, ctx: Context) -> None: + """Group for blacklisting commands.""" + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) + + @whitelist.command(name="add", aliases=("a", "set")) + async def allow_add( + self, + ctx: Context, + list_type: ValidFilterListType, + content: str, + *, + comment: Optional[str] = None, + ) -> None: + """Add an item to the specified allowlist.""" + await self._add_data(ctx, True, list_type, content, comment) + + @blacklist.command(name="add", aliases=("a", "set")) + async def deny_add( + self, + ctx: Context, + list_type: ValidFilterListType, + content: str, + *, + comment: Optional[str] = None, + ) -> None: + """Add an item to the specified denylist.""" + await self._add_data(ctx, False, list_type, content, comment) + + @whitelist.command(name="remove", aliases=("delete", "rm",)) + async def allow_delete(self, ctx: Context, list_type: ValidFilterListType, content: str) -> None: + """Remove an item from the specified allowlist.""" + await self._delete_data(ctx, True, list_type, content) + + @blacklist.command(name="remove", aliases=("delete", "rm",)) + async def deny_delete(self, ctx: Context, list_type: ValidFilterListType, content: str) -> None: + """Remove an item from the specified denylist.""" + await self._delete_data(ctx, False, list_type, content) + + @whitelist.command(name="get", aliases=("list", "ls", "fetch", "show")) + async def allow_get(self, ctx: Context, list_type: ValidFilterListType) -> None: + """Get the contents of a specified allowlist.""" + await self._list_all_data(ctx, True, list_type) + + @blacklist.command(name="get", aliases=("list", "ls", "fetch", "show")) + async def deny_get(self, ctx: Context, list_type: ValidFilterListType) -> None: + """Get the contents of a specified denylist.""" + await self._list_all_data(ctx, False, list_type) + + @whitelist.command(name="sync", aliases=("s",)) + async def allow_sync(self, ctx: Context) -> None: + """Syncs both allowlists and denylists with the API.""" + await self._sync_data(ctx) + + @blacklist.command(name="sync", aliases=("s",)) + async def deny_sync(self, ctx: Context) -> None: + """Syncs both allowlists and denylists with the API.""" + await self._sync_data(ctx) + + def cog_check(self, ctx: Context) -> bool: + """Only allow moderators to invoke the commands in this cog.""" + return with_role_check(ctx, *constants.MODERATION_ROLES) + + +def setup(bot: Bot) -> None: + """Load the FilterLists cog.""" + bot.add_cog(FilterLists(bot)) diff --git a/bot/cogs/filters/filtering.py b/bot/cogs/filters/filtering.py new file mode 100644 index 000000000..93cc1c655 --- /dev/null +++ b/bot/cogs/filters/filtering.py @@ -0,0 +1,575 @@ +import asyncio +import logging +import re +from datetime import datetime, timedelta +from typing import List, Mapping, Optional, Tuple, Union + +import dateutil +import discord.errors +from dateutil.relativedelta import relativedelta +from discord import Colour, HTTPException, Member, Message, NotFound, TextChannel +from discord.ext.commands import Cog +from discord.utils import escape_markdown + +from bot.bot import Bot +from bot.cogs.moderation import ModLog +from bot.constants import ( + Channels, Colours, + Filter, Icons, URLs +) +from bot.utils.redis_cache import RedisCache +from bot.utils.regex import INVITE_RE +from bot.utils.scheduling import Scheduler + +log = logging.getLogger(__name__) + +# Regular expressions +SPOILER_RE = re.compile(r"(\|\|.+?\|\|)", re.DOTALL) +URL_RE = re.compile(r"(https?://[^\s]+)", flags=re.IGNORECASE) +ZALGO_RE = re.compile(r"[\u0300-\u036F\u0489]") + +# Other constants. +DAYS_BETWEEN_ALERTS = 3 +OFFENSIVE_MSG_DELETE_TIME = timedelta(days=Filter.offensive_msg_delete_days) + + +class Filtering(Cog): + """Filtering out invites, blacklisting domains, and warning us of certain regular expressions.""" + + # Redis cache mapping a user ID to the last timestamp a bad nickname alert was sent + name_alerts = RedisCache() + + def __init__(self, bot: Bot): + self.bot = bot + self.scheduler = Scheduler(self.__class__.__name__) + self.name_lock = asyncio.Lock() + + staff_mistake_str = "If you believe this was a mistake, please let staff know!" + self.filters = { + "filter_zalgo": { + "enabled": Filter.filter_zalgo, + "function": self._has_zalgo, + "type": "filter", + "content_only": True, + "user_notification": Filter.notify_user_zalgo, + "notification_msg": ( + "Your post has been removed for abusing Unicode character rendering (aka Zalgo text). " + f"{staff_mistake_str}" + ), + "schedule_deletion": False + }, + "filter_invites": { + "enabled": Filter.filter_invites, + "function": self._has_invites, + "type": "filter", + "content_only": True, + "user_notification": Filter.notify_user_invites, + "notification_msg": ( + f"Per Rule 6, your invite link has been removed. {staff_mistake_str}\n\n" + r"Our server rules can be found here: " + ), + "schedule_deletion": False + }, + "filter_domains": { + "enabled": Filter.filter_domains, + "function": self._has_urls, + "type": "filter", + "content_only": True, + "user_notification": Filter.notify_user_domains, + "notification_msg": ( + f"Your URL has been removed because it matched a blacklisted domain. {staff_mistake_str}" + ), + "schedule_deletion": False + }, + "watch_regex": { + "enabled": Filter.watch_regex, + "function": self._has_watch_regex_match, + "type": "watchlist", + "content_only": True, + "schedule_deletion": True + }, + "watch_rich_embeds": { + "enabled": Filter.watch_rich_embeds, + "function": self._has_rich_embed, + "type": "watchlist", + "content_only": False, + "schedule_deletion": False + } + } + + self.bot.loop.create_task(self.reschedule_offensive_msg_deletion()) + + def cog_unload(self) -> None: + """Cancel scheduled tasks.""" + self.scheduler.cancel_all() + + def _get_filterlist_items(self, list_type: str, *, allowed: bool) -> list: + """Fetch items from the filter_list_cache.""" + return self.bot.filter_list_cache[f"{list_type.upper()}.{allowed}"].keys() + + @staticmethod + def _expand_spoilers(text: str) -> str: + """Return a string containing all interpretations of a spoilered message.""" + split_text = SPOILER_RE.split(text) + return ''.join( + split_text[0::2] + split_text[1::2] + split_text + ) + + @property + def mod_log(self) -> ModLog: + """Get currently loaded ModLog cog instance.""" + return self.bot.get_cog("ModLog") + + @Cog.listener() + async def on_message(self, msg: Message) -> None: + """Invoke message filter for new messages.""" + await self._filter_message(msg) + + # Ignore webhook messages. + if msg.webhook_id is None: + await self.check_bad_words_in_name(msg.author) + + @Cog.listener() + async def on_message_edit(self, before: Message, after: Message) -> None: + """ + Invoke message filter for message edits. + + If there have been multiple edits, calculate the time delta from the previous edit. + """ + if not before.edited_at: + delta = relativedelta(after.edited_at, before.created_at).microseconds + else: + delta = relativedelta(after.edited_at, before.edited_at).microseconds + await self._filter_message(after, delta) + + def get_name_matches(self, name: str) -> List[re.Match]: + """Check bad words from passed string (name). Return list of matches.""" + matches = [] + watchlist_patterns = self._get_filterlist_items('filter_token', allowed=False) + for pattern in watchlist_patterns: + if match := re.search(pattern, name, flags=re.IGNORECASE): + matches.append(match) + return matches + + async def check_send_alert(self, member: Member) -> bool: + """When there is less than 3 days after last alert, return `False`, otherwise `True`.""" + if last_alert := await self.name_alerts.get(member.id): + last_alert = datetime.utcfromtimestamp(last_alert) + if datetime.utcnow() - timedelta(days=DAYS_BETWEEN_ALERTS) < last_alert: + log.trace(f"Last alert was too recent for {member}'s nickname.") + return False + + return True + + async def check_bad_words_in_name(self, member: Member) -> None: + """Send a mod alert every 3 days if a username still matches a watchlist pattern.""" + # Use lock to avoid race conditions + async with self.name_lock: + # Check whether the users display name contains any words in our blacklist + matches = self.get_name_matches(member.display_name) + + if not matches or not await self.check_send_alert(member): + return + + log.info(f"Sending bad nickname alert for '{member.display_name}' ({member.id}).") + + log_string = ( + f"**User:** {member.mention} (`{member.id}`)\n" + f"**Display Name:** {member.display_name}\n" + f"**Bad Matches:** {', '.join(match.group() for match in matches)}" + ) + + await self.mod_log.send_log_message( + icon_url=Icons.token_removed, + colour=Colours.soft_red, + title="Username filtering alert", + text=log_string, + channel_id=Channels.mod_alerts, + thumbnail=member.avatar_url + ) + + # Update time when alert sent + await self.name_alerts.set(member.id, datetime.utcnow().timestamp()) + + async def filter_eval(self, result: str, msg: Message) -> bool: + """ + Filter the result of an !eval to see if it violates any of our rules, and then respond accordingly. + + Also requires the original message, to check whether to filter and for mod logs. + Returns whether a filter was triggered or not. + """ + filter_triggered = False + # Should we filter this message? + if self._check_filter(msg): + for filter_name, _filter in self.filters.items(): + # Is this specific filter enabled in the config? + # We also do not need to worry about filters that take the full message, + # since all we have is an arbitrary string. + if _filter["enabled"] and _filter["content_only"]: + match = await _filter["function"](result) + + if match: + # If this is a filter (not a watchlist), we set the variable so we know + # that it has been triggered + if _filter["type"] == "filter": + filter_triggered = True + + # We do not have to check against DM channels since !eval cannot be used there. + channel_str = f"in {msg.channel.mention}" + + message_content, additional_embeds, additional_embeds_msg = self._add_stats( + filter_name, match, result + ) + + message = ( + f"The {filter_name} {_filter['type']} was triggered " + f"by **{msg.author}** " + f"(`{msg.author.id}`) {channel_str} using !eval with " + f"[the following message]({msg.jump_url}):\n\n" + f"{message_content}" + ) + + log.debug(message) + + # Send pretty mod log embed to mod-alerts + await self.mod_log.send_log_message( + icon_url=Icons.filtering, + colour=Colour(Colours.soft_red), + title=f"{_filter['type'].title()} triggered!", + text=message, + thumbnail=msg.author.avatar_url_as(static_format="png"), + channel_id=Channels.mod_alerts, + ping_everyone=Filter.ping_everyone, + additional_embeds=additional_embeds, + additional_embeds_msg=additional_embeds_msg + ) + + break # We don't want multiple filters to trigger + + return filter_triggered + + async def _filter_message(self, msg: Message, delta: Optional[int] = None) -> None: + """Filter the input message to see if it violates any of our rules, and then respond accordingly.""" + # Should we filter this message? + if self._check_filter(msg): + for filter_name, _filter in self.filters.items(): + # Is this specific filter enabled in the config? + if _filter["enabled"]: + # Double trigger check for the embeds filter + if filter_name == "watch_rich_embeds": + # If the edit delta is less than 0.001 seconds, then we're probably dealing + # with a double filter trigger. + if delta is not None and delta < 100: + continue + + # Does the filter only need the message content or the full message? + if _filter["content_only"]: + match = await _filter["function"](msg.content) + else: + match = await _filter["function"](msg) + + if match: + is_private = msg.channel.type is discord.ChannelType.private + + # If this is a filter (not a watchlist) and not in a DM, delete the message. + if _filter["type"] == "filter" and not is_private: + try: + # Embeds (can?) trigger both the `on_message` and `on_message_edit` + # event handlers, triggering filtering twice for the same message. + # + # If `on_message`-triggered filtering already deleted the message + # then `on_message_edit`-triggered filtering will raise exception + # since the message no longer exists. + # + # In addition, to avoid sending two notifications to the user, the + # logs, and mod_alert, we return if the message no longer exists. + await msg.delete() + except discord.errors.NotFound: + return + + # Notify the user if the filter specifies + if _filter["user_notification"]: + await self.notify_member(msg.author, _filter["notification_msg"], msg.channel) + + # If the message is classed as offensive, we store it in the site db and + # it will be deleted it after one week. + if _filter["schedule_deletion"] and not is_private: + delete_date = (msg.created_at + OFFENSIVE_MSG_DELETE_TIME).isoformat() + data = { + 'id': msg.id, + 'channel_id': msg.channel.id, + 'delete_date': delete_date + } + + await self.bot.api_client.post('bot/offensive-messages', json=data) + self.schedule_msg_delete(data) + log.trace(f"Offensive message {msg.id} will be deleted on {delete_date}") + + if is_private: + channel_str = "via DM" + else: + channel_str = f"in {msg.channel.mention}" + + message_content, additional_embeds, additional_embeds_msg = self._add_stats( + filter_name, match, msg.content + ) + + message = ( + f"The {filter_name} {_filter['type']} was triggered " + f"by **{msg.author}** " + f"(`{msg.author.id}`) {channel_str} with [the " + f"following message]({msg.jump_url}):\n\n" + f"{message_content}" + ) + + log.debug(message) + + # Send pretty mod log embed to mod-alerts + await self.mod_log.send_log_message( + icon_url=Icons.filtering, + colour=Colour(Colours.soft_red), + title=f"{_filter['type'].title()} triggered!", + text=message, + thumbnail=msg.author.avatar_url_as(static_format="png"), + channel_id=Channels.mod_alerts, + ping_everyone=Filter.ping_everyone if not is_private else False, + additional_embeds=additional_embeds, + additional_embeds_msg=additional_embeds_msg + ) + + break # We don't want multiple filters to trigger + + def _add_stats(self, name: str, match: Union[re.Match, dict, bool, List[discord.Embed]], content: str) -> Tuple[ + str, Optional[List[discord.Embed]], Optional[str] + ]: + """Adds relevant statistical information to the relevant filter and increments the bot's stats.""" + # Word and match stats for watch_regex + if name == "watch_regex": + surroundings = match.string[max(match.start() - 10, 0): match.end() + 10] + message_content = ( + f"**Match:** '{match[0]}'\n" + f"**Location:** '...{escape_markdown(surroundings)}...'\n" + f"\n**Original Message:**\n{escape_markdown(content)}" + ) + else: # Use original content + message_content = content + + additional_embeds = None + additional_embeds_msg = None + + self.bot.stats.incr(f"filters.{name}") + + # The function returns True for invalid invites. + # They have no data so additional embeds can't be created for them. + if name == "filter_invites" and match is not True: + additional_embeds = [] + for _, data in match.items(): + embed = discord.Embed(description=( + f"**Members:**\n{data['members']}\n" + f"**Active:**\n{data['active']}" + )) + embed.set_author(name=data["name"]) + embed.set_thumbnail(url=data["icon"]) + embed.set_footer(text=f"Guild ID: {data['id']}") + additional_embeds.append(embed) + additional_embeds_msg = "For the following guild(s):" + + elif name == "watch_rich_embeds": + additional_embeds = match + additional_embeds_msg = "With the following embed(s):" + + return message_content, additional_embeds, additional_embeds_msg + + @staticmethod + def _check_filter(msg: Message) -> bool: + """Check whitelists to see if we should filter this message.""" + role_whitelisted = False + + if type(msg.author) is Member: # Only Member has roles, not User. + for role in msg.author.roles: + if role.id in Filter.role_whitelist: + role_whitelisted = True + + return ( + msg.channel.id not in Filter.channel_whitelist # Channel not in whitelist + and not role_whitelisted # Role not in whitelist + and not msg.author.bot # Author not a bot + ) + + async def _has_watch_regex_match(self, text: str) -> Union[bool, re.Match]: + """ + Return True if `text` matches any regex from `word_watchlist` or `token_watchlist` configs. + + `word_watchlist`'s patterns are placed between word boundaries while `token_watchlist` is + matched as-is. Spoilers are expanded, if any, and URLs are ignored. + """ + if SPOILER_RE.search(text): + text = self._expand_spoilers(text) + + # Make sure it's not a URL + if URL_RE.search(text): + return False + + watchlist_patterns = self._get_filterlist_items('filter_token', allowed=False) + for pattern in watchlist_patterns: + match = re.search(pattern, text, flags=re.IGNORECASE) + if match: + return match + + async def _has_urls(self, text: str) -> bool: + """Returns True if the text contains one of the blacklisted URLs from the config file.""" + if not URL_RE.search(text): + return False + + text = text.lower() + domain_blacklist = self._get_filterlist_items("domain_name", allowed=False) + + for url in domain_blacklist: + if url.lower() in text: + return True + + return False + + @staticmethod + async def _has_zalgo(text: str) -> bool: + """ + Returns True if the text contains zalgo characters. + + Zalgo range is \u0300 – \u036F and \u0489. + """ + return bool(ZALGO_RE.search(text)) + + async def _has_invites(self, text: str) -> Union[dict, bool]: + """ + Checks if there's any invites in the text content that aren't in the guild whitelist. + + If any are detected, a dictionary of invite data is returned, with a key per invite. + If none are detected, False is returned. + + Attempts to catch some of common ways to try to cheat the system. + """ + # Remove backslashes to prevent escape character aroundfuckery like + # discord\.gg/gdudes-pony-farm + text = text.replace("\\", "") + + invites = INVITE_RE.findall(text) + invite_data = dict() + for invite in invites: + if invite in invite_data: + continue + + response = await self.bot.http_session.get( + f"{URLs.discord_invite_api}/{invite}", params={"with_counts": "true"} + ) + response = await response.json() + guild = response.get("guild") + if guild is None: + # Lack of a "guild" key in the JSON response indicates either an group DM invite, an + # expired invite, or an invalid invite. The API does not currently differentiate + # between invalid and expired invites + return True + + guild_id = guild.get("id") + guild_invite_whitelist = self._get_filterlist_items("guild_invite", allowed=True) + guild_invite_blacklist = self._get_filterlist_items("guild_invite", allowed=False) + + # Is this invite allowed? + guild_partnered_or_verified = ( + 'PARTNERED' in guild.get("features", []) + or 'VERIFIED' in guild.get("features", []) + ) + invite_not_allowed = ( + guild_id in guild_invite_blacklist # Blacklisted guilds are never permitted. + or guild_id not in guild_invite_whitelist # Whitelisted guilds are always permitted. + and not guild_partnered_or_verified # Otherwise guilds have to be Verified or Partnered. + ) + + if invite_not_allowed: + guild_icon_hash = guild["icon"] + guild_icon = ( + "https://cdn.discordapp.com/icons/" + f"{guild_id}/{guild_icon_hash}.png?size=512" + ) + + invite_data[invite] = { + "name": guild["name"], + "id": guild['id'], + "icon": guild_icon, + "members": response["approximate_member_count"], + "active": response["approximate_presence_count"] + } + + return invite_data if invite_data else False + + @staticmethod + async def _has_rich_embed(msg: Message) -> Union[bool, List[discord.Embed]]: + """Determines if `msg` contains any rich embeds not auto-generated from a URL.""" + if msg.embeds: + for embed in msg.embeds: + if embed.type == "rich": + urls = URL_RE.findall(msg.content) + if not embed.url or embed.url not in urls: + # If `embed.url` does not exist or if `embed.url` is not part of the content + # of the message, it's unlikely to be an auto-generated embed by Discord. + return msg.embeds + else: + log.trace( + "Found a rich embed sent by a regular user account, " + "but it was likely just an automatic URL embed." + ) + return False + return False + + async def notify_member(self, filtered_member: Member, reason: str, channel: TextChannel) -> None: + """ + Notify filtered_member about a moderation action with the reason str. + + First attempts to DM the user, fall back to in-channel notification if user has DMs disabled + """ + try: + await filtered_member.send(reason) + except discord.errors.Forbidden: + await channel.send(f"{filtered_member.mention} {reason}") + + def schedule_msg_delete(self, msg: dict) -> None: + """Delete an offensive message once its deletion date is reached.""" + delete_at = dateutil.parser.isoparse(msg['delete_date']).replace(tzinfo=None) + self.scheduler.schedule_at(delete_at, msg['id'], self.delete_offensive_msg(msg)) + + async def reschedule_offensive_msg_deletion(self) -> None: + """Get all the pending message deletion from the API and reschedule them.""" + await self.bot.wait_until_ready() + response = await self.bot.api_client.get('bot/offensive-messages',) + + now = datetime.utcnow() + + for msg in response: + delete_at = dateutil.parser.isoparse(msg['delete_date']).replace(tzinfo=None) + + if delete_at < now: + await self.delete_offensive_msg(msg) + else: + self.schedule_msg_delete(msg) + + async def delete_offensive_msg(self, msg: Mapping[str, str]) -> None: + """Delete an offensive message, and then delete it from the db.""" + try: + channel = self.bot.get_channel(msg['channel_id']) + if channel: + msg_obj = await channel.fetch_message(msg['id']) + await msg_obj.delete() + except NotFound: + log.info( + f"Tried to delete message {msg['id']}, but the message can't be found " + f"(it has been probably already deleted)." + ) + except HTTPException as e: + log.warning(f"Failed to delete message {msg['id']}: status {e.status}") + + await self.bot.api_client.delete(f'bot/offensive-messages/{msg["id"]}') + log.info(f"Deleted the offensive message with id {msg['id']}.") + + +def setup(bot: Bot) -> None: + """Load the Filtering cog.""" + bot.add_cog(Filtering(bot)) diff --git a/bot/cogs/filters/security.py b/bot/cogs/filters/security.py new file mode 100644 index 000000000..c680c5e27 --- /dev/null +++ b/bot/cogs/filters/security.py @@ -0,0 +1,31 @@ +import logging + +from discord.ext.commands import Cog, Context, NoPrivateMessage + +from bot.bot import Bot + +log = logging.getLogger(__name__) + + +class Security(Cog): + """Security-related helpers.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.bot.check(self.check_not_bot) # Global commands check - no bots can run any commands at all + self.bot.check(self.check_on_guild) # Global commands check - commands can't be run in a DM + + def check_not_bot(self, ctx: Context) -> bool: + """Check if the context is a bot user.""" + return not ctx.author.bot + + def check_on_guild(self, ctx: Context) -> bool: + """Check if the context is in a guild.""" + if ctx.guild is None: + raise NoPrivateMessage("This command cannot be used in private messages.") + return True + + +def setup(bot: Bot) -> None: + """Load the Security cog.""" + bot.add_cog(Security(bot)) diff --git a/bot/cogs/filters/token_remover.py b/bot/cogs/filters/token_remover.py new file mode 100644 index 000000000..ef979f222 --- /dev/null +++ b/bot/cogs/filters/token_remover.py @@ -0,0 +1,182 @@ +import base64 +import binascii +import logging +import re +import typing as t + +from discord import Colour, Message, NotFound +from discord.ext.commands import Cog + +from bot import utils +from bot.bot import Bot +from bot.cogs.moderation import ModLog +from bot.constants import Channels, Colours, Event, Icons + +log = logging.getLogger(__name__) + +LOG_MESSAGE = ( + "Censored a seemingly valid token sent by {author} (`{author_id}`) in {channel}, " + "token was `{user_id}.{timestamp}.{hmac}`" +) +DELETION_MESSAGE_TEMPLATE = ( + "Hey {mention}! I noticed you posted a seemingly valid Discord API " + "token in your message and have removed your message. " + "This means that your token has been **compromised**. " + "Please change your token **immediately** at: " + "\n\n" + "Feel free to re-post it with the token removed. " + "If you believe this was a mistake, please let us know!" +) +DISCORD_EPOCH = 1_420_070_400 +TOKEN_EPOCH = 1_293_840_000 + +# Three parts delimited by dots: user ID, creation timestamp, HMAC. +# The HMAC isn't parsed further, but it's in the regex to ensure it at least exists in the string. +# Each part only matches base64 URL-safe characters. +# Padding has never been observed, but the padding character '=' is matched just in case. +TOKEN_RE = re.compile(r"([\w\-=]+)\.([\w\-=]+)\.([\w\-=]+)", re.ASCII) + + +class Token(t.NamedTuple): + """A Discord Bot token.""" + + user_id: str + timestamp: str + hmac: str + + +class TokenRemover(Cog): + """Scans messages for potential discord.py bot tokens and removes them.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @property + def mod_log(self) -> ModLog: + """Get currently loaded ModLog cog instance.""" + return self.bot.get_cog("ModLog") + + @Cog.listener() + async def on_message(self, msg: Message) -> None: + """ + Check each message for a string that matches Discord's token pattern. + + See: https://discordapp.com/developers/docs/reference#snowflakes + """ + # Ignore DMs; can't delete messages in there anyway. + if not msg.guild or msg.author.bot: + return + + found_token = self.find_token_in_message(msg) + if found_token: + await self.take_action(msg, found_token) + + @Cog.listener() + async def on_message_edit(self, before: Message, after: Message) -> None: + """ + Check each edit for a string that matches Discord's token pattern. + + See: https://discordapp.com/developers/docs/reference#snowflakes + """ + await self.on_message(after) + + async def take_action(self, msg: Message, found_token: Token) -> None: + """Remove the `msg` containing the `found_token` and send a mod log message.""" + self.mod_log.ignore(Event.message_delete, msg.id) + + try: + await msg.delete() + except NotFound: + log.debug(f"Failed to remove token in message {msg.id}: message already deleted.") + return + + await msg.channel.send(DELETION_MESSAGE_TEMPLATE.format(mention=msg.author.mention)) + + log_message = self.format_log_message(msg, found_token) + log.debug(log_message) + + # Send pretty mod log embed to mod-alerts + await self.mod_log.send_log_message( + icon_url=Icons.token_removed, + colour=Colour(Colours.soft_red), + title="Token removed!", + text=log_message, + thumbnail=msg.author.avatar_url_as(static_format="png"), + channel_id=Channels.mod_alerts, + ) + + self.bot.stats.incr("tokens.removed_tokens") + + @staticmethod + def format_log_message(msg: Message, token: Token) -> str: + """Return the log message to send for `token` being censored in `msg`.""" + return LOG_MESSAGE.format( + author=msg.author, + author_id=msg.author.id, + channel=msg.channel.mention, + user_id=token.user_id, + timestamp=token.timestamp, + hmac='x' * len(token.hmac), + ) + + @classmethod + def find_token_in_message(cls, msg: Message) -> t.Optional[Token]: + """Return a seemingly valid token found in `msg` or `None` if no token is found.""" + # Use finditer rather than search to guard against method calls prematurely returning the + # token check (e.g. `message.channel.send` also matches our token pattern) + for match in TOKEN_RE.finditer(msg.content): + token = Token(*match.groups()) + if cls.is_valid_user_id(token.user_id) and cls.is_valid_timestamp(token.timestamp): + # Short-circuit on first match + return token + + # No matching substring + return + + @staticmethod + def is_valid_user_id(b64_content: str) -> bool: + """ + Check potential token to see if it contains a valid Discord user ID. + + See: https://discordapp.com/developers/docs/reference#snowflakes + """ + b64_content = utils.pad_base64(b64_content) + + try: + decoded_bytes = base64.urlsafe_b64decode(b64_content) + string = decoded_bytes.decode('utf-8') + + # isdigit on its own would match a lot of other Unicode characters, hence the isascii. + return string.isascii() and string.isdigit() + except (binascii.Error, ValueError): + return False + + @staticmethod + def is_valid_timestamp(b64_content: str) -> bool: + """ + Return True if `b64_content` decodes to a valid timestamp. + + If the timestamp is greater than the Discord epoch, it's probably valid. + See: https://i.imgur.com/7WdehGn.png + """ + b64_content = utils.pad_base64(b64_content) + + try: + decoded_bytes = base64.urlsafe_b64decode(b64_content) + timestamp = int.from_bytes(decoded_bytes, byteorder="big") + except (binascii.Error, ValueError) as e: + log.debug(f"Failed to decode token timestamp '{b64_content}': {e}") + return False + + # Seems like newer tokens don't need the epoch added, but add anyway since an upper bound + # is not checked. + if timestamp + TOKEN_EPOCH >= DISCORD_EPOCH: + return True + else: + log.debug(f"Invalid token timestamp '{b64_content}': smaller than Discord epoch") + return False + + +def setup(bot: Bot) -> None: + """Load the TokenRemover cog.""" + bot.add_cog(TokenRemover(bot)) diff --git a/bot/cogs/filters/webhook_remover.py b/bot/cogs/filters/webhook_remover.py new file mode 100644 index 000000000..5812da87c --- /dev/null +++ b/bot/cogs/filters/webhook_remover.py @@ -0,0 +1,84 @@ +import logging +import re + +from discord import Colour, Message, NotFound +from discord.ext.commands import Cog + +from bot.bot import Bot +from bot.cogs.moderation.modlog import ModLog +from bot.constants import Channels, Colours, Event, Icons + +WEBHOOK_URL_RE = re.compile(r"((?:https?://)?discord(?:app)?\.com/api/webhooks/\d+/)\S+/?", re.IGNORECASE) + +ALERT_MESSAGE_TEMPLATE = ( + "{user}, looks like you posted a Discord webhook URL. Therefore, your " + "message has been removed. Your webhook may have been **compromised** so " + "please re-create the webhook **immediately**. If you believe this was " + "mistake, please let us know." +) + +log = logging.getLogger(__name__) + + +class WebhookRemover(Cog): + """Scan messages to detect Discord webhooks links.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @property + def mod_log(self) -> ModLog: + """Get current instance of `ModLog`.""" + return self.bot.get_cog("ModLog") + + async def delete_and_respond(self, msg: Message, redacted_url: str) -> None: + """Delete `msg` and send a warning that it contained the Discord webhook `redacted_url`.""" + # Don't log this, due internal delete, not by user. Will make different entry. + self.mod_log.ignore(Event.message_delete, msg.id) + + try: + await msg.delete() + except NotFound: + log.debug(f"Failed to remove webhook in message {msg.id}: message already deleted.") + return + + await msg.channel.send(ALERT_MESSAGE_TEMPLATE.format(user=msg.author.mention)) + + message = ( + f"{msg.author} (`{msg.author.id}`) posted a Discord webhook URL " + f"to #{msg.channel}. Webhook URL was `{redacted_url}`" + ) + log.debug(message) + + # Send entry to moderation alerts. + await self.mod_log.send_log_message( + icon_url=Icons.token_removed, + colour=Colour(Colours.soft_red), + title="Discord webhook URL removed!", + text=message, + thumbnail=msg.author.avatar_url_as(static_format="png"), + channel_id=Channels.mod_alerts + ) + + self.bot.stats.incr("tokens.removed_webhooks") + + @Cog.listener() + async def on_message(self, msg: Message) -> None: + """Check if a Discord webhook URL is in `message`.""" + # Ignore DMs; can't delete messages in there anyway. + if not msg.guild or msg.author.bot: + return + + matches = WEBHOOK_URL_RE.search(msg.content) + if matches: + await self.delete_and_respond(msg, matches[1] + "xxx") + + @Cog.listener() + async def on_message_edit(self, before: Message, after: Message) -> None: + """Check if a Discord webhook URL is in the edited message `after`.""" + await self.on_message(after) + + +def setup(bot: Bot) -> None: + """Load `WebhookRemover` cog.""" + bot.add_cog(WebhookRemover(bot)) diff --git a/bot/cogs/help.py b/bot/cogs/help.py deleted file mode 100644 index 3d1d6fd10..000000000 --- a/bot/cogs/help.py +++ /dev/null @@ -1,375 +0,0 @@ -import itertools -import logging -from asyncio import TimeoutError -from collections import namedtuple -from contextlib import suppress -from typing import List, Union - -from discord import Colour, Embed, Member, Message, NotFound, Reaction, User -from discord.ext.commands import Bot, Cog, Command, Context, Group, HelpCommand -from fuzzywuzzy import fuzz, process -from fuzzywuzzy.utils import full_process - -from bot import constants -from bot.constants import Channels, Emojis, STAFF_ROLES -from bot.decorators import redirect_output -from bot.pagination import LinePaginator - -log = logging.getLogger(__name__) - -COMMANDS_PER_PAGE = 8 -DELETE_EMOJI = Emojis.trashcan -PREFIX = constants.Bot.prefix - -Category = namedtuple("Category", ["name", "description", "cogs"]) - - -async def help_cleanup(bot: Bot, author: Member, message: Message) -> None: - """ - Runs the cleanup for the help command. - - Adds the :trashcan: reaction that, when clicked, will delete the help message. - After a 300 second timeout, the reaction will be removed. - """ - def check(reaction: Reaction, user: User) -> bool: - """Checks the reaction is :trashcan:, the author is original author and messages are the same.""" - return str(reaction) == DELETE_EMOJI and user.id == author.id and reaction.message.id == message.id - - await message.add_reaction(DELETE_EMOJI) - - with suppress(NotFound): - try: - await bot.wait_for("reaction_add", check=check, timeout=300) - await message.delete() - except TimeoutError: - await message.remove_reaction(DELETE_EMOJI, bot.user) - - -class HelpQueryNotFound(ValueError): - """ - Raised when a HelpSession Query doesn't match a command or cog. - - Contains the custom attribute of ``possible_matches``. - - Instances of this object contain a dictionary of any command(s) that were close to matching the - query, where keys are the possible matched command names and values are the likeness match scores. - """ - - def __init__(self, arg: str, possible_matches: dict = None): - super().__init__(arg) - self.possible_matches = possible_matches - - -class CustomHelpCommand(HelpCommand): - """ - An interactive instance for the bot help command. - - Cogs can be grouped into custom categories. All cogs with the same category will be displayed - under a single category name in the help output. Custom categories are defined inside the cogs - as a class attribute named `category`. A description can also be specified with the attribute - `category_description`. If a description is not found in at least one cog, the default will be - the regular description (class docstring) of the first cog found in the category. - """ - - def __init__(self): - super().__init__(command_attrs={"help": "Shows help for bot commands"}) - - @redirect_output(destination_channel=Channels.bot_commands, bypass_roles=STAFF_ROLES) - async def command_callback(self, ctx: Context, *, command: str = None) -> None: - """Attempts to match the provided query with a valid command or cog.""" - # the only reason we need to tamper with this is because d.py does not support "categories", - # so we need to deal with them ourselves. - - bot = ctx.bot - - if command is None: - # quick and easy, send bot help if command is none - mapping = self.get_bot_mapping() - await self.send_bot_help(mapping) - return - - cog_matches = [] - description = None - for cog in bot.cogs.values(): - if hasattr(cog, "category") and cog.category == command: - cog_matches.append(cog) - if hasattr(cog, "category_description"): - description = cog.category_description - - if cog_matches: - category = Category(name=command, description=description, cogs=cog_matches) - await self.send_category_help(category) - return - - # it's either a cog, group, command or subcommand; let the parent class deal with it - await super().command_callback(ctx, command=command) - - async def get_all_help_choices(self) -> set: - """ - Get all the possible options for getting help in the bot. - - This will only display commands the author has permission to run. - - These include: - - Category names - - Cog names - - Group command names (and aliases) - - Command names (and aliases) - - Subcommand names (with parent group and aliases for subcommand, but not including aliases for group) - - Options and choices are case sensitive. - """ - # first get all commands including subcommands and full command name aliases - choices = set() - for command in await self.filter_commands(self.context.bot.walk_commands()): - # the the command or group name - choices.add(str(command)) - - if isinstance(command, Command): - # all aliases if it's just a command - choices.update(command.aliases) - else: - # otherwise we need to add the parent name in - choices.update(f"{command.full_parent_name} {alias}" for alias in command.aliases) - - # all cog names - choices.update(self.context.bot.cogs) - - # all category names - choices.update(cog.category for cog in self.context.bot.cogs.values() if hasattr(cog, "category")) - return choices - - async def command_not_found(self, string: str) -> "HelpQueryNotFound": - """ - Handles when a query does not match a valid command, group, cog or category. - - Will return an instance of the `HelpQueryNotFound` exception with the error message and possible matches. - """ - choices = await self.get_all_help_choices() - - # Run fuzzywuzzy's processor beforehand, and avoid matching if processed string is empty - # This avoids fuzzywuzzy from raising a warning on inputs with only non-alphanumeric characters - if (processed := full_process(string)): - result = process.extractBests(processed, choices, scorer=fuzz.ratio, score_cutoff=60, processor=None) - else: - result = [] - - return HelpQueryNotFound(f'Query "{string}" not found.', dict(result)) - - async def subcommand_not_found(self, command: Command, string: str) -> "HelpQueryNotFound": - """ - Redirects the error to `command_not_found`. - - `command_not_found` deals with searching and getting best choices for both commands and subcommands. - """ - return await self.command_not_found(f"{command.qualified_name} {string}") - - async def send_error_message(self, error: HelpQueryNotFound) -> None: - """Send the error message to the channel.""" - embed = Embed(colour=Colour.red(), title=str(error)) - - if getattr(error, "possible_matches", None): - matches = "\n".join(f"`{match}`" for match in error.possible_matches) - embed.description = f"**Did you mean:**\n{matches}" - - await self.context.send(embed=embed) - - async def command_formatting(self, command: Command) -> Embed: - """ - Takes a command and turns it into an embed. - - It will add an author, command signature + help, aliases and a note if the user can't run the command. - """ - embed = Embed() - embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark) - - parent = command.full_parent_name - - name = str(command) if not parent else f"{parent} {command.name}" - command_details = f"**```{PREFIX}{name} {command.signature}```**\n" - - # show command aliases - aliases = ", ".join(f"`{alias}`" if not parent else f"`{parent} {alias}`" for alias in command.aliases) - if aliases: - command_details += f"**Can also use:** {aliases}\n\n" - - # check if the user is allowed to run this command - if not await command.can_run(self.context): - command_details += "***You cannot run this command.***\n\n" - - command_details += f"*{command.help or 'No details provided.'}*\n" - embed.description = command_details - - return embed - - async def send_command_help(self, command: Command) -> None: - """Send help for a single command.""" - embed = await self.command_formatting(command) - message = await self.context.send(embed=embed) - await help_cleanup(self.context.bot, self.context.author, message) - - @staticmethod - def get_commands_brief_details(commands_: List[Command], return_as_list: bool = False) -> Union[List[str], str]: - """ - Formats the prefix, command name and signature, and short doc for an iterable of commands. - - return_as_list is helpful for passing these command details into the paginator as a list of command details. - """ - details = [] - for command in commands_: - signature = f" {command.signature}" if command.signature else "" - details.append( - f"\n**`{PREFIX}{command.qualified_name}{signature}`**\n*{command.short_doc or 'No details provided'}*" - ) - if return_as_list: - return details - else: - return "".join(details) - - async def send_group_help(self, group: Group) -> None: - """Sends help for a group command.""" - subcommands = group.commands - - if len(subcommands) == 0: - # no subcommands, just treat it like a regular command - await self.send_command_help(group) - return - - # remove commands that the user can't run and are hidden, and sort by name - commands_ = await self.filter_commands(subcommands, sort=True) - - embed = await self.command_formatting(group) - - command_details = self.get_commands_brief_details(commands_) - if command_details: - embed.description += f"\n**Subcommands:**\n{command_details}" - - message = await self.context.send(embed=embed) - await help_cleanup(self.context.bot, self.context.author, message) - - async def send_cog_help(self, cog: Cog) -> None: - """Send help for a cog.""" - # sort commands by name, and remove any the user cant run or are hidden. - commands_ = await self.filter_commands(cog.get_commands(), sort=True) - - embed = Embed() - embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark) - embed.description = f"**{cog.qualified_name}**\n*{cog.description}*" - - command_details = self.get_commands_brief_details(commands_) - if command_details: - embed.description += f"\n\n**Commands:**\n{command_details}" - - message = await self.context.send(embed=embed) - await help_cleanup(self.context.bot, self.context.author, message) - - @staticmethod - def _category_key(command: Command) -> str: - """ - Returns a cog name of a given command for use as a key for `sorted` and `groupby`. - - A zero width space is used as a prefix for results with no cogs to force them last in ordering. - """ - if command.cog: - with suppress(AttributeError): - if command.cog.category: - return f"**{command.cog.category}**" - return f"**{command.cog_name}**" - else: - return "**\u200bNo Category:**" - - async def send_category_help(self, category: Category) -> None: - """ - Sends help for a bot category. - - This sends a brief help for all commands in all cogs registered to the category. - """ - embed = Embed() - embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark) - - all_commands = [] - for cog in category.cogs: - all_commands.extend(cog.get_commands()) - - filtered_commands = await self.filter_commands(all_commands, sort=True) - - command_detail_lines = self.get_commands_brief_details(filtered_commands, return_as_list=True) - description = f"**{category.name}**\n*{category.description}*" - - if command_detail_lines: - description += "\n\n**Commands:**" - - await LinePaginator.paginate( - command_detail_lines, - self.context, - embed, - prefix=description, - max_lines=COMMANDS_PER_PAGE, - max_size=2000, - ) - - async def send_bot_help(self, mapping: dict) -> None: - """Sends help for all bot commands and cogs.""" - bot = self.context.bot - - embed = Embed() - embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark) - - filter_commands = await self.filter_commands(bot.commands, sort=True, key=self._category_key) - - cog_or_category_pages = [] - - for cog_or_category, _commands in itertools.groupby(filter_commands, key=self._category_key): - sorted_commands = sorted(_commands, key=lambda c: c.name) - - if len(sorted_commands) == 0: - continue - - command_detail_lines = self.get_commands_brief_details(sorted_commands, return_as_list=True) - - # Split cogs or categories which have too many commands to fit in one page. - # The length of commands is included for later use when aggregating into pages for the paginator. - for index in range(0, len(sorted_commands), COMMANDS_PER_PAGE): - truncated_lines = command_detail_lines[index:index + COMMANDS_PER_PAGE] - joined_lines = "".join(truncated_lines) - cog_or_category_pages.append((f"**{cog_or_category}**{joined_lines}", len(truncated_lines))) - - pages = [] - counter = 0 - page = "" - for page_details, length in cog_or_category_pages: - counter += length - if counter > COMMANDS_PER_PAGE: - # force a new page on paginator even if it falls short of the max pages - # since we still want to group categories/cogs. - counter = length - pages.append(page) - page = f"{page_details}\n\n" - else: - page += f"{page_details}\n\n" - - if page: - # add any remaining command help that didn't get added in the last iteration above. - pages.append(page) - - await LinePaginator.paginate(pages, self.context, embed=embed, max_lines=1, max_size=2000) - - -class Help(Cog): - """Custom Embed Pagination Help feature.""" - - def __init__(self, bot: Bot) -> None: - self.bot = bot - self.old_help_command = bot.help_command - bot.help_command = CustomHelpCommand() - bot.help_command.cog = self - - def cog_unload(self) -> None: - """Reset the help command when the cog is unloaded.""" - self.bot.help_command = self.old_help_command - - -def setup(bot: Bot) -> None: - """Load the Help cog.""" - bot.add_cog(Help(bot)) - log.info("Cog loaded: Help") diff --git a/bot/cogs/info/__init__.py b/bot/cogs/info/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bot/cogs/info/doc.py b/bot/cogs/info/doc.py new file mode 100644 index 000000000..204cffb37 --- /dev/null +++ b/bot/cogs/info/doc.py @@ -0,0 +1,511 @@ +import asyncio +import functools +import logging +import re +import textwrap +from collections import OrderedDict +from contextlib import suppress +from types import SimpleNamespace +from typing import Any, Callable, Optional, Tuple + +import discord +from bs4 import BeautifulSoup +from bs4.element import PageElement, Tag +from discord.errors import NotFound +from discord.ext import commands +from markdownify import MarkdownConverter +from requests import ConnectTimeout, ConnectionError, HTTPError +from sphinx.ext import intersphinx +from urllib3.exceptions import ProtocolError + +from bot.bot import Bot +from bot.constants import MODERATION_ROLES, RedirectOutput +from bot.converters import ValidPythonIdentifier, ValidURL +from bot.decorators import with_role +from bot.pagination import LinePaginator + + +log = logging.getLogger(__name__) +logging.getLogger('urllib3').setLevel(logging.WARNING) + +# Since Intersphinx is intended to be used with Sphinx, +# we need to mock its configuration. +SPHINX_MOCK_APP = SimpleNamespace( + config=SimpleNamespace( + intersphinx_timeout=3, + tls_verify=True, + user_agent="python3:python-discord/bot:1.0.0" + ) +) + +NO_OVERRIDE_GROUPS = ( + "2to3fixer", + "token", + "label", + "pdbcommand", + "term", +) +NO_OVERRIDE_PACKAGES = ( + "python", +) + +SEARCH_END_TAG_ATTRS = ( + "data", + "function", + "class", + "exception", + "seealso", + "section", + "rubric", + "sphinxsidebar", +) +UNWANTED_SIGNATURE_SYMBOLS_RE = re.compile(r"\[source]|\\\\|¶") +WHITESPACE_AFTER_NEWLINES_RE = re.compile(r"(?<=\n\n)(\s+)") + +FAILED_REQUEST_RETRY_AMOUNT = 3 +NOT_FOUND_DELETE_DELAY = RedirectOutput.delete_delay + + +def async_cache(max_size: int = 128, arg_offset: int = 0) -> Callable: + """ + LRU cache implementation for coroutines. + + Once the cache exceeds the maximum size, keys are deleted in FIFO order. + + An offset may be optionally provided to be applied to the coroutine's arguments when creating the cache key. + """ + # Assign the cache to the function itself so we can clear it from outside. + async_cache.cache = OrderedDict() + + def decorator(function: Callable) -> Callable: + """Define the async_cache decorator.""" + @functools.wraps(function) + async def wrapper(*args) -> Any: + """Decorator wrapper for the caching logic.""" + key = ':'.join(args[arg_offset:]) + + value = async_cache.cache.get(key) + if value is None: + if len(async_cache.cache) > max_size: + async_cache.cache.popitem(last=False) + + async_cache.cache[key] = await function(*args) + return async_cache.cache[key] + return wrapper + return decorator + + +class DocMarkdownConverter(MarkdownConverter): + """Subclass markdownify's MarkdownCoverter to provide custom conversion methods.""" + + def convert_code(self, el: PageElement, text: str) -> str: + """Undo `markdownify`s underscore escaping.""" + return f"`{text}`".replace('\\', '') + + def convert_pre(self, el: PageElement, text: str) -> str: + """Wrap any codeblocks in `py` for syntax highlighting.""" + code = ''.join(el.strings) + return f"```py\n{code}```" + + +def markdownify(html: str) -> DocMarkdownConverter: + """Create a DocMarkdownConverter object from the input html.""" + return DocMarkdownConverter(bullets='•').convert(html) + + +class InventoryURL(commands.Converter): + """ + Represents an Intersphinx inventory URL. + + This converter checks whether intersphinx accepts the given inventory URL, and raises + `BadArgument` if that is not the case. + + Otherwise, it simply passes through the given URL. + """ + + @staticmethod + async def convert(ctx: commands.Context, url: str) -> str: + """Convert url to Intersphinx inventory URL.""" + try: + intersphinx.fetch_inventory(SPHINX_MOCK_APP, '', url) + except AttributeError: + raise commands.BadArgument(f"Failed to fetch Intersphinx inventory from URL `{url}`.") + except ConnectionError: + if url.startswith('https'): + raise commands.BadArgument( + f"Cannot establish a connection to `{url}`. Does it support HTTPS?" + ) + raise commands.BadArgument(f"Cannot connect to host with URL `{url}`.") + except ValueError: + raise commands.BadArgument( + f"Failed to read Intersphinx inventory from URL `{url}`. " + "Are you sure that it's a valid inventory file?" + ) + return url + + +class Doc(commands.Cog): + """A set of commands for querying & displaying documentation.""" + + def __init__(self, bot: Bot): + self.base_urls = {} + self.bot = bot + self.inventories = {} + self.renamed_symbols = set() + + self.bot.loop.create_task(self.init_refresh_inventory()) + + async def init_refresh_inventory(self) -> None: + """Refresh documentation inventory on cog initialization.""" + await self.bot.wait_until_guild_available() + await self.refresh_inventory() + + async def update_single( + self, package_name: str, base_url: str, inventory_url: str + ) -> None: + """ + Rebuild the inventory for a single package. + + Where: + * `package_name` is the package name to use, appears in the log + * `base_url` is the root documentation URL for the specified package, used to build + absolute paths that link to specific symbols + * `inventory_url` is the absolute URL to the intersphinx inventory, fetched by running + `intersphinx.fetch_inventory` in an executor on the bot's event loop + """ + self.base_urls[package_name] = base_url + + package = await self._fetch_inventory(inventory_url) + if not package: + return None + + for group, value in package.items(): + for symbol, (package_name, _version, relative_doc_url, _) in value.items(): + absolute_doc_url = base_url + relative_doc_url + + if symbol in self.inventories: + group_name = group.split(":")[1] + symbol_base_url = self.inventories[symbol].split("/", 3)[2] + if ( + group_name in NO_OVERRIDE_GROUPS + or any(package in symbol_base_url for package in NO_OVERRIDE_PACKAGES) + ): + + symbol = f"{group_name}.{symbol}" + # If renamed `symbol` already exists, add library name in front to differentiate between them. + if symbol in self.renamed_symbols: + # Split `package_name` because of packages like Pillow that have spaces in them. + symbol = f"{package_name.split()[0]}.{symbol}" + + self.inventories[symbol] = absolute_doc_url + self.renamed_symbols.add(symbol) + continue + + self.inventories[symbol] = absolute_doc_url + + log.trace(f"Fetched inventory for {package_name}.") + + async def refresh_inventory(self) -> None: + """Refresh internal documentation inventory.""" + log.debug("Refreshing documentation inventory...") + + # Clear the old base URLS and inventories to ensure + # that we start from a fresh local dataset. + # Also, reset the cache used for fetching documentation. + self.base_urls.clear() + self.inventories.clear() + self.renamed_symbols.clear() + async_cache.cache = OrderedDict() + + # Run all coroutines concurrently - since each of them performs a HTTP + # request, this speeds up fetching the inventory data heavily. + coros = [ + self.update_single( + package["package"], package["base_url"], package["inventory_url"] + ) for package in await self.bot.api_client.get('bot/documentation-links') + ] + await asyncio.gather(*coros) + + async def get_symbol_html(self, symbol: str) -> Optional[Tuple[list, str]]: + """ + Given a Python symbol, return its signature and description. + + The first tuple element is the signature of the given symbol as a markup-free string, and + the second tuple element is the description of the given symbol with HTML markup included. + + If the given symbol is a module, returns a tuple `(None, str)` + else if the symbol could not be found, returns `None`. + """ + url = self.inventories.get(symbol) + if url is None: + return None + + async with self.bot.http_session.get(url) as response: + html = await response.text(encoding='utf-8') + + # Find the signature header and parse the relevant parts. + symbol_id = url.split('#')[-1] + soup = BeautifulSoup(html, 'lxml') + symbol_heading = soup.find(id=symbol_id) + search_html = str(soup) + + if symbol_heading is None: + return None + + if symbol_id == f"module-{symbol}": + # Get page content from the module headerlink to the + # first tag that has its class in `SEARCH_END_TAG_ATTRS` + start_tag = symbol_heading.find("a", attrs={"class": "headerlink"}) + if start_tag is None: + return [], "" + + end_tag = start_tag.find_next(self._match_end_tag) + if end_tag is None: + return [], "" + + description_start_index = search_html.find(str(start_tag.parent)) + len(str(start_tag.parent)) + description_end_index = search_html.find(str(end_tag)) + description = search_html[description_start_index:description_end_index] + signatures = None + + else: + signatures = [] + description = str(symbol_heading.find_next_sibling("dd")) + description_pos = search_html.find(description) + # Get text of up to 3 signatures, remove unwanted symbols + for element in [symbol_heading] + symbol_heading.find_next_siblings("dt", limit=2): + signature = UNWANTED_SIGNATURE_SYMBOLS_RE.sub("", element.text) + if signature and search_html.find(str(element)) < description_pos: + signatures.append(signature) + + return signatures, description.replace('¶', '') + + @async_cache(arg_offset=1) + async def get_symbol_embed(self, symbol: str) -> Optional[discord.Embed]: + """ + Attempt to scrape and fetch the data for the given `symbol`, and build an embed from its contents. + + If the symbol is known, an Embed with documentation about it is returned. + """ + scraped_html = await self.get_symbol_html(symbol) + if scraped_html is None: + return None + + signatures = scraped_html[0] + permalink = self.inventories[symbol] + description = markdownify(scraped_html[1]) + + # Truncate the description of the embed to the last occurrence + # of a double newline (interpreted as a paragraph) before index 1000. + if len(description) > 1000: + shortened = description[:1000] + description_cutoff = shortened.rfind('\n\n', 100) + if description_cutoff == -1: + # Search the shortened version for cutoff points in decreasing desirability, + # cutoff at 1000 if none are found. + for string in (". ", ", ", ",", " "): + description_cutoff = shortened.rfind(string) + if description_cutoff != -1: + break + else: + description_cutoff = 1000 + description = description[:description_cutoff] + + # If there is an incomplete code block, cut it out + if description.count("```") % 2: + codeblock_start = description.rfind('```py') + description = description[:codeblock_start].rstrip() + description += f"... [read more]({permalink})" + + description = WHITESPACE_AFTER_NEWLINES_RE.sub('', description) + if signatures is None: + # If symbol is a module, don't show signature. + embed_description = description + + elif not signatures: + # It's some "meta-page", for example: + # https://docs.djangoproject.com/en/dev/ref/views/#module-django.views + embed_description = "This appears to be a generic page not tied to a specific symbol." + + else: + embed_description = "".join(f"```py\n{textwrap.shorten(signature, 500)}```" for signature in signatures) + embed_description += f"\n{description}" + + embed = discord.Embed( + title=f'`{symbol}`', + url=permalink, + description=embed_description + ) + # Show all symbols with the same name that were renamed in the footer. + embed.set_footer( + text=", ".join(renamed for renamed in self.renamed_symbols - {symbol} if renamed.endswith(f".{symbol}")) + ) + return embed + + @commands.group(name='docs', aliases=('doc', 'd'), invoke_without_command=True) + async def docs_group(self, ctx: commands.Context, symbol: commands.clean_content = None) -> None: + """Lookup documentation for Python symbols.""" + await ctx.invoke(self.get_command, symbol) + + @docs_group.command(name='get', aliases=('g',)) + async def get_command(self, ctx: commands.Context, symbol: commands.clean_content = None) -> None: + """ + Return a documentation embed for a given symbol. + + If no symbol is given, return a list of all available inventories. + + Examples: + !docs + !docs aiohttp + !docs aiohttp.ClientSession + !docs get aiohttp.ClientSession + """ + if symbol is None: + inventory_embed = discord.Embed( + title=f"All inventories (`{len(self.base_urls)}` total)", + colour=discord.Colour.blue() + ) + + lines = sorted(f"• [`{name}`]({url})" for name, url in self.base_urls.items()) + if self.base_urls: + await LinePaginator.paginate(lines, ctx, inventory_embed, max_size=400, empty=False) + + else: + inventory_embed.description = "Hmmm, seems like there's nothing here yet." + await ctx.send(embed=inventory_embed) + + else: + # Fetching documentation for a symbol (at least for the first time, since + # caching is used) takes quite some time, so let's send typing to indicate + # that we got the command, but are still working on it. + async with ctx.typing(): + doc_embed = await self.get_symbol_embed(symbol) + + if doc_embed is None: + error_embed = discord.Embed( + description=f"Sorry, I could not find any documentation for `{symbol}`.", + colour=discord.Colour.red() + ) + error_message = await ctx.send(embed=error_embed) + with suppress(NotFound): + await error_message.delete(delay=NOT_FOUND_DELETE_DELAY) + await ctx.message.delete(delay=NOT_FOUND_DELETE_DELAY) + else: + await ctx.send(embed=doc_embed) + + @docs_group.command(name='set', aliases=('s',)) + @with_role(*MODERATION_ROLES) + async def set_command( + self, ctx: commands.Context, package_name: ValidPythonIdentifier, + base_url: ValidURL, inventory_url: InventoryURL + ) -> None: + """ + Adds a new documentation metadata object to the site's database. + + The database will update the object, should an existing item with the specified `package_name` already exist. + + Example: + !docs set \ + python \ + https://docs.python.org/3/ \ + https://docs.python.org/3/objects.inv + """ + body = { + 'package': package_name, + 'base_url': base_url, + 'inventory_url': inventory_url + } + await self.bot.api_client.post('bot/documentation-links', json=body) + + log.info( + f"User @{ctx.author} ({ctx.author.id}) added a new documentation package:\n" + f"Package name: {package_name}\n" + f"Base url: {base_url}\n" + f"Inventory URL: {inventory_url}" + ) + + # Rebuilding the inventory can take some time, so lets send out a + # typing event to show that the Bot is still working. + async with ctx.typing(): + await self.refresh_inventory() + await ctx.send(f"Added package `{package_name}` to database and refreshed inventory.") + + @docs_group.command(name='delete', aliases=('remove', 'rm', 'd')) + @with_role(*MODERATION_ROLES) + async def delete_command(self, ctx: commands.Context, package_name: ValidPythonIdentifier) -> None: + """ + Removes the specified package from the database. + + Examples: + !docs delete aiohttp + """ + await self.bot.api_client.delete(f'bot/documentation-links/{package_name}') + + async with ctx.typing(): + # Rebuild the inventory to ensure that everything + # that was from this package is properly deleted. + await self.refresh_inventory() + await ctx.send(f"Successfully deleted `{package_name}` and refreshed inventory.") + + @docs_group.command(name="refresh", aliases=("rfsh", "r")) + @with_role(*MODERATION_ROLES) + async def refresh_command(self, ctx: commands.Context) -> None: + """Refresh inventories and send differences to channel.""" + old_inventories = set(self.base_urls) + with ctx.typing(): + await self.refresh_inventory() + # Get differences of added and removed inventories + added = ', '.join(inv for inv in self.base_urls if inv not in old_inventories) + if added: + added = f"+ {added}" + + removed = ', '.join(inv for inv in old_inventories if inv not in self.base_urls) + if removed: + removed = f"- {removed}" + + embed = discord.Embed( + title="Inventories refreshed", + description=f"```diff\n{added}\n{removed}```" if added or removed else "" + ) + await ctx.send(embed=embed) + + async def _fetch_inventory(self, inventory_url: str) -> Optional[dict]: + """Get and return inventory from `inventory_url`. If fetching fails, return None.""" + fetch_func = functools.partial(intersphinx.fetch_inventory, SPHINX_MOCK_APP, '', inventory_url) + for retry in range(1, FAILED_REQUEST_RETRY_AMOUNT+1): + try: + package = await self.bot.loop.run_in_executor(None, fetch_func) + except ConnectTimeout: + log.error( + f"Fetching of inventory {inventory_url} timed out," + f" trying again. ({retry}/{FAILED_REQUEST_RETRY_AMOUNT})" + ) + except ProtocolError: + log.error( + f"Connection lost while fetching inventory {inventory_url}," + f" trying again. ({retry}/{FAILED_REQUEST_RETRY_AMOUNT})" + ) + except HTTPError as e: + log.error(f"Fetching of inventory {inventory_url} failed with status code {e.response.status_code}.") + return None + except ConnectionError: + log.error(f"Couldn't establish connection to inventory {inventory_url}.") + return None + else: + return package + log.error(f"Fetching of inventory {inventory_url} failed.") + return None + + @staticmethod + def _match_end_tag(tag: Tag) -> bool: + """Matches `tag` if its class value is in `SEARCH_END_TAG_ATTRS` or the tag is table.""" + for attr in SEARCH_END_TAG_ATTRS: + if attr in tag.get("class", ()): + return True + + return tag.name == "table" + + +def setup(bot: Bot) -> None: + """Load the Doc cog.""" + bot.add_cog(Doc(bot)) diff --git a/bot/cogs/info/help.py b/bot/cogs/info/help.py new file mode 100644 index 000000000..3d1d6fd10 --- /dev/null +++ b/bot/cogs/info/help.py @@ -0,0 +1,375 @@ +import itertools +import logging +from asyncio import TimeoutError +from collections import namedtuple +from contextlib import suppress +from typing import List, Union + +from discord import Colour, Embed, Member, Message, NotFound, Reaction, User +from discord.ext.commands import Bot, Cog, Command, Context, Group, HelpCommand +from fuzzywuzzy import fuzz, process +from fuzzywuzzy.utils import full_process + +from bot import constants +from bot.constants import Channels, Emojis, STAFF_ROLES +from bot.decorators import redirect_output +from bot.pagination import LinePaginator + +log = logging.getLogger(__name__) + +COMMANDS_PER_PAGE = 8 +DELETE_EMOJI = Emojis.trashcan +PREFIX = constants.Bot.prefix + +Category = namedtuple("Category", ["name", "description", "cogs"]) + + +async def help_cleanup(bot: Bot, author: Member, message: Message) -> None: + """ + Runs the cleanup for the help command. + + Adds the :trashcan: reaction that, when clicked, will delete the help message. + After a 300 second timeout, the reaction will be removed. + """ + def check(reaction: Reaction, user: User) -> bool: + """Checks the reaction is :trashcan:, the author is original author and messages are the same.""" + return str(reaction) == DELETE_EMOJI and user.id == author.id and reaction.message.id == message.id + + await message.add_reaction(DELETE_EMOJI) + + with suppress(NotFound): + try: + await bot.wait_for("reaction_add", check=check, timeout=300) + await message.delete() + except TimeoutError: + await message.remove_reaction(DELETE_EMOJI, bot.user) + + +class HelpQueryNotFound(ValueError): + """ + Raised when a HelpSession Query doesn't match a command or cog. + + Contains the custom attribute of ``possible_matches``. + + Instances of this object contain a dictionary of any command(s) that were close to matching the + query, where keys are the possible matched command names and values are the likeness match scores. + """ + + def __init__(self, arg: str, possible_matches: dict = None): + super().__init__(arg) + self.possible_matches = possible_matches + + +class CustomHelpCommand(HelpCommand): + """ + An interactive instance for the bot help command. + + Cogs can be grouped into custom categories. All cogs with the same category will be displayed + under a single category name in the help output. Custom categories are defined inside the cogs + as a class attribute named `category`. A description can also be specified with the attribute + `category_description`. If a description is not found in at least one cog, the default will be + the regular description (class docstring) of the first cog found in the category. + """ + + def __init__(self): + super().__init__(command_attrs={"help": "Shows help for bot commands"}) + + @redirect_output(destination_channel=Channels.bot_commands, bypass_roles=STAFF_ROLES) + async def command_callback(self, ctx: Context, *, command: str = None) -> None: + """Attempts to match the provided query with a valid command or cog.""" + # the only reason we need to tamper with this is because d.py does not support "categories", + # so we need to deal with them ourselves. + + bot = ctx.bot + + if command is None: + # quick and easy, send bot help if command is none + mapping = self.get_bot_mapping() + await self.send_bot_help(mapping) + return + + cog_matches = [] + description = None + for cog in bot.cogs.values(): + if hasattr(cog, "category") and cog.category == command: + cog_matches.append(cog) + if hasattr(cog, "category_description"): + description = cog.category_description + + if cog_matches: + category = Category(name=command, description=description, cogs=cog_matches) + await self.send_category_help(category) + return + + # it's either a cog, group, command or subcommand; let the parent class deal with it + await super().command_callback(ctx, command=command) + + async def get_all_help_choices(self) -> set: + """ + Get all the possible options for getting help in the bot. + + This will only display commands the author has permission to run. + + These include: + - Category names + - Cog names + - Group command names (and aliases) + - Command names (and aliases) + - Subcommand names (with parent group and aliases for subcommand, but not including aliases for group) + + Options and choices are case sensitive. + """ + # first get all commands including subcommands and full command name aliases + choices = set() + for command in await self.filter_commands(self.context.bot.walk_commands()): + # the the command or group name + choices.add(str(command)) + + if isinstance(command, Command): + # all aliases if it's just a command + choices.update(command.aliases) + else: + # otherwise we need to add the parent name in + choices.update(f"{command.full_parent_name} {alias}" for alias in command.aliases) + + # all cog names + choices.update(self.context.bot.cogs) + + # all category names + choices.update(cog.category for cog in self.context.bot.cogs.values() if hasattr(cog, "category")) + return choices + + async def command_not_found(self, string: str) -> "HelpQueryNotFound": + """ + Handles when a query does not match a valid command, group, cog or category. + + Will return an instance of the `HelpQueryNotFound` exception with the error message and possible matches. + """ + choices = await self.get_all_help_choices() + + # Run fuzzywuzzy's processor beforehand, and avoid matching if processed string is empty + # This avoids fuzzywuzzy from raising a warning on inputs with only non-alphanumeric characters + if (processed := full_process(string)): + result = process.extractBests(processed, choices, scorer=fuzz.ratio, score_cutoff=60, processor=None) + else: + result = [] + + return HelpQueryNotFound(f'Query "{string}" not found.', dict(result)) + + async def subcommand_not_found(self, command: Command, string: str) -> "HelpQueryNotFound": + """ + Redirects the error to `command_not_found`. + + `command_not_found` deals with searching and getting best choices for both commands and subcommands. + """ + return await self.command_not_found(f"{command.qualified_name} {string}") + + async def send_error_message(self, error: HelpQueryNotFound) -> None: + """Send the error message to the channel.""" + embed = Embed(colour=Colour.red(), title=str(error)) + + if getattr(error, "possible_matches", None): + matches = "\n".join(f"`{match}`" for match in error.possible_matches) + embed.description = f"**Did you mean:**\n{matches}" + + await self.context.send(embed=embed) + + async def command_formatting(self, command: Command) -> Embed: + """ + Takes a command and turns it into an embed. + + It will add an author, command signature + help, aliases and a note if the user can't run the command. + """ + embed = Embed() + embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark) + + parent = command.full_parent_name + + name = str(command) if not parent else f"{parent} {command.name}" + command_details = f"**```{PREFIX}{name} {command.signature}```**\n" + + # show command aliases + aliases = ", ".join(f"`{alias}`" if not parent else f"`{parent} {alias}`" for alias in command.aliases) + if aliases: + command_details += f"**Can also use:** {aliases}\n\n" + + # check if the user is allowed to run this command + if not await command.can_run(self.context): + command_details += "***You cannot run this command.***\n\n" + + command_details += f"*{command.help or 'No details provided.'}*\n" + embed.description = command_details + + return embed + + async def send_command_help(self, command: Command) -> None: + """Send help for a single command.""" + embed = await self.command_formatting(command) + message = await self.context.send(embed=embed) + await help_cleanup(self.context.bot, self.context.author, message) + + @staticmethod + def get_commands_brief_details(commands_: List[Command], return_as_list: bool = False) -> Union[List[str], str]: + """ + Formats the prefix, command name and signature, and short doc for an iterable of commands. + + return_as_list is helpful for passing these command details into the paginator as a list of command details. + """ + details = [] + for command in commands_: + signature = f" {command.signature}" if command.signature else "" + details.append( + f"\n**`{PREFIX}{command.qualified_name}{signature}`**\n*{command.short_doc or 'No details provided'}*" + ) + if return_as_list: + return details + else: + return "".join(details) + + async def send_group_help(self, group: Group) -> None: + """Sends help for a group command.""" + subcommands = group.commands + + if len(subcommands) == 0: + # no subcommands, just treat it like a regular command + await self.send_command_help(group) + return + + # remove commands that the user can't run and are hidden, and sort by name + commands_ = await self.filter_commands(subcommands, sort=True) + + embed = await self.command_formatting(group) + + command_details = self.get_commands_brief_details(commands_) + if command_details: + embed.description += f"\n**Subcommands:**\n{command_details}" + + message = await self.context.send(embed=embed) + await help_cleanup(self.context.bot, self.context.author, message) + + async def send_cog_help(self, cog: Cog) -> None: + """Send help for a cog.""" + # sort commands by name, and remove any the user cant run or are hidden. + commands_ = await self.filter_commands(cog.get_commands(), sort=True) + + embed = Embed() + embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark) + embed.description = f"**{cog.qualified_name}**\n*{cog.description}*" + + command_details = self.get_commands_brief_details(commands_) + if command_details: + embed.description += f"\n\n**Commands:**\n{command_details}" + + message = await self.context.send(embed=embed) + await help_cleanup(self.context.bot, self.context.author, message) + + @staticmethod + def _category_key(command: Command) -> str: + """ + Returns a cog name of a given command for use as a key for `sorted` and `groupby`. + + A zero width space is used as a prefix for results with no cogs to force them last in ordering. + """ + if command.cog: + with suppress(AttributeError): + if command.cog.category: + return f"**{command.cog.category}**" + return f"**{command.cog_name}**" + else: + return "**\u200bNo Category:**" + + async def send_category_help(self, category: Category) -> None: + """ + Sends help for a bot category. + + This sends a brief help for all commands in all cogs registered to the category. + """ + embed = Embed() + embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark) + + all_commands = [] + for cog in category.cogs: + all_commands.extend(cog.get_commands()) + + filtered_commands = await self.filter_commands(all_commands, sort=True) + + command_detail_lines = self.get_commands_brief_details(filtered_commands, return_as_list=True) + description = f"**{category.name}**\n*{category.description}*" + + if command_detail_lines: + description += "\n\n**Commands:**" + + await LinePaginator.paginate( + command_detail_lines, + self.context, + embed, + prefix=description, + max_lines=COMMANDS_PER_PAGE, + max_size=2000, + ) + + async def send_bot_help(self, mapping: dict) -> None: + """Sends help for all bot commands and cogs.""" + bot = self.context.bot + + embed = Embed() + embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark) + + filter_commands = await self.filter_commands(bot.commands, sort=True, key=self._category_key) + + cog_or_category_pages = [] + + for cog_or_category, _commands in itertools.groupby(filter_commands, key=self._category_key): + sorted_commands = sorted(_commands, key=lambda c: c.name) + + if len(sorted_commands) == 0: + continue + + command_detail_lines = self.get_commands_brief_details(sorted_commands, return_as_list=True) + + # Split cogs or categories which have too many commands to fit in one page. + # The length of commands is included for later use when aggregating into pages for the paginator. + for index in range(0, len(sorted_commands), COMMANDS_PER_PAGE): + truncated_lines = command_detail_lines[index:index + COMMANDS_PER_PAGE] + joined_lines = "".join(truncated_lines) + cog_or_category_pages.append((f"**{cog_or_category}**{joined_lines}", len(truncated_lines))) + + pages = [] + counter = 0 + page = "" + for page_details, length in cog_or_category_pages: + counter += length + if counter > COMMANDS_PER_PAGE: + # force a new page on paginator even if it falls short of the max pages + # since we still want to group categories/cogs. + counter = length + pages.append(page) + page = f"{page_details}\n\n" + else: + page += f"{page_details}\n\n" + + if page: + # add any remaining command help that didn't get added in the last iteration above. + pages.append(page) + + await LinePaginator.paginate(pages, self.context, embed=embed, max_lines=1, max_size=2000) + + +class Help(Cog): + """Custom Embed Pagination Help feature.""" + + def __init__(self, bot: Bot) -> None: + self.bot = bot + self.old_help_command = bot.help_command + bot.help_command = CustomHelpCommand() + bot.help_command.cog = self + + def cog_unload(self) -> None: + """Reset the help command when the cog is unloaded.""" + self.bot.help_command = self.old_help_command + + +def setup(bot: Bot) -> None: + """Load the Help cog.""" + bot.add_cog(Help(bot)) + log.info("Cog loaded: Help") diff --git a/bot/cogs/info/information.py b/bot/cogs/info/information.py new file mode 100644 index 000000000..8982196d1 --- /dev/null +++ b/bot/cogs/info/information.py @@ -0,0 +1,422 @@ +import colorsys +import logging +import pprint +import textwrap +from collections import Counter, defaultdict +from string import Template +from typing import Any, Mapping, Optional, Union + +from discord import ChannelType, Colour, Embed, Guild, Member, Message, Role, Status, utils +from discord.abc import GuildChannel +from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group +from discord.utils import escape_markdown + +from bot import constants +from bot.bot import Bot +from bot.decorators import in_whitelist, with_role +from bot.pagination import LinePaginator +from bot.utils.checks import InWhitelistCheckFailure, cooldown_with_role_bypass, with_role_check +from bot.utils.time import time_since + +log = logging.getLogger(__name__) + + +class Information(Cog): + """A cog with commands for generating embeds with server info, such as server stats and user info.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @staticmethod + def role_can_read(channel: GuildChannel, role: Role) -> bool: + """Return True if `role` can read messages in `channel`.""" + overwrites = channel.overwrites_for(role) + return overwrites.read_messages is True + + def get_staff_channel_count(self, guild: Guild) -> int: + """ + Get the number of channels that are staff-only. + + We need to know two things about a channel: + - Does the @everyone role have explicit read deny permissions? + - Do staff roles have explicit read allow permissions? + + If the answer to both of these questions is yes, it's a staff channel. + """ + channel_ids = set() + for channel in guild.channels: + if channel.type is ChannelType.category: + continue + + everyone_can_read = self.role_can_read(channel, guild.default_role) + + for role in constants.STAFF_ROLES: + role_can_read = self.role_can_read(channel, guild.get_role(role)) + if role_can_read and not everyone_can_read: + channel_ids.add(channel.id) + break + + return len(channel_ids) + + @staticmethod + def get_channel_type_counts(guild: Guild) -> str: + """Return the total amounts of the various types of channels in `guild`.""" + channel_counter = Counter(c.type for c in guild.channels) + channel_type_list = [] + for channel, count in channel_counter.items(): + channel_type = str(channel).title() + channel_type_list.append(f"{channel_type} channels: {count}") + + channel_type_list = sorted(channel_type_list) + return "\n".join(channel_type_list) + + @with_role(*constants.MODERATION_ROLES) + @command(name="roles") + async def roles_info(self, ctx: Context) -> None: + """Returns a list of all roles and their corresponding IDs.""" + # Sort the roles alphabetically and remove the @everyone role + roles = sorted(ctx.guild.roles[1:], key=lambda role: role.name) + + # Build a list + role_list = [] + for role in roles: + role_list.append(f"`{role.id}` - {role.mention}") + + # Build an embed + embed = Embed( + title=f"Role information (Total {len(roles)} role{'s' * (len(role_list) > 1)})", + colour=Colour.blurple() + ) + + await LinePaginator.paginate(role_list, ctx, embed, empty=False) + + @with_role(*constants.MODERATION_ROLES) + @command(name="role") + async def role_info(self, ctx: Context, *roles: Union[Role, str]) -> None: + """ + Return information on a role or list of roles. + + To specify multiple roles just add to the arguments, delimit roles with spaces in them using quotation marks. + """ + parsed_roles = [] + failed_roles = [] + + for role_name in roles: + if isinstance(role_name, Role): + # Role conversion has already succeeded + parsed_roles.append(role_name) + continue + + role = utils.find(lambda r: r.name.lower() == role_name.lower(), ctx.guild.roles) + + if not role: + failed_roles.append(role_name) + continue + + parsed_roles.append(role) + + if failed_roles: + await ctx.send(f":x: Could not retrieve the following roles: {', '.join(failed_roles)}") + + for role in parsed_roles: + h, s, v = colorsys.rgb_to_hsv(*role.colour.to_rgb()) + + embed = Embed( + title=f"{role.name} info", + colour=role.colour, + ) + embed.add_field(name="ID", value=role.id, inline=True) + embed.add_field(name="Colour (RGB)", value=f"#{role.colour.value:0>6x}", inline=True) + embed.add_field(name="Colour (HSV)", value=f"{h:.2f} {s:.2f} {v}", inline=True) + embed.add_field(name="Member count", value=len(role.members), inline=True) + embed.add_field(name="Position", value=role.position) + embed.add_field(name="Permission code", value=role.permissions.value, inline=True) + + await ctx.send(embed=embed) + + @command(name="server", aliases=["server_info", "guild", "guild_info"]) + async def server_info(self, ctx: Context) -> None: + """Returns an embed full of server information.""" + created = time_since(ctx.guild.created_at, precision="days") + features = ", ".join(ctx.guild.features) + region = ctx.guild.region + + roles = len(ctx.guild.roles) + member_count = ctx.guild.member_count + channel_counts = self.get_channel_type_counts(ctx.guild) + + # How many of each user status? + statuses = Counter(member.status for member in ctx.guild.members) + embed = Embed(colour=Colour.blurple()) + + # How many staff members and staff channels do we have? + staff_member_count = len(ctx.guild.get_role(constants.Roles.helpers).members) + staff_channel_count = self.get_staff_channel_count(ctx.guild) + + # Because channel_counts lacks leading whitespace, it breaks the dedent if it's inserted directly by the + # f-string. While this is correctly formated by Discord, it makes unit testing difficult. To keep the formatting + # without joining a tuple of strings we can use a Template string to insert the already-formatted channel_counts + # after the dedent is made. + embed.description = Template( + textwrap.dedent(f""" + **Server information** + Created: {created} + Voice region: {region} + Features: {features} + + **Channel counts** + $channel_counts + Staff channels: {staff_channel_count} + + **Member counts** + Members: {member_count:,} + Staff members: {staff_member_count} + Roles: {roles} + + **Member statuses** + {constants.Emojis.status_online} {statuses[Status.online]:,} + {constants.Emojis.status_idle} {statuses[Status.idle]:,} + {constants.Emojis.status_dnd} {statuses[Status.dnd]:,} + {constants.Emojis.status_offline} {statuses[Status.offline]:,} + """) + ).substitute({"channel_counts": channel_counts}) + embed.set_thumbnail(url=ctx.guild.icon_url) + + await ctx.send(embed=embed) + + @command(name="user", aliases=["user_info", "member", "member_info"]) + async def user_info(self, ctx: Context, user: Member = None) -> None: + """Returns info about a user.""" + if user is None: + user = ctx.author + + # Do a role check if this is being executed on someone other than the caller + elif user != ctx.author and not with_role_check(ctx, *constants.MODERATION_ROLES): + await ctx.send("You may not use this command on users other than yourself.") + return + + # Non-staff may only do this in #bot-commands + if not with_role_check(ctx, *constants.STAFF_ROLES): + if not ctx.channel.id == constants.Channels.bot_commands: + raise InWhitelistCheckFailure(constants.Channels.bot_commands) + + embed = await self.create_user_embed(ctx, user) + + await ctx.send(embed=embed) + + async def create_user_embed(self, ctx: Context, user: Member) -> Embed: + """Creates an embed containing information on the `user`.""" + created = time_since(user.created_at, max_units=3) + + # Custom status + custom_status = '' + for activity in user.activities: + # Check activity.state for None value if user has a custom status set + # This guards against a custom status with an emoji but no text, which will cause + # escape_markdown to raise an exception + # This can be reworked after a move to d.py 1.3.0+, which adds a CustomActivity class + if activity.name == 'Custom Status' and activity.state: + state = escape_markdown(activity.state) + custom_status = f'Status: {state}\n' + + name = str(user) + if user.nick: + name = f"{user.nick} ({name})" + + joined = time_since(user.joined_at, max_units=3) + roles = ", ".join(role.mention for role in user.roles[1:]) + + description = [ + textwrap.dedent(f""" + **User Information** + Created: {created} + Profile: {user.mention} + ID: {user.id} + {custom_status} + **Member Information** + Joined: {joined} + Roles: {roles or None} + """).strip() + ] + + # Show more verbose output in moderation channels for infractions and nominations + if ctx.channel.id in constants.MODERATION_CHANNELS: + description.append(await self.expanded_user_infraction_counts(user)) + description.append(await self.user_nomination_counts(user)) + else: + description.append(await self.basic_user_infraction_counts(user)) + + # Let's build the embed now + embed = Embed( + title=name, + description="\n\n".join(description) + ) + + embed.set_thumbnail(url=user.avatar_url_as(static_format="png")) + embed.colour = user.top_role.colour if roles else Colour.blurple() + + return embed + + async def basic_user_infraction_counts(self, member: Member) -> str: + """Gets the total and active infraction counts for the given `member`.""" + infractions = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'hidden': 'False', + 'user__id': str(member.id) + } + ) + + total_infractions = len(infractions) + active_infractions = sum(infraction['active'] for infraction in infractions) + + infraction_output = f"**Infractions**\nTotal: {total_infractions}\nActive: {active_infractions}" + + return infraction_output + + async def expanded_user_infraction_counts(self, member: Member) -> str: + """ + Gets expanded infraction counts for the given `member`. + + The counts will be split by infraction type and the number of active infractions for each type will indicated + in the output as well. + """ + infractions = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'user__id': str(member.id) + } + ) + + infraction_output = ["**Infractions**"] + if not infractions: + infraction_output.append("This user has never received an infraction.") + else: + # Count infractions split by `type` and `active` status for this user + infraction_types = set() + infraction_counter = defaultdict(int) + for infraction in infractions: + infraction_type = infraction["type"] + infraction_active = 'active' if infraction["active"] else 'inactive' + + infraction_types.add(infraction_type) + infraction_counter[f"{infraction_active} {infraction_type}"] += 1 + + # Format the output of the infraction counts + for infraction_type in sorted(infraction_types): + active_count = infraction_counter[f"active {infraction_type}"] + total_count = active_count + infraction_counter[f"inactive {infraction_type}"] + + line = f"{infraction_type.capitalize()}s: {total_count}" + if active_count: + line += f" ({active_count} active)" + + infraction_output.append(line) + + return "\n".join(infraction_output) + + async def user_nomination_counts(self, member: Member) -> str: + """Gets the active and historical nomination counts for the given `member`.""" + nominations = await self.bot.api_client.get( + 'bot/nominations', + params={ + 'user__id': str(member.id) + } + ) + + output = ["**Nominations**"] + + if not nominations: + output.append("This user has never been nominated.") + else: + count = len(nominations) + is_currently_nominated = any(nomination["active"] for nomination in nominations) + nomination_noun = "nomination" if count == 1 else "nominations" + + if is_currently_nominated: + output.append(f"This user is **currently** nominated ({count} {nomination_noun} in total).") + else: + output.append(f"This user has {count} historical {nomination_noun}, but is currently not nominated.") + + return "\n".join(output) + + def format_fields(self, mapping: Mapping[str, Any], field_width: Optional[int] = None) -> str: + """Format a mapping to be readable to a human.""" + # sorting is technically superfluous but nice if you want to look for a specific field + fields = sorted(mapping.items(), key=lambda item: item[0]) + + if field_width is None: + field_width = len(max(mapping.keys(), key=len)) + + out = '' + + for key, val in fields: + if isinstance(val, dict): + # if we have dicts inside dicts we want to apply the same treatment to the inner dictionaries + inner_width = int(field_width * 1.6) + val = '\n' + self.format_fields(val, field_width=inner_width) + + elif isinstance(val, str): + # split up text since it might be long + text = textwrap.fill(val, width=100, replace_whitespace=False) + + # indent it, I guess you could do this with `wrap` and `join` but this is nicer + val = textwrap.indent(text, ' ' * (field_width + len(': '))) + + # the first line is already indented so we `str.lstrip` it + val = val.lstrip() + + if key == 'color': + # makes the base 10 representation of a hex number readable to humans + val = hex(val) + + out += '{0:>{width}}: {1}\n'.format(key, val, width=field_width) + + # remove trailing whitespace + return out.rstrip() + + @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_ROLES) + @group(invoke_without_command=True) + @in_whitelist(channels=(constants.Channels.bot_commands,), roles=constants.STAFF_ROLES) + async def raw(self, ctx: Context, *, message: Message, json: bool = False) -> None: + """Shows information about the raw API response.""" + # I *guess* it could be deleted right as the command is invoked but I felt like it wasn't worth handling + # doing this extra request is also much easier than trying to convert everything back into a dictionary again + raw_data = await ctx.bot.http.get_message(message.channel.id, message.id) + + paginator = Paginator() + + def add_content(title: str, content: str) -> None: + paginator.add_line(f'== {title} ==\n') + # replace backticks as it breaks out of code blocks. Spaces seemed to be the most reasonable solution. + # we hope it's not close to 2000 + paginator.add_line(content.replace('```', '`` `')) + paginator.close_page() + + if message.content: + add_content('Raw message', message.content) + + transformer = pprint.pformat if json else self.format_fields + for field_name in ('embeds', 'attachments'): + data = raw_data[field_name] + + if not data: + continue + + total = len(data) + for current, item in enumerate(data, start=1): + title = f'Raw {field_name} ({current}/{total})' + add_content(title, transformer(item)) + + for page in paginator.pages: + await ctx.send(page) + + @raw.command() + async def json(self, ctx: Context, message: Message) -> None: + """Shows information about the raw API response in a copy-pasteable Python format.""" + await ctx.invoke(self.raw, message=message, json=True) + + +def setup(bot: Bot) -> None: + """Load the Information cog.""" + bot.add_cog(Information(bot)) diff --git a/bot/cogs/info/python_news.py b/bot/cogs/info/python_news.py new file mode 100644 index 000000000..0ab5738a4 --- /dev/null +++ b/bot/cogs/info/python_news.py @@ -0,0 +1,232 @@ +import logging +import typing as t +from datetime import date, datetime + +import discord +import feedparser +from bs4 import BeautifulSoup +from discord.ext.commands import Cog +from discord.ext.tasks import loop + +from bot import constants +from bot.bot import Bot +from bot.utils.webhooks import send_webhook + +PEPS_RSS_URL = "https://www.python.org/dev/peps/peps.rss/" + +RECENT_THREADS_TEMPLATE = "https://mail.python.org/archives/list/{name}@python.org/recent-threads" +THREAD_TEMPLATE_URL = "https://mail.python.org/archives/api/list/{name}@python.org/thread/{id}/" +MAILMAN_PROFILE_URL = "https://mail.python.org/archives/users/{id}/" +THREAD_URL = "https://mail.python.org/archives/list/{list}@python.org/thread/{id}/" + +AVATAR_URL = "https://www.python.org/static/opengraph-icon-200x200.png" + +log = logging.getLogger(__name__) + + +class PythonNews(Cog): + """Post new PEPs and Python News to `#python-news`.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.webhook_names = {} + self.webhook: t.Optional[discord.Webhook] = None + + self.bot.loop.create_task(self.get_webhook_names()) + self.bot.loop.create_task(self.get_webhook_and_channel()) + + async def start_tasks(self) -> None: + """Start the tasks for fetching new PEPs and mailing list messages.""" + self.fetch_new_media.start() + + @loop(minutes=20) + async def fetch_new_media(self) -> None: + """Fetch new mailing list messages and then new PEPs.""" + await self.post_maillist_news() + await self.post_pep_news() + + async def sync_maillists(self) -> None: + """Sync currently in-use maillists with API.""" + # Wait until guild is available to avoid running before everything is ready + await self.bot.wait_until_guild_available() + + response = await self.bot.api_client.get("bot/bot-settings/news") + for mail in constants.PythonNews.mail_lists: + if mail not in response["data"]: + response["data"][mail] = [] + + # Because we are handling PEPs differently, we don't include it to mail lists + if "pep" not in response["data"]: + response["data"]["pep"] = [] + + await self.bot.api_client.put("bot/bot-settings/news", json=response) + + async def get_webhook_names(self) -> None: + """Get webhook author names from maillist API.""" + await self.bot.wait_until_guild_available() + + async with self.bot.http_session.get("https://mail.python.org/archives/api/lists") as resp: + lists = await resp.json() + + for mail in lists: + if mail["name"].split("@")[0] in constants.PythonNews.mail_lists: + self.webhook_names[mail["name"].split("@")[0]] = mail["display_name"] + + async def post_pep_news(self) -> None: + """Fetch new PEPs and when they don't have announcement in #python-news, create it.""" + # Wait until everything is ready and http_session available + await self.bot.wait_until_guild_available() + await self.sync_maillists() + + async with self.bot.http_session.get(PEPS_RSS_URL) as resp: + data = feedparser.parse(await resp.text("utf-8")) + + news_listing = await self.bot.api_client.get("bot/bot-settings/news") + payload = news_listing.copy() + pep_numbers = news_listing["data"]["pep"] + + # Reverse entries to send oldest first + data["entries"].reverse() + for new in data["entries"]: + try: + new_datetime = datetime.strptime(new["published"], "%a, %d %b %Y %X %Z") + except ValueError: + log.warning(f"Wrong datetime format passed in PEP new: {new['published']}") + continue + pep_nr = new["title"].split(":")[0].split()[1] + if ( + pep_nr in pep_numbers + or new_datetime.date() < date.today() + ): + continue + + # Build an embed and send a webhook + embed = discord.Embed( + title=new["title"], + description=new["summary"], + timestamp=new_datetime, + url=new["link"], + colour=constants.Colours.soft_green + ) + embed.set_footer(text=data["feed"]["title"], icon_url=AVATAR_URL) + msg = await send_webhook( + webhook=self.webhook, + username=data["feed"]["title"], + embed=embed, + avatar_url=AVATAR_URL, + wait=True, + ) + payload["data"]["pep"].append(pep_nr) + + # Increase overall PEP new stat + self.bot.stats.incr("python_news.posted.pep") + + if msg.channel.is_news(): + log.trace("Publishing PEP annnouncement because it was in a news channel") + await msg.publish() + + # Apply new sent news to DB to avoid duplicate sending + await self.bot.api_client.put("bot/bot-settings/news", json=payload) + + async def post_maillist_news(self) -> None: + """Send new maillist threads to #python-news that is listed in configuration.""" + await self.bot.wait_until_guild_available() + await self.sync_maillists() + existing_news = await self.bot.api_client.get("bot/bot-settings/news") + payload = existing_news.copy() + + for maillist in constants.PythonNews.mail_lists: + async with self.bot.http_session.get(RECENT_THREADS_TEMPLATE.format(name=maillist)) as resp: + recents = BeautifulSoup(await resp.text(), features="lxml") + + # When a

element is present in the response then the mailing list + # has not had any activity during the current month, so therefore it + # can be ignored. + if recents.p: + continue + + for thread in recents.html.body.div.find_all("a", href=True): + # We want only these threads that have identifiers + if "latest" in thread["href"]: + continue + + thread_information, email_information = await self.get_thread_and_first_mail( + maillist, thread["href"].split("/")[-2] + ) + + try: + new_date = datetime.strptime(email_information["date"], "%Y-%m-%dT%X%z") + except ValueError: + log.warning(f"Invalid datetime from Thread email: {email_information['date']}") + continue + + if ( + thread_information["thread_id"] in existing_news["data"][maillist] + or 'Re: ' in thread_information["subject"] + or new_date.date() < date.today() + ): + continue + + content = email_information["content"] + link = THREAD_URL.format(id=thread["href"].split("/")[-2], list=maillist) + + # Build an embed and send a message to the webhook + embed = discord.Embed( + title=thread_information["subject"], + description=content[:500] + f"... [continue reading]({link})" if len(content) > 500 else content, + timestamp=new_date, + url=link, + colour=constants.Colours.soft_green + ) + embed.set_author( + name=f"{email_information['sender_name']} ({email_information['sender']['address']})", + url=MAILMAN_PROFILE_URL.format(id=email_information["sender"]["mailman_id"]), + ) + embed.set_footer( + text=f"Posted to {self.webhook_names[maillist]}", + icon_url=AVATAR_URL, + ) + msg = await send_webhook( + webhook=self.webhook, + username=self.webhook_names[maillist], + embed=embed, + avatar_url=AVATAR_URL, + wait=True, + ) + payload["data"][maillist].append(thread_information["thread_id"]) + + # Increase this specific maillist counter in stats + self.bot.stats.incr(f"python_news.posted.{maillist.replace('-', '_')}") + + if msg.channel.is_news(): + log.trace("Publishing mailing list message because it was in a news channel") + await msg.publish() + + await self.bot.api_client.put("bot/bot-settings/news", json=payload) + + async def get_thread_and_first_mail(self, maillist: str, thread_identifier: str) -> t.Tuple[t.Any, t.Any]: + """Get mail thread and first mail from mail.python.org based on `maillist` and `thread_identifier`.""" + async with self.bot.http_session.get( + THREAD_TEMPLATE_URL.format(name=maillist, id=thread_identifier) + ) as resp: + thread_information = await resp.json() + + async with self.bot.http_session.get(thread_information["starting_email"]) as resp: + email_information = await resp.json() + return thread_information, email_information + + async def get_webhook_and_channel(self) -> None: + """Storage #python-news channel Webhook and `TextChannel` to `News.webhook` and `channel`.""" + await self.bot.wait_until_guild_available() + self.webhook = await self.bot.fetch_webhook(constants.PythonNews.webhook) + + await self.start_tasks() + + def cog_unload(self) -> None: + """Stop news posting tasks on cog unload.""" + self.fetch_new_media.cancel() + + +def setup(bot: Bot) -> None: + """Add `News` cog.""" + bot.add_cog(PythonNews(bot)) diff --git a/bot/cogs/info/reddit.py b/bot/cogs/info/reddit.py new file mode 100644 index 000000000..d853ab2ea --- /dev/null +++ b/bot/cogs/info/reddit.py @@ -0,0 +1,304 @@ +import asyncio +import logging +import random +import textwrap +from collections import namedtuple +from datetime import datetime, timedelta +from typing import List + +from aiohttp import BasicAuth, ClientError +from discord import Colour, Embed, TextChannel +from discord.ext.commands import Cog, Context, group +from discord.ext.tasks import loop + +from bot.bot import Bot +from bot.constants import Channels, ERROR_REPLIES, Emojis, Reddit as RedditConfig, STAFF_ROLES, Webhooks +from bot.converters import Subreddit +from bot.decorators import with_role +from bot.pagination import LinePaginator +from bot.utils.messages import sub_clyde + +log = logging.getLogger(__name__) + +AccessToken = namedtuple("AccessToken", ["token", "expires_at"]) + + +class Reddit(Cog): + """Track subreddit posts and show detailed statistics about them.""" + + HEADERS = {"User-Agent": "python3:python-discord/bot:1.0.0 (by /u/PythonDiscord)"} + URL = "https://www.reddit.com" + OAUTH_URL = "https://oauth.reddit.com" + MAX_RETRIES = 3 + + def __init__(self, bot: Bot): + self.bot = bot + + self.webhook = None + self.access_token = None + self.client_auth = BasicAuth(RedditConfig.client_id, RedditConfig.secret) + + bot.loop.create_task(self.init_reddit_ready()) + self.auto_poster_loop.start() + + def cog_unload(self) -> None: + """Stop the loop task and revoke the access token when the cog is unloaded.""" + self.auto_poster_loop.cancel() + if self.access_token and self.access_token.expires_at > datetime.utcnow(): + asyncio.create_task(self.revoke_access_token()) + + async def init_reddit_ready(self) -> None: + """Sets the reddit webhook when the cog is loaded.""" + await self.bot.wait_until_guild_available() + if not self.webhook: + self.webhook = await self.bot.fetch_webhook(Webhooks.reddit) + + @property + def channel(self) -> TextChannel: + """Get the #reddit channel object from the bot's cache.""" + return self.bot.get_channel(Channels.reddit) + + async def get_access_token(self) -> None: + """ + Get a Reddit API OAuth2 access token and assign it to self.access_token. + + A token is valid for 1 hour. There will be MAX_RETRIES to get a token, after which the cog + will be unloaded and a ClientError raised if retrieval was still unsuccessful. + """ + for i in range(1, self.MAX_RETRIES + 1): + response = await self.bot.http_session.post( + url=f"{self.URL}/api/v1/access_token", + headers=self.HEADERS, + auth=self.client_auth, + data={ + "grant_type": "client_credentials", + "duration": "temporary" + } + ) + + if response.status == 200 and response.content_type == "application/json": + content = await response.json() + expiration = int(content["expires_in"]) - 60 # Subtract 1 minute for leeway. + self.access_token = AccessToken( + token=content["access_token"], + expires_at=datetime.utcnow() + timedelta(seconds=expiration) + ) + + log.debug(f"New token acquired; expires on UTC {self.access_token.expires_at}") + return + else: + log.debug( + f"Failed to get an access token: " + f"status {response.status} & content type {response.content_type}; " + f"retrying ({i}/{self.MAX_RETRIES})" + ) + + await asyncio.sleep(3) + + self.bot.remove_cog(self.qualified_name) + raise ClientError("Authentication with the Reddit API failed. Unloading the cog.") + + async def revoke_access_token(self) -> None: + """ + Revoke the OAuth2 access token for the Reddit API. + + For security reasons, it's good practice to revoke the token when it's no longer being used. + """ + response = await self.bot.http_session.post( + url=f"{self.URL}/api/v1/revoke_token", + headers=self.HEADERS, + auth=self.client_auth, + data={ + "token": self.access_token.token, + "token_type_hint": "access_token" + } + ) + + if response.status == 204 and response.content_type == "application/json": + self.access_token = None + else: + log.warning(f"Unable to revoke access token: status {response.status}.") + + async def fetch_posts(self, route: str, *, amount: int = 25, params: dict = None) -> List[dict]: + """A helper method to fetch a certain amount of Reddit posts at a given route.""" + # Reddit's JSON responses only provide 25 posts at most. + if not 25 >= amount > 0: + raise ValueError("Invalid amount of subreddit posts requested.") + + # Renew the token if necessary. + if not self.access_token or self.access_token.expires_at < datetime.utcnow(): + await self.get_access_token() + + url = f"{self.OAUTH_URL}/{route}" + for _ in range(self.MAX_RETRIES): + response = await self.bot.http_session.get( + url=url, + headers={**self.HEADERS, "Authorization": f"bearer {self.access_token.token}"}, + params=params + ) + if response.status == 200 and response.content_type == 'application/json': + # Got appropriate response - process and return. + content = await response.json() + posts = content["data"]["children"] + return posts[:amount] + + await asyncio.sleep(3) + + log.debug(f"Invalid response from: {url} - status code {response.status}, mimetype {response.content_type}") + return list() # Failed to get appropriate response within allowed number of retries. + + async def get_top_posts(self, subreddit: Subreddit, time: str = "all", amount: int = 5) -> Embed: + """ + Get the top amount of posts for a given subreddit within a specified timeframe. + + A time of "all" will get posts from all time, "day" will get top daily posts and "week" will get the top + weekly posts. + + The amount should be between 0 and 25 as Reddit's JSON requests only provide 25 posts at most. + """ + embed = Embed(description="") + + posts = await self.fetch_posts( + route=f"{subreddit}/top", + amount=amount, + params={"t": time} + ) + + if not posts: + embed.title = random.choice(ERROR_REPLIES) + embed.colour = Colour.red() + embed.description = ( + "Sorry! We couldn't find any posts from that subreddit. " + "If this problem persists, please let us know." + ) + + return embed + + for post in posts: + data = post["data"] + + text = data["selftext"] + if text: + text = textwrap.shorten(text, width=128, placeholder="...") + text += "\n" # Add newline to separate embed info + + ups = data["ups"] + comments = data["num_comments"] + author = data["author"] + + title = textwrap.shorten(data["title"], width=64, placeholder="...") + link = self.URL + data["permalink"] + + embed.description += ( + f"**[{title}]({link})**\n" + f"{text}" + f"{Emojis.upvotes} {ups} {Emojis.comments} {comments} {Emojis.user} {author}\n\n" + ) + + embed.colour = Colour.blurple() + return embed + + @loop() + async def auto_poster_loop(self) -> None: + """Post the top 5 posts daily, and the top 5 posts weekly.""" + # once we upgrade to d.py 1.3 this can be removed and the loop can use the `time=datetime.time.min` parameter + now = datetime.utcnow() + tomorrow = now + timedelta(days=1) + midnight_tomorrow = tomorrow.replace(hour=0, minute=0, second=0) + seconds_until = (midnight_tomorrow - now).total_seconds() + + await asyncio.sleep(seconds_until) + + await self.bot.wait_until_guild_available() + if not self.webhook: + await self.bot.fetch_webhook(Webhooks.reddit) + + if datetime.utcnow().weekday() == 0: + await self.top_weekly_posts() + # if it's a monday send the top weekly posts + + for subreddit in RedditConfig.subreddits: + top_posts = await self.get_top_posts(subreddit=subreddit, time="day") + username = sub_clyde(f"{subreddit} Top Daily Posts") + message = await self.webhook.send(username=username, embed=top_posts, wait=True) + + if message.channel.is_news(): + await message.publish() + + async def top_weekly_posts(self) -> None: + """Post a summary of the top posts.""" + for subreddit in RedditConfig.subreddits: + # Send and pin the new weekly posts. + top_posts = await self.get_top_posts(subreddit=subreddit, time="week") + username = sub_clyde(f"{subreddit} Top Weekly Posts") + message = await self.webhook.send(wait=True, username=username, embed=top_posts) + + if subreddit.lower() == "r/python": + if not self.channel: + log.warning("Failed to get #reddit channel to remove pins in the weekly loop.") + return + + # Remove the oldest pins so that only 12 remain at most. + pins = await self.channel.pins() + + while len(pins) >= 12: + await pins[-1].unpin() + del pins[-1] + + await message.pin() + + if message.channel.is_news(): + await message.publish() + + @group(name="reddit", invoke_without_command=True) + async def reddit_group(self, ctx: Context) -> None: + """View the top posts from various subreddits.""" + await ctx.send_help(ctx.command) + + @reddit_group.command(name="top") + async def top_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: + """Send the top posts of all time from a given subreddit.""" + async with ctx.typing(): + embed = await self.get_top_posts(subreddit=subreddit, time="all") + + await ctx.send(content=f"Here are the top {subreddit} posts of all time!", embed=embed) + + @reddit_group.command(name="daily") + async def daily_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: + """Send the top posts of today from a given subreddit.""" + async with ctx.typing(): + embed = await self.get_top_posts(subreddit=subreddit, time="day") + + await ctx.send(content=f"Here are today's top {subreddit} posts!", embed=embed) + + @reddit_group.command(name="weekly") + async def weekly_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: + """Send the top posts of this week from a given subreddit.""" + async with ctx.typing(): + embed = await self.get_top_posts(subreddit=subreddit, time="week") + + await ctx.send(content=f"Here are this week's top {subreddit} posts!", embed=embed) + + @with_role(*STAFF_ROLES) + @reddit_group.command(name="subreddits", aliases=("subs",)) + async def subreddits_command(self, ctx: Context) -> None: + """Send a paginated embed of all the subreddits we're relaying.""" + embed = Embed() + embed.title = "Relayed subreddits." + embed.colour = Colour.blurple() + + await LinePaginator.paginate( + RedditConfig.subreddits, + ctx, embed, + footer_text="Use the reddit commands along with these to view their posts.", + empty=False, + max_lines=15 + ) + + +def setup(bot: Bot) -> None: + """Load the Reddit cog.""" + if not RedditConfig.secret or not RedditConfig.client_id: + log.error("Credentials not provided, cog not loaded.") + return + bot.add_cog(Reddit(bot)) diff --git a/bot/cogs/info/site.py b/bot/cogs/info/site.py new file mode 100644 index 000000000..ac29daa1d --- /dev/null +++ b/bot/cogs/info/site.py @@ -0,0 +1,146 @@ +import logging + +from discord import Colour, Embed +from discord.ext.commands import Cog, Context, group + +from bot.bot import Bot +from bot.constants import URLs +from bot.pagination import LinePaginator + +log = logging.getLogger(__name__) + +PAGES_URL = f"{URLs.site_schema}{URLs.site}/pages" + + +class Site(Cog): + """Commands for linking to different parts of the site.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @group(name="site", aliases=("s",), invoke_without_command=True) + async def site_group(self, ctx: Context) -> None: + """Commands for getting info about our website.""" + await ctx.send_help(ctx.command) + + @site_group.command(name="home", aliases=("about",)) + async def site_main(self, ctx: Context) -> None: + """Info about the website itself.""" + url = f"{URLs.site_schema}{URLs.site}/" + + embed = Embed(title="Python Discord website") + embed.set_footer(text=url) + embed.colour = Colour.blurple() + embed.description = ( + f"[Our official website]({url}) is an open-source community project " + "created with Python and Django. It contains information about the server " + "itself, lets you sign up for upcoming events, has its own wiki, contains " + "a list of valuable learning resources, and much more." + ) + + await ctx.send(embed=embed) + + @site_group.command(name="resources") + async def site_resources(self, ctx: Context) -> None: + """Info about the site's Resources page.""" + learning_url = f"{PAGES_URL}/resources" + + embed = Embed(title="Resources") + embed.set_footer(text=f"{learning_url}") + embed.colour = Colour.blurple() + embed.description = ( + f"The [Resources page]({learning_url}) on our website contains a " + "list of hand-selected learning resources that we regularly recommend " + f"to both beginners and experts." + ) + + await ctx.send(embed=embed) + + @site_group.command(name="tools") + async def site_tools(self, ctx: Context) -> None: + """Info about the site's Tools page.""" + tools_url = f"{PAGES_URL}/resources/tools" + + embed = Embed(title="Tools") + embed.set_footer(text=f"{tools_url}") + embed.colour = Colour.blurple() + embed.description = ( + f"The [Tools page]({tools_url}) on our website contains a " + f"couple of the most popular tools for programming in Python." + ) + + await ctx.send(embed=embed) + + @site_group.command(name="help") + async def site_help(self, ctx: Context) -> None: + """Info about the site's Getting Help page.""" + url = f"{PAGES_URL}/resources/guides/asking-good-questions" + + embed = Embed(title="Asking Good Questions") + embed.set_footer(text=url) + embed.colour = Colour.blurple() + embed.description = ( + "Asking the right question about something that's new to you can sometimes be tricky. " + f"To help with this, we've created a [guide to asking good questions]({url}) on our website. " + "It contains everything you need to get the very best help from our community." + ) + + await ctx.send(embed=embed) + + @site_group.command(name="faq") + async def site_faq(self, ctx: Context) -> None: + """Info about the site's FAQ page.""" + url = f"{PAGES_URL}/frequently-asked-questions" + + embed = Embed(title="FAQ") + embed.set_footer(text=url) + embed.colour = Colour.blurple() + embed.description = ( + "As the largest Python community on Discord, we get hundreds of questions every day. " + "Many of these questions have been asked before. We've compiled a list of the most " + "frequently asked questions along with their answers, which can be found on " + f"our [FAQ page]({url})." + ) + + await ctx.send(embed=embed) + + @site_group.command(aliases=['r', 'rule'], name='rules') + async def site_rules(self, ctx: Context, *rules: int) -> None: + """Provides a link to all rules or, if specified, displays specific rule(s).""" + rules_embed = Embed(title='Rules', color=Colour.blurple()) + rules_embed.url = f"{PAGES_URL}/rules" + + if not rules: + # Rules were not submitted. Return the default description. + rules_embed.description = ( + "The rules and guidelines that apply to this community can be found on" + f" our [rules page]({PAGES_URL}/rules). We expect" + " all members of the community to have read and understood these." + ) + + await ctx.send(embed=rules_embed) + return + + full_rules = await self.bot.api_client.get('rules', params={'link_format': 'md'}) + invalid_indices = tuple( + pick + for pick in rules + if pick < 1 or pick > len(full_rules) + ) + + if invalid_indices: + indices = ', '.join(map(str, invalid_indices)) + await ctx.send(f":x: Invalid rule indices: {indices}") + return + + for rule in rules: + self.bot.stats.incr(f"rule_uses.{rule}") + + final_rules = tuple(f"**{pick}.** {full_rules[pick - 1]}" for pick in rules) + + await LinePaginator.paginate(final_rules, ctx, rules_embed, max_lines=3) + + +def setup(bot: Bot) -> None: + """Load the Site cog.""" + bot.add_cog(Site(bot)) diff --git a/bot/cogs/info/source.py b/bot/cogs/info/source.py new file mode 100644 index 000000000..205e0ba81 --- /dev/null +++ b/bot/cogs/info/source.py @@ -0,0 +1,141 @@ +import inspect +from pathlib import Path +from typing import Optional, Tuple, Union + +from discord import Embed +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import URLs + +SourceType = Union[commands.HelpCommand, commands.Command, commands.Cog, str, commands.ExtensionNotLoaded] + + +class SourceConverter(commands.Converter): + """Convert an argument into a help command, tag, command, or cog.""" + + async def convert(self, ctx: commands.Context, argument: str) -> SourceType: + """Convert argument into source object.""" + if argument.lower().startswith("help"): + return ctx.bot.help_command + + cog = ctx.bot.get_cog(argument) + if cog: + return cog + + cmd = ctx.bot.get_command(argument) + if cmd: + return cmd + + tags_cog = ctx.bot.get_cog("Tags") + show_tag = True + + if not tags_cog: + show_tag = False + elif argument.lower() in tags_cog._cache: + return argument.lower() + + raise commands.BadArgument( + f"Unable to convert `{argument}` to valid command{', tag,' if show_tag else ''} or Cog." + ) + + +class BotSource(commands.Cog): + """Displays information about the bot's source code.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @commands.command(name="source", aliases=("src",)) + async def source_command(self, ctx: commands.Context, *, source_item: SourceConverter = None) -> None: + """Display information and a GitHub link to the source code of a command, tag, or cog.""" + if not source_item: + embed = Embed(title="Bot's GitHub Repository") + embed.add_field(name="Repository", value=f"[Go to GitHub]({URLs.github_bot_repo})") + embed.set_thumbnail(url="https://avatars1.githubusercontent.com/u/9919") + await ctx.send(embed=embed) + return + + embed = await self.build_embed(source_item) + await ctx.send(embed=embed) + + def get_source_link(self, source_item: SourceType) -> Tuple[str, str, Optional[int]]: + """ + Build GitHub link of source item, return this link, file location and first line number. + + Raise BadArgument if `source_item` is a dynamically-created object (e.g. via internal eval). + """ + if isinstance(source_item, commands.Command): + if source_item.cog_name == "Alias": + cmd_name = source_item.callback.__name__.replace("_alias", "") + cmd = self.bot.get_command(cmd_name.replace("_", " ")) + src = cmd.callback.__code__ + filename = src.co_filename + else: + src = source_item.callback.__code__ + filename = src.co_filename + elif isinstance(source_item, str): + tags_cog = self.bot.get_cog("Tags") + filename = tags_cog._cache[source_item]["location"] + else: + src = type(source_item) + try: + filename = inspect.getsourcefile(src) + except TypeError: + raise commands.BadArgument("Cannot get source for a dynamically-created object.") + + if not isinstance(source_item, str): + try: + lines, first_line_no = inspect.getsourcelines(src) + except OSError: + raise commands.BadArgument("Cannot get source for a dynamically-created object.") + + lines_extension = f"#L{first_line_no}-L{first_line_no+len(lines)-1}" + else: + first_line_no = None + lines_extension = "" + + # Handle tag file location differently than others to avoid errors in some cases + if not first_line_no: + file_location = Path(filename).relative_to("/bot/") + else: + file_location = Path(filename).relative_to(Path.cwd()).as_posix() + + url = f"{URLs.github_bot_repo}/blob/master/{file_location}{lines_extension}" + + return url, file_location, first_line_no or None + + async def build_embed(self, source_object: SourceType) -> Optional[Embed]: + """Build embed based on source object.""" + url, location, first_line = self.get_source_link(source_object) + + if isinstance(source_object, commands.HelpCommand): + title = "Help Command" + description = source_object.__doc__.splitlines()[1] + elif isinstance(source_object, commands.Command): + if source_object.cog_name == "Alias": + cmd_name = source_object.callback.__name__.replace("_alias", "") + cmd = self.bot.get_command(cmd_name.replace("_", " ")) + description = cmd.short_doc + else: + description = source_object.short_doc + + title = f"Command: {source_object.qualified_name}" + elif isinstance(source_object, str): + title = f"Tag: {source_object}" + description = "" + else: + title = f"Cog: {source_object.qualified_name}" + description = source_object.description.splitlines()[0] + + embed = Embed(title=title, description=description) + embed.add_field(name="Source Code", value=f"[Go to GitHub]({url})") + line_text = f":{first_line}" if first_line else "" + embed.set_footer(text=f"{location}{line_text}") + + return embed + + +def setup(bot: Bot) -> None: + """Load the BotSource cog.""" + bot.add_cog(BotSource(bot)) diff --git a/bot/cogs/info/stats.py b/bot/cogs/info/stats.py new file mode 100644 index 000000000..d42f55466 --- /dev/null +++ b/bot/cogs/info/stats.py @@ -0,0 +1,129 @@ +import string +from datetime import datetime + +from discord import Member, Message, Status +from discord.ext.commands import Cog, Context +from discord.ext.tasks import loop + +from bot.bot import Bot +from bot.constants import Categories, Channels, Guild, Stats as StatConf + + +CHANNEL_NAME_OVERRIDES = { + Channels.off_topic_0: "off_topic_0", + Channels.off_topic_1: "off_topic_1", + Channels.off_topic_2: "off_topic_2", + Channels.staff_lounge: "staff_lounge" +} + +ALLOWED_CHARS = string.ascii_letters + string.digits + "_" + + +class Stats(Cog): + """A cog which provides a way to hook onto Discord events and forward to stats.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.last_presence_update = None + self.update_guild_boost.start() + + @Cog.listener() + async def on_message(self, message: Message) -> None: + """Report message events in the server to statsd.""" + if message.guild is None: + return + + if message.guild.id != Guild.id: + return + + cat = getattr(message.channel, "category", None) + if cat is not None and cat.id == Categories.modmail: + if message.channel.id != Channels.incidents: + # Do not report modmail channels to stats, there are too many + # of them for interesting statistics to be drawn out of this. + return + + reformatted_name = message.channel.name.replace('-', '_') + + if CHANNEL_NAME_OVERRIDES.get(message.channel.id): + reformatted_name = CHANNEL_NAME_OVERRIDES.get(message.channel.id) + + reformatted_name = "".join(char for char in reformatted_name if char in ALLOWED_CHARS) + + stat_name = f"channels.{reformatted_name}" + self.bot.stats.incr(stat_name) + + # Increment the total message count + self.bot.stats.incr("messages") + + @Cog.listener() + async def on_command_completion(self, ctx: Context) -> None: + """Report completed commands to statsd.""" + command_name = ctx.command.qualified_name.replace(" ", "_") + + self.bot.stats.incr(f"commands.{command_name}") + + @Cog.listener() + async def on_member_join(self, member: Member) -> None: + """Update member count stat on member join.""" + if member.guild.id != Guild.id: + return + + self.bot.stats.gauge("guild.total_members", len(member.guild.members)) + + @Cog.listener() + async def on_member_leave(self, member: Member) -> None: + """Update member count stat on member leave.""" + if member.guild.id != Guild.id: + return + + self.bot.stats.gauge("guild.total_members", len(member.guild.members)) + + @Cog.listener() + async def on_member_update(self, _before: Member, after: Member) -> None: + """Update presence estimates on member update.""" + if after.guild.id != Guild.id: + return + + if self.last_presence_update: + if (datetime.now() - self.last_presence_update).seconds < StatConf.presence_update_timeout: + return + + self.last_presence_update = datetime.now() + + online = 0 + idle = 0 + dnd = 0 + offline = 0 + + for member in after.guild.members: + if member.status is Status.online: + online += 1 + elif member.status is Status.dnd: + dnd += 1 + elif member.status is Status.idle: + idle += 1 + elif member.status is Status.offline: + offline += 1 + + self.bot.stats.gauge("guild.status.online", online) + self.bot.stats.gauge("guild.status.idle", idle) + self.bot.stats.gauge("guild.status.do_not_disturb", dnd) + self.bot.stats.gauge("guild.status.offline", offline) + + @loop(hours=1) + async def update_guild_boost(self) -> None: + """Post the server boost level and tier every hour.""" + await self.bot.wait_until_guild_available() + g = self.bot.get_guild(Guild.id) + self.bot.stats.gauge("boost.amount", g.premium_subscription_count) + self.bot.stats.gauge("boost.tier", g.premium_tier) + + def cog_unload(self) -> None: + """Stop the boost statistic task on unload of the Cog.""" + self.update_guild_boost.stop() + + +def setup(bot: Bot) -> None: + """Load the stats cog.""" + bot.add_cog(Stats(bot)) diff --git a/bot/cogs/info/tags.py b/bot/cogs/info/tags.py new file mode 100644 index 000000000..3d76c5c08 --- /dev/null +++ b/bot/cogs/info/tags.py @@ -0,0 +1,277 @@ +import logging +import re +import time +from pathlib import Path +from typing import Callable, Dict, Iterable, List, Optional + +from discord import Colour, Embed, Member +from discord.ext.commands import Cog, Context, group + +from bot import constants +from bot.bot import Bot +from bot.converters import TagNameConverter +from bot.pagination import LinePaginator +from bot.utils.messages import wait_for_deletion + +log = logging.getLogger(__name__) + +TEST_CHANNELS = ( + constants.Channels.bot_commands, + constants.Channels.helpers +) + +REGEX_NON_ALPHABET = re.compile(r"[^a-z]", re.MULTILINE & re.IGNORECASE) +FOOTER_TEXT = f"To show a tag, type {constants.Bot.prefix}tags ." + + +class Tags(Cog): + """Save new tags and fetch existing tags.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.tag_cooldowns = {} + self._cache = self.get_tags() + + @staticmethod + def get_tags() -> dict: + """Get all tags.""" + cache = {} + + base_path = Path("bot", "resources", "tags") + for file in base_path.glob("**/*"): + if file.is_file(): + tag_title = file.stem + tag = { + "title": tag_title, + "embed": { + "description": file.read_text(encoding="utf8"), + }, + "restricted_to": "developers", + "location": f"/bot/{file}" + } + + # Convert to a list to allow negative indexing. + parents = list(file.relative_to(base_path).parents) + if len(parents) > 1: + # -1 would be '.' hence -2 is used as the index. + tag["restricted_to"] = parents[-2].name + + cache[tag_title] = tag + + return cache + + @staticmethod + def check_accessibility(user: Member, tag: dict) -> bool: + """Check if user can access a tag.""" + return tag["restricted_to"].lower() in [role.name.lower() for role in user.roles] + + @staticmethod + def _fuzzy_search(search: str, target: str) -> float: + """A simple scoring algorithm based on how many letters are found / total, with order in mind.""" + current, index = 0, 0 + _search = REGEX_NON_ALPHABET.sub('', search.lower()) + _targets = iter(REGEX_NON_ALPHABET.split(target.lower())) + _target = next(_targets) + try: + while True: + while index < len(_target) and _search[current] == _target[index]: + current += 1 + index += 1 + index, _target = 0, next(_targets) + except (StopIteration, IndexError): + pass + return current / len(_search) * 100 + + def _get_suggestions(self, tag_name: str, thresholds: Optional[List[int]] = None) -> List[str]: + """Return a list of suggested tags.""" + scores: Dict[str, int] = { + tag_title: Tags._fuzzy_search(tag_name, tag['title']) + for tag_title, tag in self._cache.items() + } + + thresholds = thresholds or [100, 90, 80, 70, 60] + + for threshold in thresholds: + suggestions = [ + self._cache[tag_title] + for tag_title, matching_score in scores.items() + if matching_score >= threshold + ] + if suggestions: + return suggestions + + return [] + + def _get_tag(self, tag_name: str) -> list: + """Get a specific tag.""" + found = [self._cache.get(tag_name.lower(), None)] + if not found[0]: + return self._get_suggestions(tag_name) + return found + + def _get_tags_via_content(self, check: Callable[[Iterable], bool], keywords: str, user: Member) -> list: + """ + Search for tags via contents. + + `predicate` will be the built-in any, all, or a custom callable. Must return a bool. + """ + keywords_processed: List[str] = [] + for keyword in keywords.split(','): + keyword_sanitized = keyword.strip().casefold() + if not keyword_sanitized: + # this happens when there are leading / trailing / consecutive comma. + continue + keywords_processed.append(keyword_sanitized) + + if not keywords_processed: + # after sanitizing, we can end up with an empty list, for example when keywords is ',' + # in that case, we simply want to search for such keywords directly instead. + keywords_processed = [keywords] + + matching_tags = [] + for tag in self._cache.values(): + matches = (query in tag['embed']['description'].casefold() for query in keywords_processed) + if self.check_accessibility(user, tag) and check(matches): + matching_tags.append(tag) + + return matching_tags + + async def _send_matching_tags(self, ctx: Context, keywords: str, matching_tags: list) -> None: + """Send the result of matching tags to user.""" + if not matching_tags: + pass + elif len(matching_tags) == 1: + await ctx.send(embed=Embed().from_dict(matching_tags[0]['embed'])) + else: + is_plural = keywords.strip().count(' ') > 0 or keywords.strip().count(',') > 0 + embed = Embed( + title=f"Here are the tags containing the given keyword{'s' * is_plural}:", + description='\n'.join(tag['title'] for tag in matching_tags[:10]) + ) + await LinePaginator.paginate( + sorted(f"**»** {tag['title']}" for tag in matching_tags), + ctx, + embed, + footer_text=FOOTER_TEXT, + empty=False, + max_lines=15 + ) + + @group(name='tags', aliases=('tag', 't'), invoke_without_command=True) + async def tags_group(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None: + """Show all known tags, a single tag, or run a subcommand.""" + await ctx.invoke(self.get_command, tag_name=tag_name) + + @tags_group.group(name='search', invoke_without_command=True) + async def search_tag_content(self, ctx: Context, *, keywords: str) -> None: + """ + Search inside tags' contents for tags. Allow searching for multiple keywords separated by comma. + + Only search for tags that has ALL the keywords. + """ + matching_tags = self._get_tags_via_content(all, keywords, ctx.author) + await self._send_matching_tags(ctx, keywords, matching_tags) + + @search_tag_content.command(name='any') + async def search_tag_content_any_keyword(self, ctx: Context, *, keywords: Optional[str] = 'any') -> None: + """ + Search inside tags' contents for tags. Allow searching for multiple keywords separated by comma. + + Search for tags that has ANY of the keywords. + """ + matching_tags = self._get_tags_via_content(any, keywords or 'any', ctx.author) + await self._send_matching_tags(ctx, keywords, matching_tags) + + @tags_group.command(name='get', aliases=('show', 'g')) + async def get_command(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None: + """Get a specified tag, or a list of all tags if no tag is specified.""" + + def _command_on_cooldown(tag_name: str) -> bool: + """ + Check if the command is currently on cooldown, on a per-tag, per-channel basis. + + The cooldown duration is set in constants.py. + """ + now = time.time() + + cooldown_conditions = ( + tag_name + and tag_name in self.tag_cooldowns + and (now - self.tag_cooldowns[tag_name]["time"]) < constants.Cooldowns.tags + and self.tag_cooldowns[tag_name]["channel"] == ctx.channel.id + ) + + if cooldown_conditions: + return True + return False + + if _command_on_cooldown(tag_name): + time_elapsed = time.time() - self.tag_cooldowns[tag_name]["time"] + time_left = constants.Cooldowns.tags - time_elapsed + log.info( + f"{ctx.author} tried to get the '{tag_name}' tag, but the tag is on cooldown. " + f"Cooldown ends in {time_left:.1f} seconds." + ) + return + + if tag_name is not None: + temp_founds = self._get_tag(tag_name) + + founds = [] + + for found_tag in temp_founds: + if self.check_accessibility(ctx.author, found_tag): + founds.append(found_tag) + + if len(founds) == 1: + tag = founds[0] + if ctx.channel.id not in TEST_CHANNELS: + self.tag_cooldowns[tag_name] = { + "time": time.time(), + "channel": ctx.channel.id + } + + self.bot.stats.incr(f"tags.usages.{tag['title'].replace('-', '_')}") + + await wait_for_deletion( + await ctx.send(embed=Embed.from_dict(tag['embed'])), + [ctx.author.id], + client=self.bot + ) + elif founds and len(tag_name) >= 3: + await wait_for_deletion( + await ctx.send( + embed=Embed( + title='Did you mean ...', + description='\n'.join(tag['title'] for tag in founds[:10]) + ) + ), + [ctx.author.id], + client=self.bot + ) + + else: + tags = self._cache.values() + if not tags: + await ctx.send(embed=Embed( + description="**There are no tags in the database!**", + colour=Colour.red() + )) + else: + embed: Embed = Embed(title="**Current tags**") + await LinePaginator.paginate( + sorted( + f"**»** {tag['title']}" for tag in tags + if self.check_accessibility(ctx.author, tag) + ), + ctx, + embed, + footer_text=FOOTER_TEXT, + empty=False, + max_lines=15 + ) + + +def setup(bot: Bot) -> None: + """Load the Tags cog.""" + bot.add_cog(Tags(bot)) diff --git a/bot/cogs/info/wolfram.py b/bot/cogs/info/wolfram.py new file mode 100644 index 000000000..e6cae3bb8 --- /dev/null +++ b/bot/cogs/info/wolfram.py @@ -0,0 +1,280 @@ +import logging +from io import BytesIO +from typing import Callable, List, Optional, Tuple +from urllib import parse + +import discord +from dateutil.relativedelta import relativedelta +from discord import Embed +from discord.ext import commands +from discord.ext.commands import BucketType, Cog, Context, check, group + +from bot.bot import Bot +from bot.constants import Colours, STAFF_ROLES, Wolfram +from bot.pagination import ImagePaginator +from bot.utils.time import humanize_delta + +log = logging.getLogger(__name__) + +APPID = Wolfram.key +DEFAULT_OUTPUT_FORMAT = "JSON" +QUERY = "http://api.wolframalpha.com/v2/{request}?{data}" +WOLF_IMAGE = "https://www.symbols.com/gi.php?type=1&id=2886&i=1" + +MAX_PODS = 20 + +# Allows for 10 wolfram calls pr user pr day +usercd = commands.CooldownMapping.from_cooldown(Wolfram.user_limit_day, 60*60*24, BucketType.user) + +# Allows for max api requests / days in month per day for the entire guild (Temporary) +guildcd = commands.CooldownMapping.from_cooldown(Wolfram.guild_limit_day, 60*60*24, BucketType.guild) + + +async def send_embed( + ctx: Context, + message_txt: str, + colour: int = Colours.soft_red, + footer: str = None, + img_url: str = None, + f: discord.File = None +) -> None: + """Generate & send a response embed with Wolfram as the author.""" + embed = Embed(colour=colour) + embed.description = message_txt + embed.set_author(name="Wolfram Alpha", + icon_url=WOLF_IMAGE, + url="https://www.wolframalpha.com/") + if footer: + embed.set_footer(text=footer) + + if img_url: + embed.set_image(url=img_url) + + await ctx.send(embed=embed, file=f) + + +def custom_cooldown(*ignore: List[int]) -> Callable: + """ + Implement per-user and per-guild cooldowns for requests to the Wolfram API. + + A list of roles may be provided to ignore the per-user cooldown + """ + async def predicate(ctx: Context) -> bool: + if ctx.invoked_with == 'help': + # if the invoked command is help we don't want to increase the ratelimits since it's not actually + # invoking the command/making a request, so instead just check if the user/guild are on cooldown. + guild_cooldown = not guildcd.get_bucket(ctx.message).get_tokens() == 0 # if guild is on cooldown + if not any(r.id in ignore for r in ctx.author.roles): # check user bucket if user is not ignored + return guild_cooldown and not usercd.get_bucket(ctx.message).get_tokens() == 0 + return guild_cooldown + + user_bucket = usercd.get_bucket(ctx.message) + + if all(role.id not in ignore for role in ctx.author.roles): + user_rate = user_bucket.update_rate_limit() + + if user_rate: + # Can't use api; cause: member limit + delta = relativedelta(seconds=int(user_rate)) + cooldown = humanize_delta(delta) + message = ( + "You've used up your limit for Wolfram|Alpha requests.\n" + f"Cooldown: {cooldown}" + ) + await send_embed(ctx, message) + return False + + guild_bucket = guildcd.get_bucket(ctx.message) + guild_rate = guild_bucket.update_rate_limit() + + # Repr has a token attribute to read requests left + log.debug(guild_bucket) + + if guild_rate: + # Can't use api; cause: guild limit + message = ( + "The max limit of requests for the server has been reached for today.\n" + f"Cooldown: {int(guild_rate)}" + ) + await send_embed(ctx, message) + return False + + return True + return check(predicate) + + +async def get_pod_pages(ctx: Context, bot: Bot, query: str) -> Optional[List[Tuple]]: + """Get the Wolfram API pod pages for the provided query.""" + async with ctx.channel.typing(): + url_str = parse.urlencode({ + "input": query, + "appid": APPID, + "output": DEFAULT_OUTPUT_FORMAT, + "format": "image,plaintext" + }) + request_url = QUERY.format(request="query", data=url_str) + + async with bot.http_session.get(request_url) as response: + json = await response.json(content_type='text/plain') + + result = json["queryresult"] + + if result["error"]: + # API key not set up correctly + if result["error"]["msg"] == "Invalid appid": + message = "Wolfram API key is invalid or missing." + log.warning( + "API key seems to be missing, or invalid when " + f"processing a wolfram request: {url_str}, Response: {json}" + ) + await send_embed(ctx, message) + return + + message = "Something went wrong internally with your request, please notify staff!" + log.warning(f"Something went wrong getting a response from wolfram: {url_str}, Response: {json}") + await send_embed(ctx, message) + return + + if not result["success"]: + message = f"I couldn't find anything for {query}." + await send_embed(ctx, message) + return + + if not result["numpods"]: + message = "Could not find any results." + await send_embed(ctx, message) + return + + pods = result["pods"] + pages = [] + for pod in pods[:MAX_PODS]: + subs = pod.get("subpods") + + for sub in subs: + title = sub.get("title") or sub.get("plaintext") or sub.get("id", "") + img = sub["img"]["src"] + pages.append((title, img)) + return pages + + +class Wolfram(Cog): + """Commands for interacting with the Wolfram|Alpha API.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @group(name="wolfram", aliases=("wolf", "wa"), invoke_without_command=True) + @custom_cooldown(*STAFF_ROLES) + async def wolfram_command(self, ctx: Context, *, query: str) -> None: + """Requests all answers on a single image, sends an image of all related pods.""" + url_str = parse.urlencode({ + "i": query, + "appid": APPID, + }) + query = QUERY.format(request="simple", data=url_str) + + # Give feedback that the bot is working. + async with ctx.channel.typing(): + async with self.bot.http_session.get(query) as response: + status = response.status + image_bytes = await response.read() + + f = discord.File(BytesIO(image_bytes), filename="image.png") + image_url = "attachment://image.png" + + if status == 501: + message = "Failed to get response" + footer = "" + color = Colours.soft_red + elif status == 400: + message = "No input found" + footer = "" + color = Colours.soft_red + elif status == 403: + message = "Wolfram API key is invalid or missing." + footer = "" + color = Colours.soft_red + else: + message = "" + footer = "View original for a bigger picture." + color = Colours.soft_orange + + # Sends a "blank" embed if no request is received, unsure how to fix + await send_embed(ctx, message, color, footer=footer, img_url=image_url, f=f) + + @wolfram_command.command(name="page", aliases=("pa", "p")) + @custom_cooldown(*STAFF_ROLES) + async def wolfram_page_command(self, ctx: Context, *, query: str) -> None: + """ + Requests a drawn image of given query. + + Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc. + """ + pages = await get_pod_pages(ctx, self.bot, query) + + if not pages: + return + + embed = Embed() + embed.set_author(name="Wolfram Alpha", + icon_url=WOLF_IMAGE, + url="https://www.wolframalpha.com/") + embed.colour = Colours.soft_orange + + await ImagePaginator.paginate(pages, ctx, embed) + + @wolfram_command.command(name="cut", aliases=("c",)) + @custom_cooldown(*STAFF_ROLES) + async def wolfram_cut_command(self, ctx: Context, *, query: str) -> None: + """ + Requests a drawn image of given query. + + Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc. + """ + pages = await get_pod_pages(ctx, self.bot, query) + + if not pages: + return + + if len(pages) >= 2: + page = pages[1] + else: + page = pages[0] + + await send_embed(ctx, page[0], colour=Colours.soft_orange, img_url=page[1]) + + @wolfram_command.command(name="short", aliases=("sh", "s")) + @custom_cooldown(*STAFF_ROLES) + async def wolfram_short_command(self, ctx: Context, *, query: str) -> None: + """Requests an answer to a simple question.""" + url_str = parse.urlencode({ + "i": query, + "appid": APPID, + }) + query = QUERY.format(request="result", data=url_str) + + # Give feedback that the bot is working. + async with ctx.channel.typing(): + async with self.bot.http_session.get(query) as response: + status = response.status + response_text = await response.text() + + if status == 501: + message = "Failed to get response" + color = Colours.soft_red + elif status == 400: + message = "No input found" + color = Colours.soft_red + elif response_text == "Error 1: Invalid appid": + message = "Wolfram API key is invalid or missing." + color = Colours.soft_red + else: + message = response_text + color = Colours.soft_orange + + await send_embed(ctx, message, color) + + +def setup(bot: Bot) -> None: + """Load the Wolfram cog.""" + bot.add_cog(Wolfram(bot)) diff --git a/bot/cogs/information.py b/bot/cogs/information.py deleted file mode 100644 index 8982196d1..000000000 --- a/bot/cogs/information.py +++ /dev/null @@ -1,422 +0,0 @@ -import colorsys -import logging -import pprint -import textwrap -from collections import Counter, defaultdict -from string import Template -from typing import Any, Mapping, Optional, Union - -from discord import ChannelType, Colour, Embed, Guild, Member, Message, Role, Status, utils -from discord.abc import GuildChannel -from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group -from discord.utils import escape_markdown - -from bot import constants -from bot.bot import Bot -from bot.decorators import in_whitelist, with_role -from bot.pagination import LinePaginator -from bot.utils.checks import InWhitelistCheckFailure, cooldown_with_role_bypass, with_role_check -from bot.utils.time import time_since - -log = logging.getLogger(__name__) - - -class Information(Cog): - """A cog with commands for generating embeds with server info, such as server stats and user info.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @staticmethod - def role_can_read(channel: GuildChannel, role: Role) -> bool: - """Return True if `role` can read messages in `channel`.""" - overwrites = channel.overwrites_for(role) - return overwrites.read_messages is True - - def get_staff_channel_count(self, guild: Guild) -> int: - """ - Get the number of channels that are staff-only. - - We need to know two things about a channel: - - Does the @everyone role have explicit read deny permissions? - - Do staff roles have explicit read allow permissions? - - If the answer to both of these questions is yes, it's a staff channel. - """ - channel_ids = set() - for channel in guild.channels: - if channel.type is ChannelType.category: - continue - - everyone_can_read = self.role_can_read(channel, guild.default_role) - - for role in constants.STAFF_ROLES: - role_can_read = self.role_can_read(channel, guild.get_role(role)) - if role_can_read and not everyone_can_read: - channel_ids.add(channel.id) - break - - return len(channel_ids) - - @staticmethod - def get_channel_type_counts(guild: Guild) -> str: - """Return the total amounts of the various types of channels in `guild`.""" - channel_counter = Counter(c.type for c in guild.channels) - channel_type_list = [] - for channel, count in channel_counter.items(): - channel_type = str(channel).title() - channel_type_list.append(f"{channel_type} channels: {count}") - - channel_type_list = sorted(channel_type_list) - return "\n".join(channel_type_list) - - @with_role(*constants.MODERATION_ROLES) - @command(name="roles") - async def roles_info(self, ctx: Context) -> None: - """Returns a list of all roles and their corresponding IDs.""" - # Sort the roles alphabetically and remove the @everyone role - roles = sorted(ctx.guild.roles[1:], key=lambda role: role.name) - - # Build a list - role_list = [] - for role in roles: - role_list.append(f"`{role.id}` - {role.mention}") - - # Build an embed - embed = Embed( - title=f"Role information (Total {len(roles)} role{'s' * (len(role_list) > 1)})", - colour=Colour.blurple() - ) - - await LinePaginator.paginate(role_list, ctx, embed, empty=False) - - @with_role(*constants.MODERATION_ROLES) - @command(name="role") - async def role_info(self, ctx: Context, *roles: Union[Role, str]) -> None: - """ - Return information on a role or list of roles. - - To specify multiple roles just add to the arguments, delimit roles with spaces in them using quotation marks. - """ - parsed_roles = [] - failed_roles = [] - - for role_name in roles: - if isinstance(role_name, Role): - # Role conversion has already succeeded - parsed_roles.append(role_name) - continue - - role = utils.find(lambda r: r.name.lower() == role_name.lower(), ctx.guild.roles) - - if not role: - failed_roles.append(role_name) - continue - - parsed_roles.append(role) - - if failed_roles: - await ctx.send(f":x: Could not retrieve the following roles: {', '.join(failed_roles)}") - - for role in parsed_roles: - h, s, v = colorsys.rgb_to_hsv(*role.colour.to_rgb()) - - embed = Embed( - title=f"{role.name} info", - colour=role.colour, - ) - embed.add_field(name="ID", value=role.id, inline=True) - embed.add_field(name="Colour (RGB)", value=f"#{role.colour.value:0>6x}", inline=True) - embed.add_field(name="Colour (HSV)", value=f"{h:.2f} {s:.2f} {v}", inline=True) - embed.add_field(name="Member count", value=len(role.members), inline=True) - embed.add_field(name="Position", value=role.position) - embed.add_field(name="Permission code", value=role.permissions.value, inline=True) - - await ctx.send(embed=embed) - - @command(name="server", aliases=["server_info", "guild", "guild_info"]) - async def server_info(self, ctx: Context) -> None: - """Returns an embed full of server information.""" - created = time_since(ctx.guild.created_at, precision="days") - features = ", ".join(ctx.guild.features) - region = ctx.guild.region - - roles = len(ctx.guild.roles) - member_count = ctx.guild.member_count - channel_counts = self.get_channel_type_counts(ctx.guild) - - # How many of each user status? - statuses = Counter(member.status for member in ctx.guild.members) - embed = Embed(colour=Colour.blurple()) - - # How many staff members and staff channels do we have? - staff_member_count = len(ctx.guild.get_role(constants.Roles.helpers).members) - staff_channel_count = self.get_staff_channel_count(ctx.guild) - - # Because channel_counts lacks leading whitespace, it breaks the dedent if it's inserted directly by the - # f-string. While this is correctly formated by Discord, it makes unit testing difficult. To keep the formatting - # without joining a tuple of strings we can use a Template string to insert the already-formatted channel_counts - # after the dedent is made. - embed.description = Template( - textwrap.dedent(f""" - **Server information** - Created: {created} - Voice region: {region} - Features: {features} - - **Channel counts** - $channel_counts - Staff channels: {staff_channel_count} - - **Member counts** - Members: {member_count:,} - Staff members: {staff_member_count} - Roles: {roles} - - **Member statuses** - {constants.Emojis.status_online} {statuses[Status.online]:,} - {constants.Emojis.status_idle} {statuses[Status.idle]:,} - {constants.Emojis.status_dnd} {statuses[Status.dnd]:,} - {constants.Emojis.status_offline} {statuses[Status.offline]:,} - """) - ).substitute({"channel_counts": channel_counts}) - embed.set_thumbnail(url=ctx.guild.icon_url) - - await ctx.send(embed=embed) - - @command(name="user", aliases=["user_info", "member", "member_info"]) - async def user_info(self, ctx: Context, user: Member = None) -> None: - """Returns info about a user.""" - if user is None: - user = ctx.author - - # Do a role check if this is being executed on someone other than the caller - elif user != ctx.author and not with_role_check(ctx, *constants.MODERATION_ROLES): - await ctx.send("You may not use this command on users other than yourself.") - return - - # Non-staff may only do this in #bot-commands - if not with_role_check(ctx, *constants.STAFF_ROLES): - if not ctx.channel.id == constants.Channels.bot_commands: - raise InWhitelistCheckFailure(constants.Channels.bot_commands) - - embed = await self.create_user_embed(ctx, user) - - await ctx.send(embed=embed) - - async def create_user_embed(self, ctx: Context, user: Member) -> Embed: - """Creates an embed containing information on the `user`.""" - created = time_since(user.created_at, max_units=3) - - # Custom status - custom_status = '' - for activity in user.activities: - # Check activity.state for None value if user has a custom status set - # This guards against a custom status with an emoji but no text, which will cause - # escape_markdown to raise an exception - # This can be reworked after a move to d.py 1.3.0+, which adds a CustomActivity class - if activity.name == 'Custom Status' and activity.state: - state = escape_markdown(activity.state) - custom_status = f'Status: {state}\n' - - name = str(user) - if user.nick: - name = f"{user.nick} ({name})" - - joined = time_since(user.joined_at, max_units=3) - roles = ", ".join(role.mention for role in user.roles[1:]) - - description = [ - textwrap.dedent(f""" - **User Information** - Created: {created} - Profile: {user.mention} - ID: {user.id} - {custom_status} - **Member Information** - Joined: {joined} - Roles: {roles or None} - """).strip() - ] - - # Show more verbose output in moderation channels for infractions and nominations - if ctx.channel.id in constants.MODERATION_CHANNELS: - description.append(await self.expanded_user_infraction_counts(user)) - description.append(await self.user_nomination_counts(user)) - else: - description.append(await self.basic_user_infraction_counts(user)) - - # Let's build the embed now - embed = Embed( - title=name, - description="\n\n".join(description) - ) - - embed.set_thumbnail(url=user.avatar_url_as(static_format="png")) - embed.colour = user.top_role.colour if roles else Colour.blurple() - - return embed - - async def basic_user_infraction_counts(self, member: Member) -> str: - """Gets the total and active infraction counts for the given `member`.""" - infractions = await self.bot.api_client.get( - 'bot/infractions', - params={ - 'hidden': 'False', - 'user__id': str(member.id) - } - ) - - total_infractions = len(infractions) - active_infractions = sum(infraction['active'] for infraction in infractions) - - infraction_output = f"**Infractions**\nTotal: {total_infractions}\nActive: {active_infractions}" - - return infraction_output - - async def expanded_user_infraction_counts(self, member: Member) -> str: - """ - Gets expanded infraction counts for the given `member`. - - The counts will be split by infraction type and the number of active infractions for each type will indicated - in the output as well. - """ - infractions = await self.bot.api_client.get( - 'bot/infractions', - params={ - 'user__id': str(member.id) - } - ) - - infraction_output = ["**Infractions**"] - if not infractions: - infraction_output.append("This user has never received an infraction.") - else: - # Count infractions split by `type` and `active` status for this user - infraction_types = set() - infraction_counter = defaultdict(int) - for infraction in infractions: - infraction_type = infraction["type"] - infraction_active = 'active' if infraction["active"] else 'inactive' - - infraction_types.add(infraction_type) - infraction_counter[f"{infraction_active} {infraction_type}"] += 1 - - # Format the output of the infraction counts - for infraction_type in sorted(infraction_types): - active_count = infraction_counter[f"active {infraction_type}"] - total_count = active_count + infraction_counter[f"inactive {infraction_type}"] - - line = f"{infraction_type.capitalize()}s: {total_count}" - if active_count: - line += f" ({active_count} active)" - - infraction_output.append(line) - - return "\n".join(infraction_output) - - async def user_nomination_counts(self, member: Member) -> str: - """Gets the active and historical nomination counts for the given `member`.""" - nominations = await self.bot.api_client.get( - 'bot/nominations', - params={ - 'user__id': str(member.id) - } - ) - - output = ["**Nominations**"] - - if not nominations: - output.append("This user has never been nominated.") - else: - count = len(nominations) - is_currently_nominated = any(nomination["active"] for nomination in nominations) - nomination_noun = "nomination" if count == 1 else "nominations" - - if is_currently_nominated: - output.append(f"This user is **currently** nominated ({count} {nomination_noun} in total).") - else: - output.append(f"This user has {count} historical {nomination_noun}, but is currently not nominated.") - - return "\n".join(output) - - def format_fields(self, mapping: Mapping[str, Any], field_width: Optional[int] = None) -> str: - """Format a mapping to be readable to a human.""" - # sorting is technically superfluous but nice if you want to look for a specific field - fields = sorted(mapping.items(), key=lambda item: item[0]) - - if field_width is None: - field_width = len(max(mapping.keys(), key=len)) - - out = '' - - for key, val in fields: - if isinstance(val, dict): - # if we have dicts inside dicts we want to apply the same treatment to the inner dictionaries - inner_width = int(field_width * 1.6) - val = '\n' + self.format_fields(val, field_width=inner_width) - - elif isinstance(val, str): - # split up text since it might be long - text = textwrap.fill(val, width=100, replace_whitespace=False) - - # indent it, I guess you could do this with `wrap` and `join` but this is nicer - val = textwrap.indent(text, ' ' * (field_width + len(': '))) - - # the first line is already indented so we `str.lstrip` it - val = val.lstrip() - - if key == 'color': - # makes the base 10 representation of a hex number readable to humans - val = hex(val) - - out += '{0:>{width}}: {1}\n'.format(key, val, width=field_width) - - # remove trailing whitespace - return out.rstrip() - - @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_ROLES) - @group(invoke_without_command=True) - @in_whitelist(channels=(constants.Channels.bot_commands,), roles=constants.STAFF_ROLES) - async def raw(self, ctx: Context, *, message: Message, json: bool = False) -> None: - """Shows information about the raw API response.""" - # I *guess* it could be deleted right as the command is invoked but I felt like it wasn't worth handling - # doing this extra request is also much easier than trying to convert everything back into a dictionary again - raw_data = await ctx.bot.http.get_message(message.channel.id, message.id) - - paginator = Paginator() - - def add_content(title: str, content: str) -> None: - paginator.add_line(f'== {title} ==\n') - # replace backticks as it breaks out of code blocks. Spaces seemed to be the most reasonable solution. - # we hope it's not close to 2000 - paginator.add_line(content.replace('```', '`` `')) - paginator.close_page() - - if message.content: - add_content('Raw message', message.content) - - transformer = pprint.pformat if json else self.format_fields - for field_name in ('embeds', 'attachments'): - data = raw_data[field_name] - - if not data: - continue - - total = len(data) - for current, item in enumerate(data, start=1): - title = f'Raw {field_name} ({current}/{total})' - add_content(title, transformer(item)) - - for page in paginator.pages: - await ctx.send(page) - - @raw.command() - async def json(self, ctx: Context, message: Message) -> None: - """Shows information about the raw API response in a copy-pasteable Python format.""" - await ctx.invoke(self.raw, message=message, json=True) - - -def setup(bot: Bot) -> None: - """Load the Information cog.""" - bot.add_cog(Information(bot)) diff --git a/bot/cogs/jams.py b/bot/cogs/jams.py deleted file mode 100644 index b3102db2f..000000000 --- a/bot/cogs/jams.py +++ /dev/null @@ -1,150 +0,0 @@ -import logging -import typing as t - -from discord import CategoryChannel, Guild, Member, PermissionOverwrite, Role -from discord.ext import commands -from more_itertools import unique_everseen - -from bot.bot import Bot -from bot.constants import Roles -from bot.decorators import with_role - -log = logging.getLogger(__name__) - -MAX_CHANNELS = 50 -CATEGORY_NAME = "Code Jam" - - -class CodeJams(commands.Cog): - """Manages the code-jam related parts of our server.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @commands.command() - @with_role(Roles.admins) - async def createteam(self, ctx: commands.Context, team_name: str, members: commands.Greedy[Member]) -> None: - """ - Create team channels (voice and text) in the Code Jams category, assign roles, and add overwrites for the team. - - The first user passed will always be the team leader. - """ - # Ignore duplicate members - members = list(unique_everseen(members)) - - # We had a little issue during Code Jam 4 here, the greedy converter did it's job - # and ignored anything which wasn't a valid argument which left us with teams of - # two members or at some times even 1 member. This fixes that by checking that there - # are always 3 members in the members list. - if len(members) < 3: - await ctx.send( - ":no_entry_sign: One of your arguments was invalid\n" - f"There must be a minimum of 3 valid members in your team. Found: {len(members)}" - " members" - ) - return - - team_channel = await self.create_channels(ctx.guild, team_name, members) - await self.add_roles(ctx.guild, members) - - await ctx.send( - f":ok_hand: Team created: {team_channel}\n" - f"**Team Leader:** {members[0].mention}\n" - f"**Team Members:** {' '.join(member.mention for member in members[1:])}" - ) - - async def get_category(self, guild: Guild) -> CategoryChannel: - """ - Return a code jam category. - - If all categories are full or none exist, create a new category. - """ - for category in guild.categories: - # Need 2 available spaces: one for the text channel and one for voice. - if category.name == CATEGORY_NAME and MAX_CHANNELS - len(category.channels) >= 2: - return category - - return await self.create_category(guild) - - @staticmethod - async def create_category(guild: Guild) -> CategoryChannel: - """Create a new code jam category and return it.""" - log.info("Creating a new code jam category.") - - category_overwrites = { - guild.default_role: PermissionOverwrite(read_messages=False), - guild.me: PermissionOverwrite(read_messages=True) - } - - return await guild.create_category_channel( - CATEGORY_NAME, - overwrites=category_overwrites, - reason="It's code jam time!" - ) - - @staticmethod - def get_overwrites(members: t.List[Member], guild: Guild) -> t.Dict[t.Union[Member, Role], PermissionOverwrite]: - """Get code jam team channels permission overwrites.""" - # First member is always the team leader - team_channel_overwrites = { - members[0]: PermissionOverwrite( - manage_messages=True, - read_messages=True, - manage_webhooks=True, - connect=True - ), - guild.default_role: PermissionOverwrite(read_messages=False, connect=False), - guild.get_role(Roles.verified): PermissionOverwrite( - read_messages=False, - connect=False - ) - } - - # Rest of members should just have read_messages - for member in members[1:]: - team_channel_overwrites[member] = PermissionOverwrite( - read_messages=True, - connect=True - ) - - return team_channel_overwrites - - async def create_channels(self, guild: Guild, team_name: str, members: t.List[Member]) -> str: - """Create team text and voice channels. Return the mention for the text channel.""" - # Get permission overwrites and category - team_channel_overwrites = self.get_overwrites(members, guild) - code_jam_category = await self.get_category(guild) - - # Create a text channel for the team - team_channel = await guild.create_text_channel( - team_name, - overwrites=team_channel_overwrites, - category=code_jam_category - ) - - # Create a voice channel for the team - team_voice_name = " ".join(team_name.split("-")).title() - - await guild.create_voice_channel( - team_voice_name, - overwrites=team_channel_overwrites, - category=code_jam_category - ) - - return team_channel.mention - - @staticmethod - async def add_roles(guild: Guild, members: t.List[Member]) -> None: - """Assign team leader and jammer roles.""" - # Assign team leader role - await members[0].add_roles(guild.get_role(Roles.team_leaders)) - - # Assign rest of roles - jammer_role = guild.get_role(Roles.jammers) - for member in members: - await member.add_roles(jammer_role) - - -def setup(bot: Bot) -> None: - """Load the CodeJams cog.""" - bot.add_cog(CodeJams(bot)) diff --git a/bot/cogs/logging.py b/bot/cogs/logging.py deleted file mode 100644 index 94fa2b139..000000000 --- a/bot/cogs/logging.py +++ /dev/null @@ -1,42 +0,0 @@ -import logging - -from discord import Embed -from discord.ext.commands import Cog - -from bot.bot import Bot -from bot.constants import Channels, DEBUG_MODE - - -log = logging.getLogger(__name__) - - -class Logging(Cog): - """Debug logging module.""" - - def __init__(self, bot: Bot): - self.bot = bot - - self.bot.loop.create_task(self.startup_greeting()) - - async def startup_greeting(self) -> None: - """Announce our presence to the configured devlog channel.""" - await self.bot.wait_until_guild_available() - log.info("Bot connected!") - - embed = Embed(description="Connected!") - embed.set_author( - name="Python Bot", - url="https://github.com/python-discord/bot", - icon_url=( - "https://raw.githubusercontent.com/" - "python-discord/branding/master/logos/logo_circle/logo_circle_large.png" - ) - ) - - if not DEBUG_MODE: - await self.bot.get_channel(Channels.dev_log).send(embed=embed) - - -def setup(bot: Bot) -> None: - """Load the Logging cog.""" - bot.add_cog(Logging(bot)) diff --git a/bot/cogs/moderation/__init__.py b/bot/cogs/moderation/__init__.py index 995187ef0..aad1f3c26 100644 --- a/bot/cogs/moderation/__init__.py +++ b/bot/cogs/moderation/__init__.py @@ -1,11 +1,11 @@ from bot.bot import Bot from .incidents import Incidents -from .infractions import Infractions -from .management import ModManagement +from .infraction.infractions import Infractions +from .infraction.management import ModManagement +from .infraction.superstarify import Superstarify from .modlog import ModLog from .silence import Silence from .slowmode import Slowmode -from .superstarify import Superstarify def setup(bot: Bot) -> None: diff --git a/bot/cogs/moderation/defcon.py b/bot/cogs/moderation/defcon.py new file mode 100644 index 000000000..4c0ad5914 --- /dev/null +++ b/bot/cogs/moderation/defcon.py @@ -0,0 +1,258 @@ +from __future__ import annotations + +import logging +from collections import namedtuple +from datetime import datetime, timedelta +from enum import Enum + +from discord import Colour, Embed, Member +from discord.ext.commands import Cog, Context, group + +from bot.bot import Bot +from bot.cogs.moderation import ModLog +from bot.constants import Channels, Colours, Emojis, Event, Icons, Roles +from bot.decorators import with_role + +log = logging.getLogger(__name__) + +REJECTION_MESSAGE = """ +Hi, {user} - Thanks for your interest in our server! + +Due to a current (or detected) cyberattack on our community, we've limited access to the server for new accounts. Since +your account is relatively new, we're unable to provide access to the server at this time. + +Even so, thanks for joining! We're very excited at the possibility of having you here, and we hope that this situation +will be resolved soon. In the meantime, please feel free to peruse the resources on our site at +, and have a nice day! +""" + +BASE_CHANNEL_TOPIC = "Python Discord Defense Mechanism" + + +class Action(Enum): + """Defcon Action.""" + + ActionInfo = namedtuple('LogInfoDetails', ['icon', 'color', 'template']) + + ENABLED = ActionInfo(Icons.defcon_enabled, Colours.soft_green, "**Days:** {days}\n\n") + DISABLED = ActionInfo(Icons.defcon_disabled, Colours.soft_red, "") + UPDATED = ActionInfo(Icons.defcon_updated, Colour.blurple(), "**Days:** {days}\n\n") + + +class Defcon(Cog): + """Time-sensitive server defense mechanisms.""" + + days = None # type: timedelta + enabled = False # type: bool + + def __init__(self, bot: Bot): + self.bot = bot + self.channel = None + self.days = timedelta(days=0) + + self.bot.loop.create_task(self.sync_settings()) + + @property + def mod_log(self) -> ModLog: + """Get currently loaded ModLog cog instance.""" + return self.bot.get_cog("ModLog") + + async def sync_settings(self) -> None: + """On cog load, try to synchronize DEFCON settings to the API.""" + await self.bot.wait_until_guild_available() + self.channel = await self.bot.fetch_channel(Channels.defcon) + + try: + response = await self.bot.api_client.get('bot/bot-settings/defcon') + data = response['data'] + + except Exception: # Yikes! + log.exception("Unable to get DEFCON settings!") + await self.bot.get_channel(Channels.dev_log).send( + f"<@&{Roles.admins}> **WARNING**: Unable to get DEFCON settings!" + ) + + else: + if data["enabled"]: + self.enabled = True + self.days = timedelta(days=data["days"]) + log.info(f"DEFCON enabled: {self.days.days} days") + + else: + self.enabled = False + self.days = timedelta(days=0) + log.info("DEFCON disabled") + + await self.update_channel_topic() + + @Cog.listener() + async def on_member_join(self, member: Member) -> None: + """If DEFCON is enabled, check newly joining users to see if they meet the account age threshold.""" + if self.enabled and self.days.days > 0: + now = datetime.utcnow() + + if now - member.created_at < self.days: + log.info(f"Rejecting user {member}: Account is too new and DEFCON is enabled") + + message_sent = False + + try: + await member.send(REJECTION_MESSAGE.format(user=member.mention)) + + message_sent = True + except Exception: + log.exception(f"Unable to send rejection message to user: {member}") + + await member.kick(reason="DEFCON active, user is too new") + self.bot.stats.incr("defcon.leaves") + + message = ( + f"{member} (`{member.id}`) was denied entry because their account is too new." + ) + + if not message_sent: + message = f"{message}\n\nUnable to send rejection message via DM; they probably have DMs disabled." + + await self.mod_log.send_log_message( + Icons.defcon_denied, Colours.soft_red, "Entry denied", + message, member.avatar_url_as(static_format="png") + ) + + @group(name='defcon', aliases=('dc',), invoke_without_command=True) + @with_role(Roles.admins, Roles.owners) + async def defcon_group(self, ctx: Context) -> None: + """Check the DEFCON status or run a subcommand.""" + await ctx.send_help(ctx.command) + + async def _defcon_action(self, ctx: Context, days: int, action: Action) -> None: + """Providing a structured way to do an defcon action.""" + try: + response = await self.bot.api_client.get('bot/bot-settings/defcon') + data = response['data'] + + if "enable_date" in data and action is Action.DISABLED: + enabled = datetime.fromisoformat(data["enable_date"]) + + delta = datetime.now() - enabled + + self.bot.stats.timing("defcon.enabled", delta) + except Exception: + pass + + error = None + try: + await self.bot.api_client.put( + 'bot/bot-settings/defcon', + json={ + 'name': 'defcon', + 'data': { + # TODO: retrieve old days count + 'days': days, + 'enabled': action is not Action.DISABLED, + 'enable_date': datetime.now().isoformat() + } + } + ) + except Exception as err: + log.exception("Unable to update DEFCON settings.") + error = err + finally: + await ctx.send(self.build_defcon_msg(action, error)) + await self.send_defcon_log(action, ctx.author, error) + + self.bot.stats.gauge("defcon.threshold", days) + + @defcon_group.command(name='enable', aliases=('on', 'e')) + @with_role(Roles.admins, Roles.owners) + async def enable_command(self, ctx: Context) -> None: + """ + Enable DEFCON mode. Useful in a pinch, but be sure you know what you're doing! + + Currently, this just adds an account age requirement. Use !defcon days to set how old an account must be, + in days. + """ + self.enabled = True + await self._defcon_action(ctx, days=0, action=Action.ENABLED) + await self.update_channel_topic() + + @defcon_group.command(name='disable', aliases=('off', 'd')) + @with_role(Roles.admins, Roles.owners) + async def disable_command(self, ctx: Context) -> None: + """Disable DEFCON mode. Useful in a pinch, but be sure you know what you're doing!""" + self.enabled = False + await self._defcon_action(ctx, days=0, action=Action.DISABLED) + await self.update_channel_topic() + + @defcon_group.command(name='status', aliases=('s',)) + @with_role(Roles.admins, Roles.owners) + async def status_command(self, ctx: Context) -> None: + """Check the current status of DEFCON mode.""" + embed = Embed( + colour=Colour.blurple(), title="DEFCON Status", + description=f"**Enabled:** {self.enabled}\n" + f"**Days:** {self.days.days}" + ) + + await ctx.send(embed=embed) + + @defcon_group.command(name='days') + @with_role(Roles.admins, Roles.owners) + async def days_command(self, ctx: Context, days: int) -> None: + """Set how old an account must be to join the server, in days, with DEFCON mode enabled.""" + self.days = timedelta(days=days) + self.enabled = True + await self._defcon_action(ctx, days=days, action=Action.UPDATED) + await self.update_channel_topic() + + async def update_channel_topic(self) -> None: + """Update the #defcon channel topic with the current DEFCON status.""" + if self.enabled: + day_str = "days" if self.days.days > 1 else "day" + new_topic = f"{BASE_CHANNEL_TOPIC}\n(Status: Enabled, Threshold: {self.days.days} {day_str})" + else: + new_topic = f"{BASE_CHANNEL_TOPIC}\n(Status: Disabled)" + + self.mod_log.ignore(Event.guild_channel_update, Channels.defcon) + await self.channel.edit(topic=new_topic) + + def build_defcon_msg(self, action: Action, e: Exception = None) -> str: + """Build in-channel response string for DEFCON action.""" + if action is Action.ENABLED: + msg = f"{Emojis.defcon_enabled} DEFCON enabled.\n\n" + elif action is Action.DISABLED: + msg = f"{Emojis.defcon_disabled} DEFCON disabled.\n\n" + elif action is Action.UPDATED: + msg = ( + f"{Emojis.defcon_updated} DEFCON days updated; accounts must be {self.days.days} " + f"day{'s' if self.days.days > 1 else ''} old to join the server.\n\n" + ) + + if e: + msg += ( + "**There was a problem updating the site** - This setting may be reverted when the bot restarts.\n\n" + f"```py\n{e}\n```" + ) + + return msg + + async def send_defcon_log(self, action: Action, actor: Member, e: Exception = None) -> None: + """Send log message for DEFCON action.""" + info = action.value + log_msg: str = ( + f"**Staffer:** {actor.mention} {actor} (`{actor.id}`)\n" + f"{info.template.format(days=self.days.days)}" + ) + status_msg = f"DEFCON {action.name.lower()}" + + if e: + log_msg += ( + "**There was a problem updating the site** - This setting may be reverted when the bot restarts.\n\n" + f"```py\n{e}\n```" + ) + + await self.mod_log.send_log_message(info.icon, info.color, status_msg, log_msg) + + +def setup(bot: Bot) -> None: + """Load the Defcon cog.""" + bot.add_cog(Defcon(bot)) diff --git a/bot/cogs/moderation/infraction/__init__.py b/bot/cogs/moderation/infraction/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bot/cogs/moderation/infraction/infractions.py b/bot/cogs/moderation/infraction/infractions.py new file mode 100644 index 000000000..8df642428 --- /dev/null +++ b/bot/cogs/moderation/infraction/infractions.py @@ -0,0 +1,370 @@ +import logging +import textwrap +import typing as t + +import discord +from discord import Member +from discord.ext import commands +from discord.ext.commands import Context, command + +from bot import constants +from bot.bot import Bot +from bot.constants import Event +from bot.converters import Expiry, FetchedMember +from bot.decorators import respect_role_hierarchy +from bot.utils.checks import with_role_check +from . import utils +from .scheduler import InfractionScheduler +from .utils import UserSnowflake + +log = logging.getLogger(__name__) + + +class Infractions(InfractionScheduler, commands.Cog): + """Apply and pardon infractions on users for moderation purposes.""" + + category = "Moderation" + category_description = "Server moderation tools." + + def __init__(self, bot: Bot): + super().__init__(bot, supported_infractions={"ban", "kick", "mute", "note", "warning"}) + + self.category = "Moderation" + self._muted_role = discord.Object(constants.Roles.muted) + + @commands.Cog.listener() + async def on_member_join(self, member: Member) -> None: + """Reapply active mute infractions for returning members.""" + active_mutes = await self.bot.api_client.get( + "bot/infractions", + params={ + "active": "true", + "type": "mute", + "user__id": member.id + } + ) + + if active_mutes: + reason = f"Re-applying active mute: {active_mutes[0]['id']}" + action = member.add_roles(self._muted_role, reason=reason) + + await self.reapply_infraction(active_mutes[0], action) + + # region: Permanent infractions + + @command() + async def warn(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None: + """Warn a user for the given reason.""" + infraction = await utils.post_infraction(ctx, user, "warning", reason, active=False) + if infraction is None: + return + + await self.apply_infraction(ctx, infraction, user) + + @command() + async def kick(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None: + """Kick a user for the given reason.""" + await self.apply_kick(ctx, user, reason) + + @command() + async def ban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: + """Permanently ban a user for the given reason and stop watching them with Big Brother.""" + await self.apply_ban(ctx, user, reason) + + # endregion + # region: Temporary infractions + + @command(aliases=["mute"]) + async def tempmute(self, ctx: Context, user: Member, duration: Expiry, *, reason: t.Optional[str] = None) -> None: + """ + Temporarily mute a user for the given reason and duration. + + A unit of time should be appended to the duration. + Units (∗case-sensitive): + \u2003`y` - years + \u2003`m` - months∗ + \u2003`w` - weeks + \u2003`d` - days + \u2003`h` - hours + \u2003`M` - minutes∗ + \u2003`s` - seconds + + Alternatively, an ISO 8601 timestamp can be provided for the duration. + """ + await self.apply_mute(ctx, user, reason, expires_at=duration) + + @command() + async def tempban( + self, + ctx: Context, + user: FetchedMember, + duration: Expiry, + *, + reason: t.Optional[str] = None + ) -> None: + """ + Temporarily ban a user for the given reason and duration. + + A unit of time should be appended to the duration. + Units (∗case-sensitive): + \u2003`y` - years + \u2003`m` - months∗ + \u2003`w` - weeks + \u2003`d` - days + \u2003`h` - hours + \u2003`M` - minutes∗ + \u2003`s` - seconds + + Alternatively, an ISO 8601 timestamp can be provided for the duration. + """ + await self.apply_ban(ctx, user, reason, expires_at=duration) + + # endregion + # region: Permanent shadow infractions + + @command(hidden=True) + async def note(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: + """Create a private note for a user with the given reason without notifying the user.""" + infraction = await utils.post_infraction(ctx, user, "note", reason, hidden=True, active=False) + if infraction is None: + return + + await self.apply_infraction(ctx, infraction, user) + + @command(hidden=True, aliases=['shadowkick', 'skick']) + async def shadow_kick(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None: + """Kick a user for the given reason without notifying the user.""" + await self.apply_kick(ctx, user, reason, hidden=True) + + @command(hidden=True, aliases=['shadowban', 'sban']) + async def shadow_ban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: + """Permanently ban a user for the given reason without notifying the user.""" + await self.apply_ban(ctx, user, reason, hidden=True) + + # endregion + # region: Temporary shadow infractions + + @command(hidden=True, aliases=["shadowtempmute, stempmute", "shadowmute", "smute"]) + async def shadow_tempmute( + self, ctx: Context, + user: Member, + duration: Expiry, + *, + reason: t.Optional[str] = None + ) -> None: + """ + Temporarily mute a user for the given reason and duration without notifying the user. + + A unit of time should be appended to the duration. + Units (∗case-sensitive): + \u2003`y` - years + \u2003`m` - months∗ + \u2003`w` - weeks + \u2003`d` - days + \u2003`h` - hours + \u2003`M` - minutes∗ + \u2003`s` - seconds + + Alternatively, an ISO 8601 timestamp can be provided for the duration. + """ + await self.apply_mute(ctx, user, reason, expires_at=duration, hidden=True) + + @command(hidden=True, aliases=["shadowtempban, stempban"]) + async def shadow_tempban( + self, + ctx: Context, + user: FetchedMember, + duration: Expiry, + *, + reason: t.Optional[str] = None + ) -> None: + """ + Temporarily ban a user for the given reason and duration without notifying the user. + + A unit of time should be appended to the duration. + Units (∗case-sensitive): + \u2003`y` - years + \u2003`m` - months∗ + \u2003`w` - weeks + \u2003`d` - days + \u2003`h` - hours + \u2003`M` - minutes∗ + \u2003`s` - seconds + + Alternatively, an ISO 8601 timestamp can be provided for the duration. + """ + await self.apply_ban(ctx, user, reason, expires_at=duration, hidden=True) + + # endregion + # region: Remove infractions (un- commands) + + @command() + async def unmute(self, ctx: Context, user: FetchedMember) -> None: + """Prematurely end the active mute infraction for the user.""" + await self.pardon_infraction(ctx, "mute", user) + + @command() + async def unban(self, ctx: Context, user: FetchedMember) -> None: + """Prematurely end the active ban infraction for the user.""" + await self.pardon_infraction(ctx, "ban", user) + + # endregion + # region: Base apply functions + + async def apply_mute(self, ctx: Context, user: Member, reason: t.Optional[str], **kwargs) -> None: + """Apply a mute infraction with kwargs passed to `post_infraction`.""" + if await utils.get_active_infraction(ctx, user, "mute"): + return + + infraction = await utils.post_infraction(ctx, user, "mute", reason, active=True, **kwargs) + if infraction is None: + return + + self.mod_log.ignore(Event.member_update, user.id) + + async def action() -> None: + await user.add_roles(self._muted_role, reason=reason) + + log.trace(f"Attempting to kick {user} from voice because they've been muted.") + await user.move_to(None, reason=reason) + + await self.apply_infraction(ctx, infraction, user, action()) + + @respect_role_hierarchy() + async def apply_kick(self, ctx: Context, user: Member, reason: t.Optional[str], **kwargs) -> None: + """Apply a kick infraction with kwargs passed to `post_infraction`.""" + infraction = await utils.post_infraction(ctx, user, "kick", reason, active=False, **kwargs) + if infraction is None: + return + + self.mod_log.ignore(Event.member_remove, user.id) + + if reason: + reason = textwrap.shorten(reason, width=512, placeholder="...") + + action = user.kick(reason=reason) + await self.apply_infraction(ctx, infraction, user, action) + + @respect_role_hierarchy() + async def apply_ban(self, ctx: Context, user: UserSnowflake, reason: t.Optional[str], **kwargs) -> None: + """ + Apply a ban infraction with kwargs passed to `post_infraction`. + + Will also remove the banned user from the Big Brother watch list if applicable. + """ + # In the case of a permanent ban, we don't need get_active_infractions to tell us if one is active + is_temporary = kwargs.get("expires_at") is not None + active_infraction = await utils.get_active_infraction(ctx, user, "ban", is_temporary) + + if active_infraction: + if is_temporary: + log.trace("Tempban ignored as it cannot overwrite an active ban.") + return + + if active_infraction.get('expires_at') is None: + log.trace("Permaban already exists, notify.") + await ctx.send(f":x: User is already permanently banned (#{active_infraction['id']}).") + return + + log.trace("Old tempban is being replaced by new permaban.") + await self.pardon_infraction(ctx, "ban", user, is_temporary) + + infraction = await utils.post_infraction(ctx, user, "ban", reason, active=True, **kwargs) + if infraction is None: + return + + self.mod_log.ignore(Event.member_remove, user.id) + + if reason: + reason = textwrap.shorten(reason, width=512, placeholder="...") + + action = ctx.guild.ban(user, reason=reason, delete_message_days=0) + await self.apply_infraction(ctx, infraction, user, action) + + if infraction.get('expires_at') is not None: + log.trace(f"Ban isn't permanent; user {user} won't be unwatched by Big Brother.") + return + + bb_cog = self.bot.get_cog("Big Brother") + if not bb_cog: + log.error(f"Big Brother cog not loaded; perma-banned user {user} won't be unwatched.") + return + + log.trace(f"Big Brother cog loaded; attempting to unwatch perma-banned user {user}.") + + bb_reason = "User has been permanently banned from the server. Automatically removed." + await bb_cog.apply_unwatch(ctx, user, bb_reason, send_message=False) + + # endregion + # region: Base pardon functions + + async def pardon_mute(self, user_id: int, guild: discord.Guild, reason: t.Optional[str]) -> t.Dict[str, str]: + """Remove a user's muted role, DM them a notification, and return a log dict.""" + user = guild.get_member(user_id) + log_text = {} + + if user: + # Remove the muted role. + self.mod_log.ignore(Event.member_update, user.id) + await user.remove_roles(self._muted_role, reason=reason) + + # DM the user about the expiration. + notified = await utils.notify_pardon( + user=user, + title="You have been unmuted", + content="You may now send messages in the server.", + icon_url=utils.INFRACTION_ICONS["mute"][1] + ) + + log_text["Member"] = f"{user.mention}(`{user.id}`)" + log_text["DM"] = "Sent" if notified else "**Failed**" + else: + log.info(f"Failed to unmute user {user_id}: user not found") + log_text["Failure"] = "User was not found in the guild." + + return log_text + + async def pardon_ban(self, user_id: int, guild: discord.Guild, reason: t.Optional[str]) -> t.Dict[str, str]: + """Remove a user's ban on the Discord guild and return a log dict.""" + user = discord.Object(user_id) + log_text = {} + + self.mod_log.ignore(Event.member_unban, user_id) + + try: + await guild.unban(user, reason=reason) + except discord.NotFound: + log.info(f"Failed to unban user {user_id}: no active ban found on Discord") + log_text["Note"] = "No active ban found on Discord." + + return log_text + + async def _pardon_action(self, infraction: utils.Infraction) -> t.Optional[t.Dict[str, str]]: + """ + Execute deactivation steps specific to the infraction's type and return a log dict. + + If an infraction type is unsupported, return None instead. + """ + guild = self.bot.get_guild(constants.Guild.id) + user_id = infraction["user"] + reason = f"Infraction #{infraction['id']} expired or was pardoned." + + if infraction["type"] == "mute": + return await self.pardon_mute(user_id, guild, reason) + elif infraction["type"] == "ban": + return await self.pardon_ban(user_id, guild, reason) + + # endregion + + # This cannot be static (must have a __func__ attribute). + def cog_check(self, ctx: Context) -> bool: + """Only allow moderators to invoke the commands in this cog.""" + return with_role_check(ctx, *constants.MODERATION_ROLES) + + # This cannot be static (must have a __func__ attribute). + async def cog_command_error(self, ctx: Context, error: Exception) -> None: + """Send a notification to the invoking context on a Union failure.""" + if isinstance(error, commands.BadUnionArgument): + if discord.User in error.converters or discord.Member in error.converters: + await ctx.send(str(error.errors[0])) + error.handled = True diff --git a/bot/cogs/moderation/infraction/management.py b/bot/cogs/moderation/infraction/management.py new file mode 100644 index 000000000..791585b6e --- /dev/null +++ b/bot/cogs/moderation/infraction/management.py @@ -0,0 +1,305 @@ +import logging +import textwrap +import typing as t +from datetime import datetime + +import discord +from discord.ext import commands +from discord.ext.commands import Context + +from bot import constants +from bot.bot import Bot +from bot.cogs.moderation.modlog import ModLog +from bot.converters import Expiry, InfractionSearchQuery, allowed_strings, proxy_user +from bot.pagination import LinePaginator +from bot.utils import time +from bot.utils.checks import in_whitelist_check, with_role_check +from . import utils +from .infractions import Infractions + +log = logging.getLogger(__name__) + + +class ModManagement(commands.Cog): + """Management of infractions.""" + + category = "Moderation" + + def __init__(self, bot: Bot): + self.bot = bot + + @property + def mod_log(self) -> ModLog: + """Get currently loaded ModLog cog instance.""" + return self.bot.get_cog("ModLog") + + @property + def infractions_cog(self) -> Infractions: + """Get currently loaded Infractions cog instance.""" + return self.bot.get_cog("Infractions") + + # region: Edit infraction commands + + @commands.group(name='infraction', aliases=('infr', 'infractions', 'inf'), invoke_without_command=True) + async def infraction_group(self, ctx: Context) -> None: + """Infraction manipulation commands.""" + await ctx.send_help(ctx.command) + + @infraction_group.command(name='edit') + async def infraction_edit( + self, + ctx: Context, + infraction_id: t.Union[int, allowed_strings("l", "last", "recent")], # noqa: F821 + duration: t.Union[Expiry, allowed_strings("p", "permanent"), None], # noqa: F821 + *, + reason: str = None + ) -> None: + """ + Edit the duration and/or the reason of an infraction. + + Durations are relative to the time of updating and should be appended with a unit of time. + Units (∗case-sensitive): + \u2003`y` - years + \u2003`m` - months∗ + \u2003`w` - weeks + \u2003`d` - days + \u2003`h` - hours + \u2003`M` - minutes∗ + \u2003`s` - seconds + + Use "l", "last", or "recent" as the infraction ID to specify that the most recent infraction + authored by the command invoker should be edited. + + Use "p" or "permanent" to mark the infraction as permanent. Alternatively, an ISO 8601 + timestamp can be provided for the duration. + """ + if duration is None and reason is None: + # Unlike UserInputError, the error handler will show a specified message for BadArgument + raise commands.BadArgument("Neither a new expiry nor a new reason was specified.") + + # Retrieve the previous infraction for its information. + if isinstance(infraction_id, str): + params = { + "actor__id": ctx.author.id, + "ordering": "-inserted_at" + } + infractions = await self.bot.api_client.get("bot/infractions", params=params) + + if infractions: + old_infraction = infractions[0] + infraction_id = old_infraction["id"] + else: + await ctx.send( + ":x: Couldn't find most recent infraction; you have never given an infraction." + ) + return + else: + old_infraction = await self.bot.api_client.get(f"bot/infractions/{infraction_id}") + + request_data = {} + confirm_messages = [] + log_text = "" + + if duration is not None and not old_infraction['active']: + if reason is None: + await ctx.send(":x: Cannot edit the expiration of an expired infraction.") + return + confirm_messages.append("expiry unchanged (infraction already expired)") + elif isinstance(duration, str): + request_data['expires_at'] = None + confirm_messages.append("marked as permanent") + elif duration is not None: + request_data['expires_at'] = duration.isoformat() + expiry = time.format_infraction_with_duration(request_data['expires_at']) + confirm_messages.append(f"set to expire on {expiry}") + else: + confirm_messages.append("expiry unchanged") + + if reason: + request_data['reason'] = reason + confirm_messages.append("set a new reason") + log_text += f""" + Previous reason: {old_infraction['reason']} + New reason: {reason} + """.rstrip() + else: + confirm_messages.append("reason unchanged") + + # Update the infraction + new_infraction = await self.bot.api_client.patch( + f'bot/infractions/{infraction_id}', + json=request_data, + ) + + # Re-schedule infraction if the expiration has been updated + if 'expires_at' in request_data: + # A scheduled task should only exist if the old infraction wasn't permanent + if old_infraction['expires_at']: + self.infractions_cog.scheduler.cancel(new_infraction['id']) + + # If the infraction was not marked as permanent, schedule a new expiration task + if request_data['expires_at']: + self.infractions_cog.schedule_expiration(new_infraction) + + log_text += f""" + Previous expiry: {old_infraction['expires_at'] or "Permanent"} + New expiry: {new_infraction['expires_at'] or "Permanent"} + """.rstrip() + + changes = ' & '.join(confirm_messages) + await ctx.send(f":ok_hand: Updated infraction #{infraction_id}: {changes}") + + # Get information about the infraction's user + user_id = new_infraction['user'] + user = ctx.guild.get_member(user_id) + + if user: + user_text = f"{user.mention} (`{user.id}`)" + thumbnail = user.avatar_url_as(static_format="png") + else: + user_text = f"`{user_id}`" + thumbnail = None + + # The infraction's actor + actor_id = new_infraction['actor'] + actor = ctx.guild.get_member(actor_id) or f"`{actor_id}`" + + await self.mod_log.send_log_message( + icon_url=constants.Icons.pencil, + colour=discord.Colour.blurple(), + title="Infraction edited", + thumbnail=thumbnail, + text=textwrap.dedent(f""" + Member: {user_text} + Actor: {actor} + Edited by: {ctx.message.author}{log_text} + """) + ) + + # endregion + # region: Search infractions + + @infraction_group.group(name="search", invoke_without_command=True) + async def infraction_search_group(self, ctx: Context, query: InfractionSearchQuery) -> None: + """Searches for infractions in the database.""" + if isinstance(query, discord.User): + await ctx.invoke(self.search_user, query) + else: + await ctx.invoke(self.search_reason, query) + + @infraction_search_group.command(name="user", aliases=("member", "id")) + async def search_user(self, ctx: Context, user: t.Union[discord.User, proxy_user]) -> None: + """Search for infractions by member.""" + infraction_list = await self.bot.api_client.get( + 'bot/infractions', + params={'user__id': str(user.id)} + ) + embed = discord.Embed( + title=f"Infractions for {user} ({len(infraction_list)} total)", + colour=discord.Colour.orange() + ) + await self.send_infraction_list(ctx, embed, infraction_list) + + @infraction_search_group.command(name="reason", aliases=("match", "regex", "re")) + async def search_reason(self, ctx: Context, reason: str) -> None: + """Search for infractions by their reason. Use Re2 for matching.""" + infraction_list = await self.bot.api_client.get( + 'bot/infractions', + params={'search': reason} + ) + embed = discord.Embed( + title=f"Infractions matching `{reason}` ({len(infraction_list)} total)", + colour=discord.Colour.orange() + ) + await self.send_infraction_list(ctx, embed, infraction_list) + + # endregion + # region: Utility functions + + async def send_infraction_list( + self, + ctx: Context, + embed: discord.Embed, + infractions: t.Iterable[utils.Infraction] + ) -> None: + """Send a paginated embed of infractions for the specified user.""" + if not infractions: + await ctx.send(":warning: No infractions could be found for that query.") + return + + lines = tuple( + self.infraction_to_string(infraction) + for infraction in infractions + ) + + await LinePaginator.paginate( + lines, + ctx=ctx, + embed=embed, + empty=True, + max_lines=3, + max_size=1000 + ) + + def infraction_to_string(self, infraction: utils.Infraction) -> str: + """Convert the infraction object to a string representation.""" + actor_id = infraction["actor"] + guild = self.bot.get_guild(constants.Guild.id) + actor = guild.get_member(actor_id) + active = infraction["active"] + user_id = infraction["user"] + hidden = infraction["hidden"] + created = time.format_infraction(infraction["inserted_at"]) + + if active: + remaining = time.until_expiration(infraction["expires_at"]) or "Expired" + else: + remaining = "Inactive" + + if infraction["expires_at"] is None: + expires = "*Permanent*" + else: + date_from = datetime.strptime(created, time.INFRACTION_FORMAT) + expires = time.format_infraction_with_duration(infraction["expires_at"], date_from) + + lines = textwrap.dedent(f""" + {"**===============**" if active else "==============="} + Status: {"__**Active**__" if active else "Inactive"} + User: {self.bot.get_user(user_id)} (`{user_id}`) + Type: **{infraction["type"]}** + Shadow: {hidden} + Created: {created} + Expires: {expires} + Remaining: {remaining} + Actor: {actor.mention if actor else actor_id} + ID: `{infraction["id"]}` + Reason: {infraction["reason"] or "*None*"} + {"**===============**" if active else "==============="} + """) + + return lines.strip() + + # endregion + + # This cannot be static (must have a __func__ attribute). + def cog_check(self, ctx: Context) -> bool: + """Only allow moderators inside moderator channels to invoke the commands in this cog.""" + checks = [ + with_role_check(ctx, *constants.MODERATION_ROLES), + in_whitelist_check( + ctx, + channels=constants.MODERATION_CHANNELS, + categories=[constants.Categories.modmail], + redirect=None, + fail_silently=True, + ) + ] + return all(checks) + + # This cannot be static (must have a __func__ attribute). + async def cog_command_error(self, ctx: Context, error: Exception) -> None: + """Send a notification to the invoking context on a Union failure.""" + if isinstance(error, commands.BadUnionArgument): + if discord.User in error.converters: + await ctx.send(str(error.errors[0])) + error.handled = True diff --git a/bot/cogs/moderation/infraction/scheduler.py b/bot/cogs/moderation/infraction/scheduler.py new file mode 100644 index 000000000..b3d27fe76 --- /dev/null +++ b/bot/cogs/moderation/infraction/scheduler.py @@ -0,0 +1,463 @@ +import logging +import textwrap +import typing as t +from abc import abstractmethod +from datetime import datetime +from gettext import ngettext + +import dateutil.parser +import discord +from discord.ext.commands import Context + +from bot import constants +from bot.api import ResponseCodeError +from bot.bot import Bot +from bot.cogs.moderation.modlog import ModLog +from bot.constants import Colours, STAFF_CHANNELS +from bot.utils import time +from bot.utils.scheduling import Scheduler +from . import utils +from .utils import UserSnowflake + +log = logging.getLogger(__name__) + + +class InfractionScheduler: + """Handles the application, pardoning, and expiration of infractions.""" + + def __init__(self, bot: Bot, supported_infractions: t.Container[str]): + self.bot = bot + self.scheduler = Scheduler(self.__class__.__name__) + + self.bot.loop.create_task(self.reschedule_infractions(supported_infractions)) + + def cog_unload(self) -> None: + """Cancel scheduled tasks.""" + self.scheduler.cancel_all() + + @property + def mod_log(self) -> ModLog: + """Get the currently loaded ModLog cog instance.""" + return self.bot.get_cog("ModLog") + + async def reschedule_infractions(self, supported_infractions: t.Container[str]) -> None: + """Schedule expiration for previous infractions.""" + await self.bot.wait_until_guild_available() + + log.trace(f"Rescheduling infractions for {self.__class__.__name__}.") + + infractions = await self.bot.api_client.get( + 'bot/infractions', + params={'active': 'true'} + ) + for infraction in infractions: + if infraction["expires_at"] is not None and infraction["type"] in supported_infractions: + self.schedule_expiration(infraction) + + async def reapply_infraction( + self, + infraction: utils.Infraction, + apply_coro: t.Optional[t.Awaitable] + ) -> None: + """Reapply an infraction if it's still active or deactivate it if less than 60 sec left.""" + # Calculate the time remaining, in seconds, for the mute. + expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None) + delta = (expiry - datetime.utcnow()).total_seconds() + + # Mark as inactive if less than a minute remains. + if delta < 60: + log.info( + "Infraction will be deactivated instead of re-applied " + "because less than 1 minute remains." + ) + await self.deactivate_infraction(infraction) + return + + # Allowing mod log since this is a passive action that should be logged. + await apply_coro + log.info(f"Re-applied {infraction['type']} to user {infraction['user']} upon rejoining.") + + async def apply_infraction( + self, + ctx: Context, + infraction: utils.Infraction, + user: UserSnowflake, + action_coro: t.Optional[t.Awaitable] = None + ) -> None: + """Apply an infraction to the user, log the infraction, and optionally notify the user.""" + infr_type = infraction["type"] + icon = utils.INFRACTION_ICONS[infr_type][0] + reason = infraction["reason"] + expiry = time.format_infraction_with_duration(infraction["expires_at"]) + id_ = infraction['id'] + + log.trace(f"Applying {infr_type} infraction #{id_} to {user}.") + + # Default values for the confirmation message and mod log. + confirm_msg = ":ok_hand: applied" + + # Specifying an expiry for a note or warning makes no sense. + if infr_type in ("note", "warning"): + expiry_msg = "" + else: + expiry_msg = f" until {expiry}" if expiry else " permanently" + + dm_result = "" + dm_log_text = "" + expiry_log_text = f"\nExpires: {expiry}" if expiry else "" + log_title = "applied" + log_content = None + failed = False + + # DM the user about the infraction if it's not a shadow/hidden infraction. + # This needs to happen before we apply the infraction, as the bot cannot + # send DMs to user that it doesn't share a guild with. If we were to + # apply kick/ban infractions first, this would mean that we'd make it + # impossible for us to deliver a DM. See python-discord/bot#982. + if not infraction["hidden"]: + dm_result = f"{constants.Emojis.failmail} " + dm_log_text = "\nDM: **Failed**" + + # Sometimes user is a discord.Object; make it a proper user. + try: + if not isinstance(user, (discord.Member, discord.User)): + user = await self.bot.fetch_user(user.id) + except discord.HTTPException as e: + log.error(f"Failed to DM {user.id}: could not fetch user (status {e.status})") + else: + # Accordingly display whether the user was successfully notified via DM. + if await utils.notify_infraction(user, infr_type, expiry, reason, icon): + dm_result = ":incoming_envelope: " + dm_log_text = "\nDM: Sent" + + end_msg = "" + if infraction["actor"] == self.bot.user.id: + log.trace( + f"Infraction #{id_} actor is bot; including the reason in the confirmation message." + ) + if reason: + end_msg = f" (reason: {textwrap.shorten(reason, width=1500, placeholder='...')})" + elif ctx.channel.id not in STAFF_CHANNELS: + log.trace( + f"Infraction #{id_} context is not in a staff channel; omitting infraction count." + ) + else: + log.trace(f"Fetching total infraction count for {user}.") + + infractions = await self.bot.api_client.get( + "bot/infractions", + params={"user__id": str(user.id)} + ) + total = len(infractions) + end_msg = f" ({total} infraction{ngettext('', 's', total)} total)" + + # Execute the necessary actions to apply the infraction on Discord. + if action_coro: + log.trace(f"Awaiting the infraction #{id_} application action coroutine.") + try: + await action_coro + if expiry: + # Schedule the expiration of the infraction. + self.schedule_expiration(infraction) + except discord.HTTPException as e: + # Accordingly display that applying the infraction failed. + confirm_msg = ":x: failed to apply" + expiry_msg = "" + log_content = ctx.author.mention + log_title = "failed to apply" + + log_msg = f"Failed to apply {infr_type} infraction #{id_} to {user}" + if isinstance(e, discord.Forbidden): + log.warning(f"{log_msg}: bot lacks permissions.") + else: + log.exception(log_msg) + failed = True + + if failed: + log.trace(f"Deleted infraction {infraction['id']} from database because applying infraction failed.") + try: + await self.bot.api_client.delete(f"bot/infractions/{id_}") + except ResponseCodeError as e: + confirm_msg += " and failed to delete" + log_title += " and failed to delete" + log.error(f"Deletion of {infr_type} infraction #{id_} failed with error code {e.status}.") + infr_message = "" + else: + infr_message = f" **{infr_type}** to {user.mention}{expiry_msg}{end_msg}" + + # Send a confirmation message to the invoking context. + log.trace(f"Sending infraction #{id_} confirmation message.") + await ctx.send(f"{dm_result}{confirm_msg}{infr_message}.") + + # Send a log message to the mod log. + log.trace(f"Sending apply mod log for infraction #{id_}.") + await self.mod_log.send_log_message( + icon_url=icon, + colour=Colours.soft_red, + title=f"Infraction {log_title}: {infr_type}", + thumbnail=user.avatar_url_as(static_format="png"), + text=textwrap.dedent(f""" + Member: {user.mention} (`{user.id}`) + Actor: {ctx.message.author}{dm_log_text}{expiry_log_text} + Reason: {reason} + """), + content=log_content, + footer=f"ID {infraction['id']}" + ) + + log.info(f"Applied {infr_type} infraction #{id_} to {user}.") + + async def pardon_infraction( + self, + ctx: Context, + infr_type: str, + user: UserSnowflake, + send_msg: bool = True + ) -> None: + """ + Prematurely end an infraction for a user and log the action in the mod log. + + If `send_msg` is True, then a pardoning confirmation message will be sent to + the context channel. Otherwise, no such message will be sent. + """ + log.trace(f"Pardoning {infr_type} infraction for {user}.") + + # Check the current active infraction + log.trace(f"Fetching active {infr_type} infractions for {user}.") + response = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': infr_type, + 'user__id': user.id + } + ) + + if not response: + log.debug(f"No active {infr_type} infraction found for {user}.") + await ctx.send(f":x: There's no active {infr_type} infraction for user {user.mention}.") + return + + # Deactivate the infraction and cancel its scheduled expiration task. + log_text = await self.deactivate_infraction(response[0], send_log=False) + + log_text["Member"] = f"{user.mention}(`{user.id}`)" + log_text["Actor"] = str(ctx.message.author) + log_content = None + id_ = response[0]['id'] + footer = f"ID: {id_}" + + # If multiple active infractions were found, mark them as inactive in the database + # and cancel their expiration tasks. + if len(response) > 1: + log.info( + f"Found more than one active {infr_type} infraction for user {user.id}; " + "deactivating the extra active infractions too." + ) + + footer = f"Infraction IDs: {', '.join(str(infr['id']) for infr in response)}" + + log_note = f"Found multiple **active** {infr_type} infractions in the database." + if "Note" in log_text: + log_text["Note"] = f" {log_note}" + else: + log_text["Note"] = log_note + + # deactivate_infraction() is not called again because: + # 1. Discord cannot store multiple active bans or assign multiples of the same role + # 2. It would send a pardon DM for each active infraction, which is redundant + for infraction in response[1:]: + id_ = infraction['id'] + try: + # Mark infraction as inactive in the database. + await self.bot.api_client.patch( + f"bot/infractions/{id_}", + json={"active": False} + ) + except ResponseCodeError: + log.exception(f"Failed to deactivate infraction #{id_} ({infr_type})") + # This is simpler and cleaner than trying to concatenate all the errors. + log_text["Failure"] = "See bot's logs for details." + + # Cancel pending expiration task. + if infraction["expires_at"] is not None: + self.scheduler.cancel(infraction["id"]) + + # Accordingly display whether the user was successfully notified via DM. + dm_emoji = "" + if log_text.get("DM") == "Sent": + dm_emoji = ":incoming_envelope: " + elif "DM" in log_text: + dm_emoji = f"{constants.Emojis.failmail} " + + # Accordingly display whether the pardon failed. + if "Failure" in log_text: + confirm_msg = ":x: failed to pardon" + log_title = "pardon failed" + log_content = ctx.author.mention + + log.warning(f"Failed to pardon {infr_type} infraction #{id_} for {user}.") + else: + confirm_msg = ":ok_hand: pardoned" + log_title = "pardoned" + + log.info(f"Pardoned {infr_type} infraction #{id_} for {user}.") + + # Send a confirmation message to the invoking context. + if send_msg: + log.trace(f"Sending infraction #{id_} pardon confirmation message.") + await ctx.send( + f"{dm_emoji}{confirm_msg} infraction **{infr_type}** for {user.mention}. " + f"{log_text.get('Failure', '')}" + ) + + # Move reason to end of entry to avoid cutting out some keys + log_text["Reason"] = log_text.pop("Reason") + + # Send a log message to the mod log. + await self.mod_log.send_log_message( + icon_url=utils.INFRACTION_ICONS[infr_type][1], + colour=Colours.soft_green, + title=f"Infraction {log_title}: {infr_type}", + thumbnail=user.avatar_url_as(static_format="png"), + text="\n".join(f"{k}: {v}" for k, v in log_text.items()), + footer=footer, + content=log_content, + ) + + async def deactivate_infraction( + self, + infraction: utils.Infraction, + send_log: bool = True + ) -> t.Dict[str, str]: + """ + Deactivate an active infraction and return a dictionary of lines to send in a mod log. + + The infraction is removed from Discord, marked as inactive in the database, and has its + expiration task cancelled. If `send_log` is True, a mod log is sent for the + deactivation of the infraction. + + Infractions of unsupported types will raise a ValueError. + """ + guild = self.bot.get_guild(constants.Guild.id) + mod_role = guild.get_role(constants.Roles.moderators) + user_id = infraction["user"] + actor = infraction["actor"] + type_ = infraction["type"] + id_ = infraction["id"] + inserted_at = infraction["inserted_at"] + expiry = infraction["expires_at"] + + log.info(f"Marking infraction #{id_} as inactive (expired).") + + expiry = dateutil.parser.isoparse(expiry).replace(tzinfo=None) if expiry else None + created = time.format_infraction_with_duration(inserted_at, expiry) + + log_content = None + log_text = { + "Member": f"<@{user_id}>", + "Actor": str(self.bot.get_user(actor) or actor), + "Reason": infraction["reason"], + "Created": created, + } + + try: + log.trace("Awaiting the pardon action coroutine.") + returned_log = await self._pardon_action(infraction) + + if returned_log is not None: + log_text = {**log_text, **returned_log} # Merge the logs together + else: + raise ValueError( + f"Attempted to deactivate an unsupported infraction #{id_} ({type_})!" + ) + except discord.Forbidden: + log.warning(f"Failed to deactivate infraction #{id_} ({type_}): bot lacks permissions.") + log_text["Failure"] = "The bot lacks permissions to do this (role hierarchy?)" + log_content = mod_role.mention + except discord.HTTPException as e: + log.exception(f"Failed to deactivate infraction #{id_} ({type_})") + log_text["Failure"] = f"HTTPException with status {e.status} and code {e.code}." + log_content = mod_role.mention + + # Check if the user is currently being watched by Big Brother. + try: + log.trace(f"Determining if user {user_id} is currently being watched by Big Brother.") + + active_watch = await self.bot.api_client.get( + "bot/infractions", + params={ + "active": "true", + "type": "watch", + "user__id": user_id + } + ) + + log_text["Watching"] = "Yes" if active_watch else "No" + except ResponseCodeError: + log.exception(f"Failed to fetch watch status for user {user_id}") + log_text["Watching"] = "Unknown - failed to fetch watch status." + + try: + # Mark infraction as inactive in the database. + log.trace(f"Marking infraction #{id_} as inactive in the database.") + await self.bot.api_client.patch( + f"bot/infractions/{id_}", + json={"active": False} + ) + except ResponseCodeError as e: + log.exception(f"Failed to deactivate infraction #{id_} ({type_})") + log_line = f"API request failed with code {e.status}." + log_content = mod_role.mention + + # Append to an existing failure message if possible + if "Failure" in log_text: + log_text["Failure"] += f" {log_line}" + else: + log_text["Failure"] = log_line + + # Cancel the expiration task. + if infraction["expires_at"] is not None: + self.scheduler.cancel(infraction["id"]) + + # Send a log message to the mod log. + if send_log: + log_title = "expiration failed" if "Failure" in log_text else "expired" + + user = self.bot.get_user(user_id) + avatar = user.avatar_url_as(static_format="png") if user else None + + # Move reason to end so when reason is too long, this is not gonna cut out required items. + log_text["Reason"] = log_text.pop("Reason") + + log.trace(f"Sending deactivation mod log for infraction #{id_}.") + await self.mod_log.send_log_message( + icon_url=utils.INFRACTION_ICONS[type_][1], + colour=Colours.soft_green, + title=f"Infraction {log_title}: {type_}", + thumbnail=avatar, + text="\n".join(f"{k}: {v}" for k, v in log_text.items()), + footer=f"ID: {id_}", + content=log_content, + ) + + return log_text + + @abstractmethod + async def _pardon_action(self, infraction: utils.Infraction) -> t.Optional[t.Dict[str, str]]: + """ + Execute deactivation steps specific to the infraction's type and return a log dict. + + If an infraction type is unsupported, return None instead. + """ + raise NotImplementedError + + def schedule_expiration(self, infraction: utils.Infraction) -> None: + """ + Marks an infraction expired after the delay from time of scheduling to time of expiration. + + At the time of expiration, the infraction is marked as inactive on the website and the + expiration task is cancelled. + """ + expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None) + self.scheduler.schedule_at(expiry, infraction["id"], self.deactivate_infraction(infraction)) diff --git a/bot/cogs/moderation/infraction/superstarify.py b/bot/cogs/moderation/infraction/superstarify.py new file mode 100644 index 000000000..867de815a --- /dev/null +++ b/bot/cogs/moderation/infraction/superstarify.py @@ -0,0 +1,239 @@ +import json +import logging +import random +import textwrap +import typing as t +from pathlib import Path + +from discord import Colour, Embed, Member +from discord.ext.commands import Cog, Context, command + +from bot import constants +from bot.bot import Bot +from bot.converters import Expiry +from bot.utils.checks import with_role_check +from bot.utils.time import format_infraction +from . import utils +from .scheduler import InfractionScheduler + +log = logging.getLogger(__name__) +NICKNAME_POLICY_URL = "https://pythondiscord.com/pages/rules/#nickname-policy" + +with Path("bot/resources/stars.json").open(encoding="utf-8") as stars_file: + STAR_NAMES = json.load(stars_file) + + +class Superstarify(InfractionScheduler, Cog): + """A set of commands to moderate terrible nicknames.""" + + def __init__(self, bot: Bot): + super().__init__(bot, supported_infractions={"superstar"}) + + @Cog.listener() + async def on_member_update(self, before: Member, after: Member) -> None: + """Revert nickname edits if the user has an active superstarify infraction.""" + if before.display_name == after.display_name: + return # User didn't change their nickname. Abort! + + log.trace( + f"{before} ({before.display_name}) is trying to change their nickname to " + f"{after.display_name}. Checking if the user is in superstar-prison..." + ) + + active_superstarifies = await self.bot.api_client.get( + "bot/infractions", + params={ + "active": "true", + "type": "superstar", + "user__id": str(before.id) + } + ) + + if not active_superstarifies: + log.trace(f"{before} has no active superstar infractions.") + return + + infraction = active_superstarifies[0] + forced_nick = self.get_nick(infraction["id"], before.id) + if after.display_name == forced_nick: + return # Nick change was triggered by this event. Ignore. + + log.info( + f"{after.display_name} ({after.id}) tried to escape superstar prison. " + f"Changing the nick back to {before.display_name}." + ) + await after.edit( + nick=forced_nick, + reason=f"Superstarified member tried to escape the prison: {infraction['id']}" + ) + + notified = await utils.notify_infraction( + user=after, + infr_type="Superstarify", + expires_at=format_infraction(infraction["expires_at"]), + reason=( + "You have tried to change your nickname on the **Python Discord** server " + f"from **{before.display_name}** to **{after.display_name}**, but as you " + "are currently in superstar-prison, you do not have permission to do so." + ), + icon_url=utils.INFRACTION_ICONS["superstar"][0] + ) + + if not notified: + log.info("Failed to DM user about why they cannot change their nickname.") + + @Cog.listener() + async def on_member_join(self, member: Member) -> None: + """Reapply active superstar infractions for returning members.""" + active_superstarifies = await self.bot.api_client.get( + "bot/infractions", + params={ + "active": "true", + "type": "superstar", + "user__id": member.id + } + ) + + if active_superstarifies: + infraction = active_superstarifies[0] + action = member.edit( + nick=self.get_nick(infraction["id"], member.id), + reason=f"Superstarified member tried to escape the prison: {infraction['id']}" + ) + + await self.reapply_infraction(infraction, action) + + @command(name="superstarify", aliases=("force_nick", "star")) + async def superstarify( + self, + ctx: Context, + member: Member, + duration: Expiry, + *, + reason: str = None, + ) -> None: + """ + Temporarily force a random superstar name (like Taylor Swift) to be the user's nickname. + + A unit of time should be appended to the duration. + Units (∗case-sensitive): + \u2003`y` - years + \u2003`m` - months∗ + \u2003`w` - weeks + \u2003`d` - days + \u2003`h` - hours + \u2003`M` - minutes∗ + \u2003`s` - seconds + + Alternatively, an ISO 8601 timestamp can be provided for the duration. + + An optional reason can be provided. If no reason is given, the original name will be shown + in a generated reason. + """ + if await utils.get_active_infraction(ctx, member, "superstar"): + return + + # Post the infraction to the API + reason = reason or f"old nick: {member.display_name}" + infraction = await utils.post_infraction(ctx, member, "superstar", reason, duration, active=True) + id_ = infraction["id"] + + old_nick = member.display_name + forced_nick = self.get_nick(id_, member.id) + expiry_str = format_infraction(infraction["expires_at"]) + + # Apply the infraction and schedule the expiration task. + log.debug(f"Changing nickname of {member} to {forced_nick}.") + self.mod_log.ignore(constants.Event.member_update, member.id) + await member.edit(nick=forced_nick, reason=reason) + self.schedule_expiration(infraction) + + # Send a DM to the user to notify them of their new infraction. + await utils.notify_infraction( + user=member, + infr_type="Superstarify", + expires_at=expiry_str, + icon_url=utils.INFRACTION_ICONS["superstar"][0], + reason=f"Your nickname didn't comply with our [nickname policy]({NICKNAME_POLICY_URL})." + ) + + # Send an embed with the infraction information to the invoking context. + log.trace(f"Sending superstar #{id_} embed.") + embed = Embed( + title="Congratulations!", + colour=constants.Colours.soft_orange, + description=( + f"Your previous nickname, **{old_nick}**, " + f"was so bad that we have decided to change it. " + f"Your new nickname will be **{forced_nick}**.\n\n" + f"You will be unable to change your nickname until **{expiry_str}**.\n\n" + "If you're confused by this, please read our " + f"[official nickname policy]({NICKNAME_POLICY_URL})." + ) + ) + await ctx.send(embed=embed) + + # Log to the mod log channel. + log.trace(f"Sending apply mod log for superstar #{id_}.") + await self.mod_log.send_log_message( + icon_url=utils.INFRACTION_ICONS["superstar"][0], + colour=Colour.gold(), + title="Member achieved superstardom", + thumbnail=member.avatar_url_as(static_format="png"), + text=textwrap.dedent(f""" + Member: {member.mention} (`{member.id}`) + Actor: {ctx.message.author} + Expires: {expiry_str} + Old nickname: `{old_nick}` + New nickname: `{forced_nick}` + Reason: {reason} + """), + footer=f"ID {id_}" + ) + + @command(name="unsuperstarify", aliases=("release_nick", "unstar")) + async def unsuperstarify(self, ctx: Context, member: Member) -> None: + """Remove the superstarify infraction and allow the user to change their nickname.""" + await self.pardon_infraction(ctx, "superstar", member) + + async def _pardon_action(self, infraction: utils.Infraction) -> t.Optional[t.Dict[str, str]]: + """Pardon a superstar infraction and return a log dict.""" + if infraction["type"] != "superstar": + return + + guild = self.bot.get_guild(constants.Guild.id) + user = guild.get_member(infraction["user"]) + + # Don't bother sending a notification if the user left the guild. + if not user: + log.debug( + "User left the guild and therefore won't be notified about superstar " + f"{infraction['id']} pardon." + ) + return {} + + # DM the user about the expiration. + notified = await utils.notify_pardon( + user=user, + title="You are no longer superstarified", + content="You may now change your nickname on the server.", + icon_url=utils.INFRACTION_ICONS["superstar"][1] + ) + + return { + "Member": f"{user.mention}(`{user.id}`)", + "DM": "Sent" if notified else "**Failed**" + } + + @staticmethod + def get_nick(infraction_id: int, member_id: int) -> str: + """Randomly select a nickname from the Superstarify nickname list.""" + log.trace(f"Choosing a random nickname for superstar #{infraction_id}.") + + rng = random.Random(str(infraction_id) + str(member_id)) + return rng.choice(STAR_NAMES) + + # This cannot be static (must have a __func__ attribute). + def cog_check(self, ctx: Context) -> bool: + """Only allow moderators to invoke the commands in this cog.""" + return with_role_check(ctx, *constants.MODERATION_ROLES) diff --git a/bot/cogs/moderation/infraction/utils.py b/bot/cogs/moderation/infraction/utils.py new file mode 100644 index 000000000..fb55287b6 --- /dev/null +++ b/bot/cogs/moderation/infraction/utils.py @@ -0,0 +1,201 @@ +import logging +import textwrap +import typing as t +from datetime import datetime + +import discord +from discord.ext.commands import Context + +from bot.api import ResponseCodeError +from bot.constants import Colours, Icons + +log = logging.getLogger(__name__) + +# apply icon, pardon icon +INFRACTION_ICONS = { + "ban": (Icons.user_ban, Icons.user_unban), + "kick": (Icons.sign_out, None), + "mute": (Icons.user_mute, Icons.user_unmute), + "note": (Icons.user_warn, None), + "superstar": (Icons.superstarify, Icons.unsuperstarify), + "warning": (Icons.user_warn, None), +} +RULES_URL = "https://pythondiscord.com/pages/rules" +APPEALABLE_INFRACTIONS = ("ban", "mute") + +# Type aliases +UserObject = t.Union[discord.Member, discord.User] +UserSnowflake = t.Union[UserObject, discord.Object] +Infraction = t.Dict[str, t.Union[str, int, bool]] + + +async def post_user(ctx: Context, user: UserSnowflake) -> t.Optional[dict]: + """ + Create a new user in the database. + + Used when an infraction needs to be applied on a user absent in the guild. + """ + log.trace(f"Attempting to add user {user.id} to the database.") + + if not isinstance(user, (discord.Member, discord.User)): + log.debug("The user being added to the DB is not a Member or User object.") + + payload = { + 'discriminator': int(getattr(user, 'discriminator', 0)), + 'id': user.id, + 'in_guild': False, + 'name': getattr(user, 'name', 'Name unknown'), + 'roles': [] + } + + try: + response = await ctx.bot.api_client.post('bot/users', json=payload) + log.info(f"User {user.id} added to the DB.") + return response + except ResponseCodeError as e: + log.error(f"Failed to add user {user.id} to the DB. {e}") + await ctx.send(f":x: The attempt to add the user to the DB failed: status {e.status}") + + +async def post_infraction( + ctx: Context, + user: UserSnowflake, + infr_type: str, + reason: str, + expires_at: datetime = None, + hidden: bool = False, + active: bool = True +) -> t.Optional[dict]: + """Posts an infraction to the API.""" + log.trace(f"Posting {infr_type} infraction for {user} to the API.") + + payload = { + "actor": ctx.message.author.id, + "hidden": hidden, + "reason": reason, + "type": infr_type, + "user": user.id, + "active": active + } + if expires_at: + payload['expires_at'] = expires_at.isoformat() + + # Try to apply the infraction. If it fails because the user doesn't exist, try to add it. + for should_post_user in (True, False): + try: + response = await ctx.bot.api_client.post('bot/infractions', json=payload) + return response + except ResponseCodeError as e: + if e.status == 400 and 'user' in e.response_json: + # Only one attempt to add the user to the database, not two: + if not should_post_user or await post_user(ctx, user) is None: + return + else: + log.exception(f"Unexpected error while adding an infraction for {user}:") + await ctx.send(f":x: There was an error adding the infraction: status {e.status}.") + return + + +async def get_active_infraction( + ctx: Context, + user: UserSnowflake, + infr_type: str, + send_msg: bool = True +) -> t.Optional[dict]: + """ + Retrieves an active infraction of the given type for the user. + + If `send_msg` is True and the user has an active infraction matching the `infr_type` parameter, + then a message for the moderator will be sent to the context channel letting them know. + Otherwise, no message will be sent. + """ + log.trace(f"Checking if {user} has active infractions of type {infr_type}.") + + active_infractions = await ctx.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': infr_type, + 'user__id': str(user.id) + } + ) + if active_infractions: + # Checks to see if the moderator should be told there is an active infraction + if send_msg: + log.trace(f"{user} has active infractions of type {infr_type}.") + await ctx.send( + f":x: According to my records, this user already has a {infr_type} infraction. " + f"See infraction **#{active_infractions[0]['id']}**." + ) + return active_infractions[0] + else: + log.trace(f"{user} does not have active infractions of type {infr_type}.") + + +async def notify_infraction( + user: UserObject, + infr_type: str, + expires_at: t.Optional[str] = None, + reason: t.Optional[str] = None, + icon_url: str = Icons.token_removed +) -> bool: + """DM a user about their new infraction and return True if the DM is successful.""" + log.trace(f"Sending {user} a DM about their {infr_type} infraction.") + + text = textwrap.dedent(f""" + **Type:** {infr_type.capitalize()} + **Expires:** {expires_at or "N/A"} + **Reason:** {reason or "No reason provided."} + """) + + embed = discord.Embed( + description=textwrap.shorten(text, width=2048, placeholder="..."), + colour=Colours.soft_red + ) + + embed.set_author(name="Infraction information", icon_url=icon_url, url=RULES_URL) + embed.title = f"Please review our rules over at {RULES_URL}" + embed.url = RULES_URL + + if infr_type in APPEALABLE_INFRACTIONS: + embed.set_footer( + text="To appeal this infraction, send an e-mail to appeals@pythondiscord.com" + ) + + return await send_private_embed(user, embed) + + +async def notify_pardon( + user: UserObject, + title: str, + content: str, + icon_url: str = Icons.user_verified +) -> bool: + """DM a user about their pardoned infraction and return True if the DM is successful.""" + log.trace(f"Sending {user} a DM about their pardoned infraction.") + + embed = discord.Embed( + description=content, + colour=Colours.soft_green + ) + + embed.set_author(name=title, icon_url=icon_url) + + return await send_private_embed(user, embed) + + +async def send_private_embed(user: UserObject, embed: discord.Embed) -> bool: + """ + A helper method for sending an embed to a user's DMs. + + Returns a boolean indicator of DM success. + """ + try: + await user.send(embed=embed) + return True + except (discord.HTTPException, discord.Forbidden, discord.NotFound): + log.debug( + f"Infraction-related information could not be sent to user {user} ({user.id}). " + "The user either could not be retrieved or probably disabled their DMs." + ) + return False diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py deleted file mode 100644 index 8df642428..000000000 --- a/bot/cogs/moderation/infractions.py +++ /dev/null @@ -1,370 +0,0 @@ -import logging -import textwrap -import typing as t - -import discord -from discord import Member -from discord.ext import commands -from discord.ext.commands import Context, command - -from bot import constants -from bot.bot import Bot -from bot.constants import Event -from bot.converters import Expiry, FetchedMember -from bot.decorators import respect_role_hierarchy -from bot.utils.checks import with_role_check -from . import utils -from .scheduler import InfractionScheduler -from .utils import UserSnowflake - -log = logging.getLogger(__name__) - - -class Infractions(InfractionScheduler, commands.Cog): - """Apply and pardon infractions on users for moderation purposes.""" - - category = "Moderation" - category_description = "Server moderation tools." - - def __init__(self, bot: Bot): - super().__init__(bot, supported_infractions={"ban", "kick", "mute", "note", "warning"}) - - self.category = "Moderation" - self._muted_role = discord.Object(constants.Roles.muted) - - @commands.Cog.listener() - async def on_member_join(self, member: Member) -> None: - """Reapply active mute infractions for returning members.""" - active_mutes = await self.bot.api_client.get( - "bot/infractions", - params={ - "active": "true", - "type": "mute", - "user__id": member.id - } - ) - - if active_mutes: - reason = f"Re-applying active mute: {active_mutes[0]['id']}" - action = member.add_roles(self._muted_role, reason=reason) - - await self.reapply_infraction(active_mutes[0], action) - - # region: Permanent infractions - - @command() - async def warn(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None: - """Warn a user for the given reason.""" - infraction = await utils.post_infraction(ctx, user, "warning", reason, active=False) - if infraction is None: - return - - await self.apply_infraction(ctx, infraction, user) - - @command() - async def kick(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None: - """Kick a user for the given reason.""" - await self.apply_kick(ctx, user, reason) - - @command() - async def ban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: - """Permanently ban a user for the given reason and stop watching them with Big Brother.""" - await self.apply_ban(ctx, user, reason) - - # endregion - # region: Temporary infractions - - @command(aliases=["mute"]) - async def tempmute(self, ctx: Context, user: Member, duration: Expiry, *, reason: t.Optional[str] = None) -> None: - """ - Temporarily mute a user for the given reason and duration. - - A unit of time should be appended to the duration. - Units (∗case-sensitive): - \u2003`y` - years - \u2003`m` - months∗ - \u2003`w` - weeks - \u2003`d` - days - \u2003`h` - hours - \u2003`M` - minutes∗ - \u2003`s` - seconds - - Alternatively, an ISO 8601 timestamp can be provided for the duration. - """ - await self.apply_mute(ctx, user, reason, expires_at=duration) - - @command() - async def tempban( - self, - ctx: Context, - user: FetchedMember, - duration: Expiry, - *, - reason: t.Optional[str] = None - ) -> None: - """ - Temporarily ban a user for the given reason and duration. - - A unit of time should be appended to the duration. - Units (∗case-sensitive): - \u2003`y` - years - \u2003`m` - months∗ - \u2003`w` - weeks - \u2003`d` - days - \u2003`h` - hours - \u2003`M` - minutes∗ - \u2003`s` - seconds - - Alternatively, an ISO 8601 timestamp can be provided for the duration. - """ - await self.apply_ban(ctx, user, reason, expires_at=duration) - - # endregion - # region: Permanent shadow infractions - - @command(hidden=True) - async def note(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: - """Create a private note for a user with the given reason without notifying the user.""" - infraction = await utils.post_infraction(ctx, user, "note", reason, hidden=True, active=False) - if infraction is None: - return - - await self.apply_infraction(ctx, infraction, user) - - @command(hidden=True, aliases=['shadowkick', 'skick']) - async def shadow_kick(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None: - """Kick a user for the given reason without notifying the user.""" - await self.apply_kick(ctx, user, reason, hidden=True) - - @command(hidden=True, aliases=['shadowban', 'sban']) - async def shadow_ban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: - """Permanently ban a user for the given reason without notifying the user.""" - await self.apply_ban(ctx, user, reason, hidden=True) - - # endregion - # region: Temporary shadow infractions - - @command(hidden=True, aliases=["shadowtempmute, stempmute", "shadowmute", "smute"]) - async def shadow_tempmute( - self, ctx: Context, - user: Member, - duration: Expiry, - *, - reason: t.Optional[str] = None - ) -> None: - """ - Temporarily mute a user for the given reason and duration without notifying the user. - - A unit of time should be appended to the duration. - Units (∗case-sensitive): - \u2003`y` - years - \u2003`m` - months∗ - \u2003`w` - weeks - \u2003`d` - days - \u2003`h` - hours - \u2003`M` - minutes∗ - \u2003`s` - seconds - - Alternatively, an ISO 8601 timestamp can be provided for the duration. - """ - await self.apply_mute(ctx, user, reason, expires_at=duration, hidden=True) - - @command(hidden=True, aliases=["shadowtempban, stempban"]) - async def shadow_tempban( - self, - ctx: Context, - user: FetchedMember, - duration: Expiry, - *, - reason: t.Optional[str] = None - ) -> None: - """ - Temporarily ban a user for the given reason and duration without notifying the user. - - A unit of time should be appended to the duration. - Units (∗case-sensitive): - \u2003`y` - years - \u2003`m` - months∗ - \u2003`w` - weeks - \u2003`d` - days - \u2003`h` - hours - \u2003`M` - minutes∗ - \u2003`s` - seconds - - Alternatively, an ISO 8601 timestamp can be provided for the duration. - """ - await self.apply_ban(ctx, user, reason, expires_at=duration, hidden=True) - - # endregion - # region: Remove infractions (un- commands) - - @command() - async def unmute(self, ctx: Context, user: FetchedMember) -> None: - """Prematurely end the active mute infraction for the user.""" - await self.pardon_infraction(ctx, "mute", user) - - @command() - async def unban(self, ctx: Context, user: FetchedMember) -> None: - """Prematurely end the active ban infraction for the user.""" - await self.pardon_infraction(ctx, "ban", user) - - # endregion - # region: Base apply functions - - async def apply_mute(self, ctx: Context, user: Member, reason: t.Optional[str], **kwargs) -> None: - """Apply a mute infraction with kwargs passed to `post_infraction`.""" - if await utils.get_active_infraction(ctx, user, "mute"): - return - - infraction = await utils.post_infraction(ctx, user, "mute", reason, active=True, **kwargs) - if infraction is None: - return - - self.mod_log.ignore(Event.member_update, user.id) - - async def action() -> None: - await user.add_roles(self._muted_role, reason=reason) - - log.trace(f"Attempting to kick {user} from voice because they've been muted.") - await user.move_to(None, reason=reason) - - await self.apply_infraction(ctx, infraction, user, action()) - - @respect_role_hierarchy() - async def apply_kick(self, ctx: Context, user: Member, reason: t.Optional[str], **kwargs) -> None: - """Apply a kick infraction with kwargs passed to `post_infraction`.""" - infraction = await utils.post_infraction(ctx, user, "kick", reason, active=False, **kwargs) - if infraction is None: - return - - self.mod_log.ignore(Event.member_remove, user.id) - - if reason: - reason = textwrap.shorten(reason, width=512, placeholder="...") - - action = user.kick(reason=reason) - await self.apply_infraction(ctx, infraction, user, action) - - @respect_role_hierarchy() - async def apply_ban(self, ctx: Context, user: UserSnowflake, reason: t.Optional[str], **kwargs) -> None: - """ - Apply a ban infraction with kwargs passed to `post_infraction`. - - Will also remove the banned user from the Big Brother watch list if applicable. - """ - # In the case of a permanent ban, we don't need get_active_infractions to tell us if one is active - is_temporary = kwargs.get("expires_at") is not None - active_infraction = await utils.get_active_infraction(ctx, user, "ban", is_temporary) - - if active_infraction: - if is_temporary: - log.trace("Tempban ignored as it cannot overwrite an active ban.") - return - - if active_infraction.get('expires_at') is None: - log.trace("Permaban already exists, notify.") - await ctx.send(f":x: User is already permanently banned (#{active_infraction['id']}).") - return - - log.trace("Old tempban is being replaced by new permaban.") - await self.pardon_infraction(ctx, "ban", user, is_temporary) - - infraction = await utils.post_infraction(ctx, user, "ban", reason, active=True, **kwargs) - if infraction is None: - return - - self.mod_log.ignore(Event.member_remove, user.id) - - if reason: - reason = textwrap.shorten(reason, width=512, placeholder="...") - - action = ctx.guild.ban(user, reason=reason, delete_message_days=0) - await self.apply_infraction(ctx, infraction, user, action) - - if infraction.get('expires_at') is not None: - log.trace(f"Ban isn't permanent; user {user} won't be unwatched by Big Brother.") - return - - bb_cog = self.bot.get_cog("Big Brother") - if not bb_cog: - log.error(f"Big Brother cog not loaded; perma-banned user {user} won't be unwatched.") - return - - log.trace(f"Big Brother cog loaded; attempting to unwatch perma-banned user {user}.") - - bb_reason = "User has been permanently banned from the server. Automatically removed." - await bb_cog.apply_unwatch(ctx, user, bb_reason, send_message=False) - - # endregion - # region: Base pardon functions - - async def pardon_mute(self, user_id: int, guild: discord.Guild, reason: t.Optional[str]) -> t.Dict[str, str]: - """Remove a user's muted role, DM them a notification, and return a log dict.""" - user = guild.get_member(user_id) - log_text = {} - - if user: - # Remove the muted role. - self.mod_log.ignore(Event.member_update, user.id) - await user.remove_roles(self._muted_role, reason=reason) - - # DM the user about the expiration. - notified = await utils.notify_pardon( - user=user, - title="You have been unmuted", - content="You may now send messages in the server.", - icon_url=utils.INFRACTION_ICONS["mute"][1] - ) - - log_text["Member"] = f"{user.mention}(`{user.id}`)" - log_text["DM"] = "Sent" if notified else "**Failed**" - else: - log.info(f"Failed to unmute user {user_id}: user not found") - log_text["Failure"] = "User was not found in the guild." - - return log_text - - async def pardon_ban(self, user_id: int, guild: discord.Guild, reason: t.Optional[str]) -> t.Dict[str, str]: - """Remove a user's ban on the Discord guild and return a log dict.""" - user = discord.Object(user_id) - log_text = {} - - self.mod_log.ignore(Event.member_unban, user_id) - - try: - await guild.unban(user, reason=reason) - except discord.NotFound: - log.info(f"Failed to unban user {user_id}: no active ban found on Discord") - log_text["Note"] = "No active ban found on Discord." - - return log_text - - async def _pardon_action(self, infraction: utils.Infraction) -> t.Optional[t.Dict[str, str]]: - """ - Execute deactivation steps specific to the infraction's type and return a log dict. - - If an infraction type is unsupported, return None instead. - """ - guild = self.bot.get_guild(constants.Guild.id) - user_id = infraction["user"] - reason = f"Infraction #{infraction['id']} expired or was pardoned." - - if infraction["type"] == "mute": - return await self.pardon_mute(user_id, guild, reason) - elif infraction["type"] == "ban": - return await self.pardon_ban(user_id, guild, reason) - - # endregion - - # This cannot be static (must have a __func__ attribute). - def cog_check(self, ctx: Context) -> bool: - """Only allow moderators to invoke the commands in this cog.""" - return with_role_check(ctx, *constants.MODERATION_ROLES) - - # This cannot be static (must have a __func__ attribute). - async def cog_command_error(self, ctx: Context, error: Exception) -> None: - """Send a notification to the invoking context on a Union failure.""" - if isinstance(error, commands.BadUnionArgument): - if discord.User in error.converters or discord.Member in error.converters: - await ctx.send(str(error.errors[0])) - error.handled = True diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py deleted file mode 100644 index 672bb0e9c..000000000 --- a/bot/cogs/moderation/management.py +++ /dev/null @@ -1,305 +0,0 @@ -import logging -import textwrap -import typing as t -from datetime import datetime - -import discord -from discord.ext import commands -from discord.ext.commands import Context - -from bot import constants -from bot.bot import Bot -from bot.converters import Expiry, InfractionSearchQuery, allowed_strings, proxy_user -from bot.pagination import LinePaginator -from bot.utils import time -from bot.utils.checks import in_whitelist_check, with_role_check -from . import utils -from .infractions import Infractions -from .modlog import ModLog - -log = logging.getLogger(__name__) - - -class ModManagement(commands.Cog): - """Management of infractions.""" - - category = "Moderation" - - def __init__(self, bot: Bot): - self.bot = bot - - @property - def mod_log(self) -> ModLog: - """Get currently loaded ModLog cog instance.""" - return self.bot.get_cog("ModLog") - - @property - def infractions_cog(self) -> Infractions: - """Get currently loaded Infractions cog instance.""" - return self.bot.get_cog("Infractions") - - # region: Edit infraction commands - - @commands.group(name='infraction', aliases=('infr', 'infractions', 'inf'), invoke_without_command=True) - async def infraction_group(self, ctx: Context) -> None: - """Infraction manipulation commands.""" - await ctx.send_help(ctx.command) - - @infraction_group.command(name='edit') - async def infraction_edit( - self, - ctx: Context, - infraction_id: t.Union[int, allowed_strings("l", "last", "recent")], # noqa: F821 - duration: t.Union[Expiry, allowed_strings("p", "permanent"), None], # noqa: F821 - *, - reason: str = None - ) -> None: - """ - Edit the duration and/or the reason of an infraction. - - Durations are relative to the time of updating and should be appended with a unit of time. - Units (∗case-sensitive): - \u2003`y` - years - \u2003`m` - months∗ - \u2003`w` - weeks - \u2003`d` - days - \u2003`h` - hours - \u2003`M` - minutes∗ - \u2003`s` - seconds - - Use "l", "last", or "recent" as the infraction ID to specify that the most recent infraction - authored by the command invoker should be edited. - - Use "p" or "permanent" to mark the infraction as permanent. Alternatively, an ISO 8601 - timestamp can be provided for the duration. - """ - if duration is None and reason is None: - # Unlike UserInputError, the error handler will show a specified message for BadArgument - raise commands.BadArgument("Neither a new expiry nor a new reason was specified.") - - # Retrieve the previous infraction for its information. - if isinstance(infraction_id, str): - params = { - "actor__id": ctx.author.id, - "ordering": "-inserted_at" - } - infractions = await self.bot.api_client.get("bot/infractions", params=params) - - if infractions: - old_infraction = infractions[0] - infraction_id = old_infraction["id"] - else: - await ctx.send( - ":x: Couldn't find most recent infraction; you have never given an infraction." - ) - return - else: - old_infraction = await self.bot.api_client.get(f"bot/infractions/{infraction_id}") - - request_data = {} - confirm_messages = [] - log_text = "" - - if duration is not None and not old_infraction['active']: - if reason is None: - await ctx.send(":x: Cannot edit the expiration of an expired infraction.") - return - confirm_messages.append("expiry unchanged (infraction already expired)") - elif isinstance(duration, str): - request_data['expires_at'] = None - confirm_messages.append("marked as permanent") - elif duration is not None: - request_data['expires_at'] = duration.isoformat() - expiry = time.format_infraction_with_duration(request_data['expires_at']) - confirm_messages.append(f"set to expire on {expiry}") - else: - confirm_messages.append("expiry unchanged") - - if reason: - request_data['reason'] = reason - confirm_messages.append("set a new reason") - log_text += f""" - Previous reason: {old_infraction['reason']} - New reason: {reason} - """.rstrip() - else: - confirm_messages.append("reason unchanged") - - # Update the infraction - new_infraction = await self.bot.api_client.patch( - f'bot/infractions/{infraction_id}', - json=request_data, - ) - - # Re-schedule infraction if the expiration has been updated - if 'expires_at' in request_data: - # A scheduled task should only exist if the old infraction wasn't permanent - if old_infraction['expires_at']: - self.infractions_cog.scheduler.cancel(new_infraction['id']) - - # If the infraction was not marked as permanent, schedule a new expiration task - if request_data['expires_at']: - self.infractions_cog.schedule_expiration(new_infraction) - - log_text += f""" - Previous expiry: {old_infraction['expires_at'] or "Permanent"} - New expiry: {new_infraction['expires_at'] or "Permanent"} - """.rstrip() - - changes = ' & '.join(confirm_messages) - await ctx.send(f":ok_hand: Updated infraction #{infraction_id}: {changes}") - - # Get information about the infraction's user - user_id = new_infraction['user'] - user = ctx.guild.get_member(user_id) - - if user: - user_text = f"{user.mention} (`{user.id}`)" - thumbnail = user.avatar_url_as(static_format="png") - else: - user_text = f"`{user_id}`" - thumbnail = None - - # The infraction's actor - actor_id = new_infraction['actor'] - actor = ctx.guild.get_member(actor_id) or f"`{actor_id}`" - - await self.mod_log.send_log_message( - icon_url=constants.Icons.pencil, - colour=discord.Colour.blurple(), - title="Infraction edited", - thumbnail=thumbnail, - text=textwrap.dedent(f""" - Member: {user_text} - Actor: {actor} - Edited by: {ctx.message.author}{log_text} - """) - ) - - # endregion - # region: Search infractions - - @infraction_group.group(name="search", invoke_without_command=True) - async def infraction_search_group(self, ctx: Context, query: InfractionSearchQuery) -> None: - """Searches for infractions in the database.""" - if isinstance(query, discord.User): - await ctx.invoke(self.search_user, query) - else: - await ctx.invoke(self.search_reason, query) - - @infraction_search_group.command(name="user", aliases=("member", "id")) - async def search_user(self, ctx: Context, user: t.Union[discord.User, proxy_user]) -> None: - """Search for infractions by member.""" - infraction_list = await self.bot.api_client.get( - 'bot/infractions', - params={'user__id': str(user.id)} - ) - embed = discord.Embed( - title=f"Infractions for {user} ({len(infraction_list)} total)", - colour=discord.Colour.orange() - ) - await self.send_infraction_list(ctx, embed, infraction_list) - - @infraction_search_group.command(name="reason", aliases=("match", "regex", "re")) - async def search_reason(self, ctx: Context, reason: str) -> None: - """Search for infractions by their reason. Use Re2 for matching.""" - infraction_list = await self.bot.api_client.get( - 'bot/infractions', - params={'search': reason} - ) - embed = discord.Embed( - title=f"Infractions matching `{reason}` ({len(infraction_list)} total)", - colour=discord.Colour.orange() - ) - await self.send_infraction_list(ctx, embed, infraction_list) - - # endregion - # region: Utility functions - - async def send_infraction_list( - self, - ctx: Context, - embed: discord.Embed, - infractions: t.Iterable[utils.Infraction] - ) -> None: - """Send a paginated embed of infractions for the specified user.""" - if not infractions: - await ctx.send(":warning: No infractions could be found for that query.") - return - - lines = tuple( - self.infraction_to_string(infraction) - for infraction in infractions - ) - - await LinePaginator.paginate( - lines, - ctx=ctx, - embed=embed, - empty=True, - max_lines=3, - max_size=1000 - ) - - def infraction_to_string(self, infraction: utils.Infraction) -> str: - """Convert the infraction object to a string representation.""" - actor_id = infraction["actor"] - guild = self.bot.get_guild(constants.Guild.id) - actor = guild.get_member(actor_id) - active = infraction["active"] - user_id = infraction["user"] - hidden = infraction["hidden"] - created = time.format_infraction(infraction["inserted_at"]) - - if active: - remaining = time.until_expiration(infraction["expires_at"]) or "Expired" - else: - remaining = "Inactive" - - if infraction["expires_at"] is None: - expires = "*Permanent*" - else: - date_from = datetime.strptime(created, time.INFRACTION_FORMAT) - expires = time.format_infraction_with_duration(infraction["expires_at"], date_from) - - lines = textwrap.dedent(f""" - {"**===============**" if active else "==============="} - Status: {"__**Active**__" if active else "Inactive"} - User: {self.bot.get_user(user_id)} (`{user_id}`) - Type: **{infraction["type"]}** - Shadow: {hidden} - Created: {created} - Expires: {expires} - Remaining: {remaining} - Actor: {actor.mention if actor else actor_id} - ID: `{infraction["id"]}` - Reason: {infraction["reason"] or "*None*"} - {"**===============**" if active else "==============="} - """) - - return lines.strip() - - # endregion - - # This cannot be static (must have a __func__ attribute). - def cog_check(self, ctx: Context) -> bool: - """Only allow moderators inside moderator channels to invoke the commands in this cog.""" - checks = [ - with_role_check(ctx, *constants.MODERATION_ROLES), - in_whitelist_check( - ctx, - channels=constants.MODERATION_CHANNELS, - categories=[constants.Categories.modmail], - redirect=None, - fail_silently=True, - ) - ] - return all(checks) - - # This cannot be static (must have a __func__ attribute). - async def cog_command_error(self, ctx: Context, error: Exception) -> None: - """Send a notification to the invoking context on a Union failure.""" - if isinstance(error, commands.BadUnionArgument): - if discord.User in error.converters: - await ctx.send(str(error.errors[0])) - error.handled = True diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py deleted file mode 100644 index 75028d851..000000000 --- a/bot/cogs/moderation/scheduler.py +++ /dev/null @@ -1,463 +0,0 @@ -import logging -import textwrap -import typing as t -from abc import abstractmethod -from datetime import datetime -from gettext import ngettext - -import dateutil.parser -import discord -from discord.ext.commands import Context - -from bot import constants -from bot.api import ResponseCodeError -from bot.bot import Bot -from bot.constants import Colours, STAFF_CHANNELS -from bot.utils import time -from bot.utils.scheduling import Scheduler -from . import utils -from .modlog import ModLog -from .utils import UserSnowflake - -log = logging.getLogger(__name__) - - -class InfractionScheduler: - """Handles the application, pardoning, and expiration of infractions.""" - - def __init__(self, bot: Bot, supported_infractions: t.Container[str]): - self.bot = bot - self.scheduler = Scheduler(self.__class__.__name__) - - self.bot.loop.create_task(self.reschedule_infractions(supported_infractions)) - - def cog_unload(self) -> None: - """Cancel scheduled tasks.""" - self.scheduler.cancel_all() - - @property - def mod_log(self) -> ModLog: - """Get the currently loaded ModLog cog instance.""" - return self.bot.get_cog("ModLog") - - async def reschedule_infractions(self, supported_infractions: t.Container[str]) -> None: - """Schedule expiration for previous infractions.""" - await self.bot.wait_until_guild_available() - - log.trace(f"Rescheduling infractions for {self.__class__.__name__}.") - - infractions = await self.bot.api_client.get( - 'bot/infractions', - params={'active': 'true'} - ) - for infraction in infractions: - if infraction["expires_at"] is not None and infraction["type"] in supported_infractions: - self.schedule_expiration(infraction) - - async def reapply_infraction( - self, - infraction: utils.Infraction, - apply_coro: t.Optional[t.Awaitable] - ) -> None: - """Reapply an infraction if it's still active or deactivate it if less than 60 sec left.""" - # Calculate the time remaining, in seconds, for the mute. - expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None) - delta = (expiry - datetime.utcnow()).total_seconds() - - # Mark as inactive if less than a minute remains. - if delta < 60: - log.info( - "Infraction will be deactivated instead of re-applied " - "because less than 1 minute remains." - ) - await self.deactivate_infraction(infraction) - return - - # Allowing mod log since this is a passive action that should be logged. - await apply_coro - log.info(f"Re-applied {infraction['type']} to user {infraction['user']} upon rejoining.") - - async def apply_infraction( - self, - ctx: Context, - infraction: utils.Infraction, - user: UserSnowflake, - action_coro: t.Optional[t.Awaitable] = None - ) -> None: - """Apply an infraction to the user, log the infraction, and optionally notify the user.""" - infr_type = infraction["type"] - icon = utils.INFRACTION_ICONS[infr_type][0] - reason = infraction["reason"] - expiry = time.format_infraction_with_duration(infraction["expires_at"]) - id_ = infraction['id'] - - log.trace(f"Applying {infr_type} infraction #{id_} to {user}.") - - # Default values for the confirmation message and mod log. - confirm_msg = ":ok_hand: applied" - - # Specifying an expiry for a note or warning makes no sense. - if infr_type in ("note", "warning"): - expiry_msg = "" - else: - expiry_msg = f" until {expiry}" if expiry else " permanently" - - dm_result = "" - dm_log_text = "" - expiry_log_text = f"\nExpires: {expiry}" if expiry else "" - log_title = "applied" - log_content = None - failed = False - - # DM the user about the infraction if it's not a shadow/hidden infraction. - # This needs to happen before we apply the infraction, as the bot cannot - # send DMs to user that it doesn't share a guild with. If we were to - # apply kick/ban infractions first, this would mean that we'd make it - # impossible for us to deliver a DM. See python-discord/bot#982. - if not infraction["hidden"]: - dm_result = f"{constants.Emojis.failmail} " - dm_log_text = "\nDM: **Failed**" - - # Sometimes user is a discord.Object; make it a proper user. - try: - if not isinstance(user, (discord.Member, discord.User)): - user = await self.bot.fetch_user(user.id) - except discord.HTTPException as e: - log.error(f"Failed to DM {user.id}: could not fetch user (status {e.status})") - else: - # Accordingly display whether the user was successfully notified via DM. - if await utils.notify_infraction(user, infr_type, expiry, reason, icon): - dm_result = ":incoming_envelope: " - dm_log_text = "\nDM: Sent" - - end_msg = "" - if infraction["actor"] == self.bot.user.id: - log.trace( - f"Infraction #{id_} actor is bot; including the reason in the confirmation message." - ) - if reason: - end_msg = f" (reason: {textwrap.shorten(reason, width=1500, placeholder='...')})" - elif ctx.channel.id not in STAFF_CHANNELS: - log.trace( - f"Infraction #{id_} context is not in a staff channel; omitting infraction count." - ) - else: - log.trace(f"Fetching total infraction count for {user}.") - - infractions = await self.bot.api_client.get( - "bot/infractions", - params={"user__id": str(user.id)} - ) - total = len(infractions) - end_msg = f" ({total} infraction{ngettext('', 's', total)} total)" - - # Execute the necessary actions to apply the infraction on Discord. - if action_coro: - log.trace(f"Awaiting the infraction #{id_} application action coroutine.") - try: - await action_coro - if expiry: - # Schedule the expiration of the infraction. - self.schedule_expiration(infraction) - except discord.HTTPException as e: - # Accordingly display that applying the infraction failed. - confirm_msg = ":x: failed to apply" - expiry_msg = "" - log_content = ctx.author.mention - log_title = "failed to apply" - - log_msg = f"Failed to apply {infr_type} infraction #{id_} to {user}" - if isinstance(e, discord.Forbidden): - log.warning(f"{log_msg}: bot lacks permissions.") - else: - log.exception(log_msg) - failed = True - - if failed: - log.trace(f"Deleted infraction {infraction['id']} from database because applying infraction failed.") - try: - await self.bot.api_client.delete(f"bot/infractions/{id_}") - except ResponseCodeError as e: - confirm_msg += " and failed to delete" - log_title += " and failed to delete" - log.error(f"Deletion of {infr_type} infraction #{id_} failed with error code {e.status}.") - infr_message = "" - else: - infr_message = f" **{infr_type}** to {user.mention}{expiry_msg}{end_msg}" - - # Send a confirmation message to the invoking context. - log.trace(f"Sending infraction #{id_} confirmation message.") - await ctx.send(f"{dm_result}{confirm_msg}{infr_message}.") - - # Send a log message to the mod log. - log.trace(f"Sending apply mod log for infraction #{id_}.") - await self.mod_log.send_log_message( - icon_url=icon, - colour=Colours.soft_red, - title=f"Infraction {log_title}: {infr_type}", - thumbnail=user.avatar_url_as(static_format="png"), - text=textwrap.dedent(f""" - Member: {user.mention} (`{user.id}`) - Actor: {ctx.message.author}{dm_log_text}{expiry_log_text} - Reason: {reason} - """), - content=log_content, - footer=f"ID {infraction['id']}" - ) - - log.info(f"Applied {infr_type} infraction #{id_} to {user}.") - - async def pardon_infraction( - self, - ctx: Context, - infr_type: str, - user: UserSnowflake, - send_msg: bool = True - ) -> None: - """ - Prematurely end an infraction for a user and log the action in the mod log. - - If `send_msg` is True, then a pardoning confirmation message will be sent to - the context channel. Otherwise, no such message will be sent. - """ - log.trace(f"Pardoning {infr_type} infraction for {user}.") - - # Check the current active infraction - log.trace(f"Fetching active {infr_type} infractions for {user}.") - response = await self.bot.api_client.get( - 'bot/infractions', - params={ - 'active': 'true', - 'type': infr_type, - 'user__id': user.id - } - ) - - if not response: - log.debug(f"No active {infr_type} infraction found for {user}.") - await ctx.send(f":x: There's no active {infr_type} infraction for user {user.mention}.") - return - - # Deactivate the infraction and cancel its scheduled expiration task. - log_text = await self.deactivate_infraction(response[0], send_log=False) - - log_text["Member"] = f"{user.mention}(`{user.id}`)" - log_text["Actor"] = str(ctx.message.author) - log_content = None - id_ = response[0]['id'] - footer = f"ID: {id_}" - - # If multiple active infractions were found, mark them as inactive in the database - # and cancel their expiration tasks. - if len(response) > 1: - log.info( - f"Found more than one active {infr_type} infraction for user {user.id}; " - "deactivating the extra active infractions too." - ) - - footer = f"Infraction IDs: {', '.join(str(infr['id']) for infr in response)}" - - log_note = f"Found multiple **active** {infr_type} infractions in the database." - if "Note" in log_text: - log_text["Note"] = f" {log_note}" - else: - log_text["Note"] = log_note - - # deactivate_infraction() is not called again because: - # 1. Discord cannot store multiple active bans or assign multiples of the same role - # 2. It would send a pardon DM for each active infraction, which is redundant - for infraction in response[1:]: - id_ = infraction['id'] - try: - # Mark infraction as inactive in the database. - await self.bot.api_client.patch( - f"bot/infractions/{id_}", - json={"active": False} - ) - except ResponseCodeError: - log.exception(f"Failed to deactivate infraction #{id_} ({infr_type})") - # This is simpler and cleaner than trying to concatenate all the errors. - log_text["Failure"] = "See bot's logs for details." - - # Cancel pending expiration task. - if infraction["expires_at"] is not None: - self.scheduler.cancel(infraction["id"]) - - # Accordingly display whether the user was successfully notified via DM. - dm_emoji = "" - if log_text.get("DM") == "Sent": - dm_emoji = ":incoming_envelope: " - elif "DM" in log_text: - dm_emoji = f"{constants.Emojis.failmail} " - - # Accordingly display whether the pardon failed. - if "Failure" in log_text: - confirm_msg = ":x: failed to pardon" - log_title = "pardon failed" - log_content = ctx.author.mention - - log.warning(f"Failed to pardon {infr_type} infraction #{id_} for {user}.") - else: - confirm_msg = ":ok_hand: pardoned" - log_title = "pardoned" - - log.info(f"Pardoned {infr_type} infraction #{id_} for {user}.") - - # Send a confirmation message to the invoking context. - if send_msg: - log.trace(f"Sending infraction #{id_} pardon confirmation message.") - await ctx.send( - f"{dm_emoji}{confirm_msg} infraction **{infr_type}** for {user.mention}. " - f"{log_text.get('Failure', '')}" - ) - - # Move reason to end of entry to avoid cutting out some keys - log_text["Reason"] = log_text.pop("Reason") - - # Send a log message to the mod log. - await self.mod_log.send_log_message( - icon_url=utils.INFRACTION_ICONS[infr_type][1], - colour=Colours.soft_green, - title=f"Infraction {log_title}: {infr_type}", - thumbnail=user.avatar_url_as(static_format="png"), - text="\n".join(f"{k}: {v}" for k, v in log_text.items()), - footer=footer, - content=log_content, - ) - - async def deactivate_infraction( - self, - infraction: utils.Infraction, - send_log: bool = True - ) -> t.Dict[str, str]: - """ - Deactivate an active infraction and return a dictionary of lines to send in a mod log. - - The infraction is removed from Discord, marked as inactive in the database, and has its - expiration task cancelled. If `send_log` is True, a mod log is sent for the - deactivation of the infraction. - - Infractions of unsupported types will raise a ValueError. - """ - guild = self.bot.get_guild(constants.Guild.id) - mod_role = guild.get_role(constants.Roles.moderators) - user_id = infraction["user"] - actor = infraction["actor"] - type_ = infraction["type"] - id_ = infraction["id"] - inserted_at = infraction["inserted_at"] - expiry = infraction["expires_at"] - - log.info(f"Marking infraction #{id_} as inactive (expired).") - - expiry = dateutil.parser.isoparse(expiry).replace(tzinfo=None) if expiry else None - created = time.format_infraction_with_duration(inserted_at, expiry) - - log_content = None - log_text = { - "Member": f"<@{user_id}>", - "Actor": str(self.bot.get_user(actor) or actor), - "Reason": infraction["reason"], - "Created": created, - } - - try: - log.trace("Awaiting the pardon action coroutine.") - returned_log = await self._pardon_action(infraction) - - if returned_log is not None: - log_text = {**log_text, **returned_log} # Merge the logs together - else: - raise ValueError( - f"Attempted to deactivate an unsupported infraction #{id_} ({type_})!" - ) - except discord.Forbidden: - log.warning(f"Failed to deactivate infraction #{id_} ({type_}): bot lacks permissions.") - log_text["Failure"] = "The bot lacks permissions to do this (role hierarchy?)" - log_content = mod_role.mention - except discord.HTTPException as e: - log.exception(f"Failed to deactivate infraction #{id_} ({type_})") - log_text["Failure"] = f"HTTPException with status {e.status} and code {e.code}." - log_content = mod_role.mention - - # Check if the user is currently being watched by Big Brother. - try: - log.trace(f"Determining if user {user_id} is currently being watched by Big Brother.") - - active_watch = await self.bot.api_client.get( - "bot/infractions", - params={ - "active": "true", - "type": "watch", - "user__id": user_id - } - ) - - log_text["Watching"] = "Yes" if active_watch else "No" - except ResponseCodeError: - log.exception(f"Failed to fetch watch status for user {user_id}") - log_text["Watching"] = "Unknown - failed to fetch watch status." - - try: - # Mark infraction as inactive in the database. - log.trace(f"Marking infraction #{id_} as inactive in the database.") - await self.bot.api_client.patch( - f"bot/infractions/{id_}", - json={"active": False} - ) - except ResponseCodeError as e: - log.exception(f"Failed to deactivate infraction #{id_} ({type_})") - log_line = f"API request failed with code {e.status}." - log_content = mod_role.mention - - # Append to an existing failure message if possible - if "Failure" in log_text: - log_text["Failure"] += f" {log_line}" - else: - log_text["Failure"] = log_line - - # Cancel the expiration task. - if infraction["expires_at"] is not None: - self.scheduler.cancel(infraction["id"]) - - # Send a log message to the mod log. - if send_log: - log_title = "expiration failed" if "Failure" in log_text else "expired" - - user = self.bot.get_user(user_id) - avatar = user.avatar_url_as(static_format="png") if user else None - - # Move reason to end so when reason is too long, this is not gonna cut out required items. - log_text["Reason"] = log_text.pop("Reason") - - log.trace(f"Sending deactivation mod log for infraction #{id_}.") - await self.mod_log.send_log_message( - icon_url=utils.INFRACTION_ICONS[type_][1], - colour=Colours.soft_green, - title=f"Infraction {log_title}: {type_}", - thumbnail=avatar, - text="\n".join(f"{k}: {v}" for k, v in log_text.items()), - footer=f"ID: {id_}", - content=log_content, - ) - - return log_text - - @abstractmethod - async def _pardon_action(self, infraction: utils.Infraction) -> t.Optional[t.Dict[str, str]]: - """ - Execute deactivation steps specific to the infraction's type and return a log dict. - - If an infraction type is unsupported, return None instead. - """ - raise NotImplementedError - - def schedule_expiration(self, infraction: utils.Infraction) -> None: - """ - Marks an infraction expired after the delay from time of scheduling to time of expiration. - - At the time of expiration, the infraction is marked as inactive on the website and the - expiration task is cancelled. - """ - expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None) - self.scheduler.schedule_at(expiry, infraction["id"], self.deactivate_infraction(infraction)) diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py deleted file mode 100644 index 867de815a..000000000 --- a/bot/cogs/moderation/superstarify.py +++ /dev/null @@ -1,239 +0,0 @@ -import json -import logging -import random -import textwrap -import typing as t -from pathlib import Path - -from discord import Colour, Embed, Member -from discord.ext.commands import Cog, Context, command - -from bot import constants -from bot.bot import Bot -from bot.converters import Expiry -from bot.utils.checks import with_role_check -from bot.utils.time import format_infraction -from . import utils -from .scheduler import InfractionScheduler - -log = logging.getLogger(__name__) -NICKNAME_POLICY_URL = "https://pythondiscord.com/pages/rules/#nickname-policy" - -with Path("bot/resources/stars.json").open(encoding="utf-8") as stars_file: - STAR_NAMES = json.load(stars_file) - - -class Superstarify(InfractionScheduler, Cog): - """A set of commands to moderate terrible nicknames.""" - - def __init__(self, bot: Bot): - super().__init__(bot, supported_infractions={"superstar"}) - - @Cog.listener() - async def on_member_update(self, before: Member, after: Member) -> None: - """Revert nickname edits if the user has an active superstarify infraction.""" - if before.display_name == after.display_name: - return # User didn't change their nickname. Abort! - - log.trace( - f"{before} ({before.display_name}) is trying to change their nickname to " - f"{after.display_name}. Checking if the user is in superstar-prison..." - ) - - active_superstarifies = await self.bot.api_client.get( - "bot/infractions", - params={ - "active": "true", - "type": "superstar", - "user__id": str(before.id) - } - ) - - if not active_superstarifies: - log.trace(f"{before} has no active superstar infractions.") - return - - infraction = active_superstarifies[0] - forced_nick = self.get_nick(infraction["id"], before.id) - if after.display_name == forced_nick: - return # Nick change was triggered by this event. Ignore. - - log.info( - f"{after.display_name} ({after.id}) tried to escape superstar prison. " - f"Changing the nick back to {before.display_name}." - ) - await after.edit( - nick=forced_nick, - reason=f"Superstarified member tried to escape the prison: {infraction['id']}" - ) - - notified = await utils.notify_infraction( - user=after, - infr_type="Superstarify", - expires_at=format_infraction(infraction["expires_at"]), - reason=( - "You have tried to change your nickname on the **Python Discord** server " - f"from **{before.display_name}** to **{after.display_name}**, but as you " - "are currently in superstar-prison, you do not have permission to do so." - ), - icon_url=utils.INFRACTION_ICONS["superstar"][0] - ) - - if not notified: - log.info("Failed to DM user about why they cannot change their nickname.") - - @Cog.listener() - async def on_member_join(self, member: Member) -> None: - """Reapply active superstar infractions for returning members.""" - active_superstarifies = await self.bot.api_client.get( - "bot/infractions", - params={ - "active": "true", - "type": "superstar", - "user__id": member.id - } - ) - - if active_superstarifies: - infraction = active_superstarifies[0] - action = member.edit( - nick=self.get_nick(infraction["id"], member.id), - reason=f"Superstarified member tried to escape the prison: {infraction['id']}" - ) - - await self.reapply_infraction(infraction, action) - - @command(name="superstarify", aliases=("force_nick", "star")) - async def superstarify( - self, - ctx: Context, - member: Member, - duration: Expiry, - *, - reason: str = None, - ) -> None: - """ - Temporarily force a random superstar name (like Taylor Swift) to be the user's nickname. - - A unit of time should be appended to the duration. - Units (∗case-sensitive): - \u2003`y` - years - \u2003`m` - months∗ - \u2003`w` - weeks - \u2003`d` - days - \u2003`h` - hours - \u2003`M` - minutes∗ - \u2003`s` - seconds - - Alternatively, an ISO 8601 timestamp can be provided for the duration. - - An optional reason can be provided. If no reason is given, the original name will be shown - in a generated reason. - """ - if await utils.get_active_infraction(ctx, member, "superstar"): - return - - # Post the infraction to the API - reason = reason or f"old nick: {member.display_name}" - infraction = await utils.post_infraction(ctx, member, "superstar", reason, duration, active=True) - id_ = infraction["id"] - - old_nick = member.display_name - forced_nick = self.get_nick(id_, member.id) - expiry_str = format_infraction(infraction["expires_at"]) - - # Apply the infraction and schedule the expiration task. - log.debug(f"Changing nickname of {member} to {forced_nick}.") - self.mod_log.ignore(constants.Event.member_update, member.id) - await member.edit(nick=forced_nick, reason=reason) - self.schedule_expiration(infraction) - - # Send a DM to the user to notify them of their new infraction. - await utils.notify_infraction( - user=member, - infr_type="Superstarify", - expires_at=expiry_str, - icon_url=utils.INFRACTION_ICONS["superstar"][0], - reason=f"Your nickname didn't comply with our [nickname policy]({NICKNAME_POLICY_URL})." - ) - - # Send an embed with the infraction information to the invoking context. - log.trace(f"Sending superstar #{id_} embed.") - embed = Embed( - title="Congratulations!", - colour=constants.Colours.soft_orange, - description=( - f"Your previous nickname, **{old_nick}**, " - f"was so bad that we have decided to change it. " - f"Your new nickname will be **{forced_nick}**.\n\n" - f"You will be unable to change your nickname until **{expiry_str}**.\n\n" - "If you're confused by this, please read our " - f"[official nickname policy]({NICKNAME_POLICY_URL})." - ) - ) - await ctx.send(embed=embed) - - # Log to the mod log channel. - log.trace(f"Sending apply mod log for superstar #{id_}.") - await self.mod_log.send_log_message( - icon_url=utils.INFRACTION_ICONS["superstar"][0], - colour=Colour.gold(), - title="Member achieved superstardom", - thumbnail=member.avatar_url_as(static_format="png"), - text=textwrap.dedent(f""" - Member: {member.mention} (`{member.id}`) - Actor: {ctx.message.author} - Expires: {expiry_str} - Old nickname: `{old_nick}` - New nickname: `{forced_nick}` - Reason: {reason} - """), - footer=f"ID {id_}" - ) - - @command(name="unsuperstarify", aliases=("release_nick", "unstar")) - async def unsuperstarify(self, ctx: Context, member: Member) -> None: - """Remove the superstarify infraction and allow the user to change their nickname.""" - await self.pardon_infraction(ctx, "superstar", member) - - async def _pardon_action(self, infraction: utils.Infraction) -> t.Optional[t.Dict[str, str]]: - """Pardon a superstar infraction and return a log dict.""" - if infraction["type"] != "superstar": - return - - guild = self.bot.get_guild(constants.Guild.id) - user = guild.get_member(infraction["user"]) - - # Don't bother sending a notification if the user left the guild. - if not user: - log.debug( - "User left the guild and therefore won't be notified about superstar " - f"{infraction['id']} pardon." - ) - return {} - - # DM the user about the expiration. - notified = await utils.notify_pardon( - user=user, - title="You are no longer superstarified", - content="You may now change your nickname on the server.", - icon_url=utils.INFRACTION_ICONS["superstar"][1] - ) - - return { - "Member": f"{user.mention}(`{user.id}`)", - "DM": "Sent" if notified else "**Failed**" - } - - @staticmethod - def get_nick(infraction_id: int, member_id: int) -> str: - """Randomly select a nickname from the Superstarify nickname list.""" - log.trace(f"Choosing a random nickname for superstar #{infraction_id}.") - - rng = random.Random(str(infraction_id) + str(member_id)) - return rng.choice(STAR_NAMES) - - # This cannot be static (must have a __func__ attribute). - def cog_check(self, ctx: Context) -> bool: - """Only allow moderators to invoke the commands in this cog.""" - return with_role_check(ctx, *constants.MODERATION_ROLES) diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py deleted file mode 100644 index fb55287b6..000000000 --- a/bot/cogs/moderation/utils.py +++ /dev/null @@ -1,201 +0,0 @@ -import logging -import textwrap -import typing as t -from datetime import datetime - -import discord -from discord.ext.commands import Context - -from bot.api import ResponseCodeError -from bot.constants import Colours, Icons - -log = logging.getLogger(__name__) - -# apply icon, pardon icon -INFRACTION_ICONS = { - "ban": (Icons.user_ban, Icons.user_unban), - "kick": (Icons.sign_out, None), - "mute": (Icons.user_mute, Icons.user_unmute), - "note": (Icons.user_warn, None), - "superstar": (Icons.superstarify, Icons.unsuperstarify), - "warning": (Icons.user_warn, None), -} -RULES_URL = "https://pythondiscord.com/pages/rules" -APPEALABLE_INFRACTIONS = ("ban", "mute") - -# Type aliases -UserObject = t.Union[discord.Member, discord.User] -UserSnowflake = t.Union[UserObject, discord.Object] -Infraction = t.Dict[str, t.Union[str, int, bool]] - - -async def post_user(ctx: Context, user: UserSnowflake) -> t.Optional[dict]: - """ - Create a new user in the database. - - Used when an infraction needs to be applied on a user absent in the guild. - """ - log.trace(f"Attempting to add user {user.id} to the database.") - - if not isinstance(user, (discord.Member, discord.User)): - log.debug("The user being added to the DB is not a Member or User object.") - - payload = { - 'discriminator': int(getattr(user, 'discriminator', 0)), - 'id': user.id, - 'in_guild': False, - 'name': getattr(user, 'name', 'Name unknown'), - 'roles': [] - } - - try: - response = await ctx.bot.api_client.post('bot/users', json=payload) - log.info(f"User {user.id} added to the DB.") - return response - except ResponseCodeError as e: - log.error(f"Failed to add user {user.id} to the DB. {e}") - await ctx.send(f":x: The attempt to add the user to the DB failed: status {e.status}") - - -async def post_infraction( - ctx: Context, - user: UserSnowflake, - infr_type: str, - reason: str, - expires_at: datetime = None, - hidden: bool = False, - active: bool = True -) -> t.Optional[dict]: - """Posts an infraction to the API.""" - log.trace(f"Posting {infr_type} infraction for {user} to the API.") - - payload = { - "actor": ctx.message.author.id, - "hidden": hidden, - "reason": reason, - "type": infr_type, - "user": user.id, - "active": active - } - if expires_at: - payload['expires_at'] = expires_at.isoformat() - - # Try to apply the infraction. If it fails because the user doesn't exist, try to add it. - for should_post_user in (True, False): - try: - response = await ctx.bot.api_client.post('bot/infractions', json=payload) - return response - except ResponseCodeError as e: - if e.status == 400 and 'user' in e.response_json: - # Only one attempt to add the user to the database, not two: - if not should_post_user or await post_user(ctx, user) is None: - return - else: - log.exception(f"Unexpected error while adding an infraction for {user}:") - await ctx.send(f":x: There was an error adding the infraction: status {e.status}.") - return - - -async def get_active_infraction( - ctx: Context, - user: UserSnowflake, - infr_type: str, - send_msg: bool = True -) -> t.Optional[dict]: - """ - Retrieves an active infraction of the given type for the user. - - If `send_msg` is True and the user has an active infraction matching the `infr_type` parameter, - then a message for the moderator will be sent to the context channel letting them know. - Otherwise, no message will be sent. - """ - log.trace(f"Checking if {user} has active infractions of type {infr_type}.") - - active_infractions = await ctx.bot.api_client.get( - 'bot/infractions', - params={ - 'active': 'true', - 'type': infr_type, - 'user__id': str(user.id) - } - ) - if active_infractions: - # Checks to see if the moderator should be told there is an active infraction - if send_msg: - log.trace(f"{user} has active infractions of type {infr_type}.") - await ctx.send( - f":x: According to my records, this user already has a {infr_type} infraction. " - f"See infraction **#{active_infractions[0]['id']}**." - ) - return active_infractions[0] - else: - log.trace(f"{user} does not have active infractions of type {infr_type}.") - - -async def notify_infraction( - user: UserObject, - infr_type: str, - expires_at: t.Optional[str] = None, - reason: t.Optional[str] = None, - icon_url: str = Icons.token_removed -) -> bool: - """DM a user about their new infraction and return True if the DM is successful.""" - log.trace(f"Sending {user} a DM about their {infr_type} infraction.") - - text = textwrap.dedent(f""" - **Type:** {infr_type.capitalize()} - **Expires:** {expires_at or "N/A"} - **Reason:** {reason or "No reason provided."} - """) - - embed = discord.Embed( - description=textwrap.shorten(text, width=2048, placeholder="..."), - colour=Colours.soft_red - ) - - embed.set_author(name="Infraction information", icon_url=icon_url, url=RULES_URL) - embed.title = f"Please review our rules over at {RULES_URL}" - embed.url = RULES_URL - - if infr_type in APPEALABLE_INFRACTIONS: - embed.set_footer( - text="To appeal this infraction, send an e-mail to appeals@pythondiscord.com" - ) - - return await send_private_embed(user, embed) - - -async def notify_pardon( - user: UserObject, - title: str, - content: str, - icon_url: str = Icons.user_verified -) -> bool: - """DM a user about their pardoned infraction and return True if the DM is successful.""" - log.trace(f"Sending {user} a DM about their pardoned infraction.") - - embed = discord.Embed( - description=content, - colour=Colours.soft_green - ) - - embed.set_author(name=title, icon_url=icon_url) - - return await send_private_embed(user, embed) - - -async def send_private_embed(user: UserObject, embed: discord.Embed) -> bool: - """ - A helper method for sending an embed to a user's DMs. - - Returns a boolean indicator of DM success. - """ - try: - await user.send(embed=embed) - return True - except (discord.HTTPException, discord.Forbidden, discord.NotFound): - log.debug( - f"Infraction-related information could not be sent to user {user} ({user.id}). " - "The user either could not be retrieved or probably disabled their DMs." - ) - return False diff --git a/bot/cogs/moderation/verification.py b/bot/cogs/moderation/verification.py new file mode 100644 index 000000000..ae156cf70 --- /dev/null +++ b/bot/cogs/moderation/verification.py @@ -0,0 +1,191 @@ +import logging +from contextlib import suppress + +from discord import Colour, Forbidden, Message, NotFound, Object +from discord.ext.commands import Cog, Context, command + +from bot import constants +from bot.bot import Bot +from bot.cogs.moderation import ModLog +from bot.decorators import in_whitelist, without_role +from bot.utils.checks import InWhitelistCheckFailure, without_role_check + +log = logging.getLogger(__name__) + +WELCOME_MESSAGE = f""" +Hello! Welcome to the server, and thanks for verifying yourself! + +For your records, these are the documents you accepted: + +`1)` Our rules, here: +`2)` Our privacy policy, here: - you can find information on how to have \ +your information removed here as well. + +Feel free to review them at any point! + +Additionally, if you'd like to receive notifications for the announcements \ +we post in <#{constants.Channels.announcements}> +from time to time, you can send `!subscribe` to <#{constants.Channels.bot_commands}> at any time \ +to assign yourself the **Announcements** role. We'll mention this role every time we make an announcement. + +If you'd like to unsubscribe from the announcement notifications, simply send `!unsubscribe` to \ +<#{constants.Channels.bot_commands}>. +""" + +BOT_MESSAGE_DELETE_DELAY = 10 + + +class Verification(Cog): + """User verification and role self-management.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @property + def mod_log(self) -> ModLog: + """Get currently loaded ModLog cog instance.""" + return self.bot.get_cog("ModLog") + + @Cog.listener() + async def on_message(self, message: Message) -> None: + """Check new message event for messages to the checkpoint channel & process.""" + if message.channel.id != constants.Channels.verification: + return # Only listen for #checkpoint messages + + if message.author.bot: + # They're a bot, delete their message after the delay. + await message.delete(delay=BOT_MESSAGE_DELETE_DELAY) + return + + # if a user mentions a role or guild member + # alert the mods in mod-alerts channel + if message.mentions or message.role_mentions: + log.debug( + f"{message.author} mentioned one or more users " + f"and/or roles in {message.channel.name}" + ) + + embed_text = ( + f"{message.author.mention} sent a message in " + f"{message.channel.mention} that contained user and/or role mentions." + f"\n\n**Original message:**\n>>> {message.content}" + ) + + # Send pretty mod log embed to mod-alerts + await self.mod_log.send_log_message( + icon_url=constants.Icons.filtering, + colour=Colour(constants.Colours.soft_red), + title=f"User/Role mentioned in {message.channel.name}", + text=embed_text, + thumbnail=message.author.avatar_url_as(static_format="png"), + channel_id=constants.Channels.mod_alerts, + ) + + ctx: Context = await self.bot.get_context(message) + if ctx.command is not None and ctx.command.name == "accept": + return + + if any(r.id == constants.Roles.verified for r in ctx.author.roles): + log.info( + f"{ctx.author} posted '{ctx.message.content}' " + "in the verification channel, but is already verified." + ) + return + + log.debug( + f"{ctx.author} posted '{ctx.message.content}' in the verification " + "channel. We are providing instructions how to verify." + ) + await ctx.send( + f"{ctx.author.mention} Please type `!accept` to verify that you accept our rules, " + f"and gain access to the rest of the server.", + delete_after=20 + ) + + log.trace(f"Deleting the message posted by {ctx.author}") + with suppress(NotFound): + await ctx.message.delete() + + @command(name='accept', aliases=('verify', 'verified', 'accepted'), hidden=True) + @without_role(constants.Roles.verified) + @in_whitelist(channels=(constants.Channels.verification,)) + async def accept_command(self, ctx: Context, *_) -> None: # We don't actually care about the args + """Accept our rules and gain access to the rest of the server.""" + log.debug(f"{ctx.author} called !accept. Assigning the 'Developer' role.") + await ctx.author.add_roles(Object(constants.Roles.verified), reason="Accepted the rules") + try: + await ctx.author.send(WELCOME_MESSAGE) + except Forbidden: + log.info(f"Sending welcome message failed for {ctx.author}.") + finally: + log.trace(f"Deleting accept message by {ctx.author}.") + with suppress(NotFound): + self.mod_log.ignore(constants.Event.message_delete, ctx.message.id) + await ctx.message.delete() + + @command(name='subscribe') + @in_whitelist(channels=(constants.Channels.bot_commands,)) + async def subscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args + """Subscribe to announcement notifications by assigning yourself the role.""" + has_role = False + + for role in ctx.author.roles: + if role.id == constants.Roles.announcements: + has_role = True + break + + if has_role: + await ctx.send(f"{ctx.author.mention} You're already subscribed!") + return + + log.debug(f"{ctx.author} called !subscribe. Assigning the 'Announcements' role.") + await ctx.author.add_roles(Object(constants.Roles.announcements), reason="Subscribed to announcements") + + log.trace(f"Deleting the message posted by {ctx.author}.") + + await ctx.send( + f"{ctx.author.mention} Subscribed to <#{constants.Channels.announcements}> notifications.", + ) + + @command(name='unsubscribe') + @in_whitelist(channels=(constants.Channels.bot_commands,)) + async def unsubscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args + """Unsubscribe from announcement notifications by removing the role from yourself.""" + has_role = False + + for role in ctx.author.roles: + if role.id == constants.Roles.announcements: + has_role = True + break + + if not has_role: + await ctx.send(f"{ctx.author.mention} You're already unsubscribed!") + return + + log.debug(f"{ctx.author} called !unsubscribe. Removing the 'Announcements' role.") + await ctx.author.remove_roles(Object(constants.Roles.announcements), reason="Unsubscribed from announcements") + + log.trace(f"Deleting the message posted by {ctx.author}.") + + await ctx.send( + f"{ctx.author.mention} Unsubscribed from <#{constants.Channels.announcements}> notifications." + ) + + # This cannot be static (must have a __func__ attribute). + async def cog_command_error(self, ctx: Context, error: Exception) -> None: + """Check for & ignore any InWhitelistCheckFailure.""" + if isinstance(error, InWhitelistCheckFailure): + error.handled = True + + @staticmethod + def bot_check(ctx: Context) -> bool: + """Block any command within the verification channel that is not !accept.""" + if ctx.channel.id == constants.Channels.verification and without_role_check(ctx, *constants.MODERATION_ROLES): + return ctx.command.name == "accept" + else: + return True + + +def setup(bot: Bot) -> None: + """Load the Verification cog.""" + bot.add_cog(Verification(bot)) diff --git a/bot/cogs/moderation/watchchannels/__init__.py b/bot/cogs/moderation/watchchannels/__init__.py new file mode 100644 index 000000000..69d118df6 --- /dev/null +++ b/bot/cogs/moderation/watchchannels/__init__.py @@ -0,0 +1,9 @@ +from bot.bot import Bot +from .bigbrother import BigBrother +from .talentpool import TalentPool + + +def setup(bot: Bot) -> None: + """Load the BigBrother and TalentPool cogs.""" + bot.add_cog(BigBrother(bot)) + bot.add_cog(TalentPool(bot)) diff --git a/bot/cogs/moderation/watchchannels/bigbrother.py b/bot/cogs/moderation/watchchannels/bigbrother.py new file mode 100644 index 000000000..0c72e88f7 --- /dev/null +++ b/bot/cogs/moderation/watchchannels/bigbrother.py @@ -0,0 +1,165 @@ +import logging +import textwrap +from collections import ChainMap + +from discord.ext.commands import Cog, Context, group + +from bot.bot import Bot +from bot.cogs.moderation.infraction.utils import post_infraction +from bot.constants import Channels, MODERATION_ROLES, Webhooks +from bot.converters import FetchedMember +from bot.decorators import with_role +from .watchchannel import WatchChannel + +log = logging.getLogger(__name__) + + +class BigBrother(WatchChannel, Cog, name="Big Brother"): + """Monitors users by relaying their messages to a watch channel to assist with moderation.""" + + def __init__(self, bot: Bot) -> None: + super().__init__( + bot, + destination=Channels.big_brother_logs, + webhook_id=Webhooks.big_brother, + api_endpoint='bot/infractions', + api_default_params={'active': 'true', 'type': 'watch', 'ordering': '-inserted_at'}, + logger=log + ) + + @group(name='bigbrother', aliases=('bb',), invoke_without_command=True) + @with_role(*MODERATION_ROLES) + async def bigbrother_group(self, ctx: Context) -> None: + """Monitors users by relaying their messages to the Big Brother watch channel.""" + await ctx.send_help(ctx.command) + + @bigbrother_group.command(name='watched', aliases=('all', 'list')) + @with_role(*MODERATION_ROLES) + async def watched_command( + self, ctx: Context, oldest_first: bool = False, update_cache: bool = True + ) -> None: + """ + Shows the users that are currently being monitored by Big Brother. + + The optional kwarg `oldest_first` can be used to order the list by oldest watched. + + The optional kwarg `update_cache` can be used to update the user + cache using the API before listing the users. + """ + await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache) + + @bigbrother_group.command(name='oldest') + @with_role(*MODERATION_ROLES) + async def oldest_command(self, ctx: Context, update_cache: bool = True) -> None: + """ + Shows Big Brother monitored users ordered by oldest watched. + + The optional kwarg `update_cache` can be used to update the user + cache using the API before listing the users. + """ + await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache) + + @bigbrother_group.command(name='watch', aliases=('w',)) + @with_role(*MODERATION_ROLES) + async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: + """ + Relay messages sent by the given `user` to the `#big-brother` channel. + + A `reason` for adding the user to Big Brother is required and will be displayed + in the header when relaying messages of this user to the watchchannel. + """ + await self.apply_watch(ctx, user, reason) + + @bigbrother_group.command(name='unwatch', aliases=('uw',)) + @with_role(*MODERATION_ROLES) + async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: + """Stop relaying messages by the given `user`.""" + await self.apply_unwatch(ctx, user, reason) + + async def apply_watch(self, ctx: Context, user: FetchedMember, reason: str) -> None: + """ + Add `user` to watched users and apply a watch infraction with `reason`. + + A message indicating the result of the operation is sent to `ctx`. + The message will include `user`'s previous watch infraction history, if it exists. + """ + if user.bot: + await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.") + return + + if not await self.fetch_user_cache(): + await ctx.send(f":x: Updating the user cache failed, can't watch user {user}") + return + + if user.id in self.watched_users: + await ctx.send(f":x: {user} is already being watched.") + return + + response = await post_infraction(ctx, user, 'watch', reason, hidden=True, active=True) + + if response is not None: + self.watched_users[user.id] = response + msg = f":white_check_mark: Messages sent by {user} will now be relayed to Big Brother." + + history = await self.bot.api_client.get( + self.api_endpoint, + params={ + "user__id": str(user.id), + "active": "false", + 'type': 'watch', + 'ordering': '-inserted_at' + } + ) + + if len(history) > 1: + total = f"({len(history) // 2} previous infractions in total)" + end_reason = textwrap.shorten(history[0]["reason"], width=500, placeholder="...") + start_reason = f"Watched: {textwrap.shorten(history[1]['reason'], width=500, placeholder='...')}" + msg += f"\n\nUser's previous watch reasons {total}:```{start_reason}\n\n{end_reason}```" + else: + msg = ":x: Failed to post the infraction: response was empty." + + await ctx.send(msg) + + async def apply_unwatch(self, ctx: Context, user: FetchedMember, reason: str, send_message: bool = True) -> None: + """ + Remove `user` from watched users and mark their infraction as inactive with `reason`. + + If `send_message` is True, a message indicating the result of the operation is sent to + `ctx`. + """ + active_watches = await self.bot.api_client.get( + self.api_endpoint, + params=ChainMap( + self.api_default_params, + {"user__id": str(user.id)} + ) + ) + if active_watches: + log.trace("Active watches for user found. Attempting to remove.") + [infraction] = active_watches + + await self.bot.api_client.patch( + f"{self.api_endpoint}/{infraction['id']}", + json={'active': False} + ) + + await post_infraction(ctx, user, 'watch', f"Unwatched: {reason}", hidden=True, active=False) + + self._remove_user(user.id) + + if not send_message: # Prevents a message being sent to the channel if part of a permanent ban + log.debug(f"Perma-banned user {user} was unwatched.") + return + log.trace("User is not banned. Sending message to channel") + message = f":white_check_mark: Messages sent by {user} will no longer be relayed." + + else: + log.trace("No active watches found for user.") + if not send_message: # Prevents a message being sent to the channel if part of a permanent ban + log.debug(f"{user} was not on the watch list; no removal necessary.") + return + log.trace("User is not perma banned. Send the error message.") + message = ":x: The specified user is currently not being watched." + + await ctx.send(message) diff --git a/bot/cogs/moderation/watchchannels/talentpool.py b/bot/cogs/moderation/watchchannels/talentpool.py new file mode 100644 index 000000000..89256e92e --- /dev/null +++ b/bot/cogs/moderation/watchchannels/talentpool.py @@ -0,0 +1,264 @@ +import logging +import textwrap +from collections import ChainMap + +from discord import Color, Embed, Member +from discord.ext.commands import Cog, Context, group + +from bot.api import ResponseCodeError +from bot.bot import Bot +from bot.constants import Channels, Guild, MODERATION_ROLES, STAFF_ROLES, Webhooks +from bot.converters import FetchedMember +from bot.decorators import with_role +from bot.pagination import LinePaginator +from bot.utils import time +from .watchchannel import WatchChannel + +log = logging.getLogger(__name__) + + +class TalentPool(WatchChannel, Cog, name="Talentpool"): + """Relays messages of helper candidates to a watch channel to observe them.""" + + def __init__(self, bot: Bot) -> None: + super().__init__( + bot, + destination=Channels.talent_pool, + webhook_id=Webhooks.talent_pool, + api_endpoint='bot/nominations', + api_default_params={'active': 'true', 'ordering': '-inserted_at'}, + logger=log, + ) + + @group(name='talentpool', aliases=('tp', 'talent', 'nomination', 'n'), invoke_without_command=True) + @with_role(*MODERATION_ROLES) + async def nomination_group(self, ctx: Context) -> None: + """Highlights the activity of helper nominees by relaying their messages to the talent pool channel.""" + await ctx.send_help(ctx.command) + + @nomination_group.command(name='watched', aliases=('all', 'list')) + @with_role(*MODERATION_ROLES) + async def watched_command( + self, ctx: Context, oldest_first: bool = False, update_cache: bool = True + ) -> None: + """ + Shows the users that are currently being monitored in the talent pool. + + The optional kwarg `oldest_first` can be used to order the list by oldest nomination. + + The optional kwarg `update_cache` can be used to update the user + cache using the API before listing the users. + """ + await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache) + + @nomination_group.command(name='oldest') + @with_role(*MODERATION_ROLES) + async def oldest_command(self, ctx: Context, update_cache: bool = True) -> None: + """ + Shows talent pool monitored users ordered by oldest nomination. + + The optional kwarg `update_cache` can be used to update the user + cache using the API before listing the users. + """ + await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache) + + @nomination_group.command(name='watch', aliases=('w', 'add', 'a')) + @with_role(*STAFF_ROLES) + async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: + """ + Relay messages sent by the given `user` to the `#talent-pool` channel. + + A `reason` for adding the user to the talent pool is required and will be displayed + in the header when relaying messages of this user to the channel. + """ + if user.bot: + await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.") + return + + if isinstance(user, Member) and any(role.id in STAFF_ROLES for role in user.roles): + await ctx.send(":x: Nominating staff members, eh? Here's a cookie :cookie:") + return + + if not await self.fetch_user_cache(): + await ctx.send(f":x: Failed to update the user cache; can't add {user}") + return + + if user.id in self.watched_users: + await ctx.send(f":x: {user} is already being watched in the talent pool") + return + + # Manual request with `raise_for_status` as False because we want the actual response + session = self.bot.api_client.session + url = self.bot.api_client._url_for(self.api_endpoint) + kwargs = { + 'json': { + 'actor': ctx.author.id, + 'reason': reason, + 'user': user.id + }, + 'raise_for_status': False, + } + async with session.post(url, **kwargs) as resp: + response_data = await resp.json() + + if resp.status == 400 and response_data.get('user', False): + await ctx.send(":x: The specified user can't be found in the database tables") + return + else: + resp.raise_for_status() + + self.watched_users[user.id] = response_data + msg = f":white_check_mark: Messages sent by {user} will now be relayed to the talent pool channel" + + history = await self.bot.api_client.get( + self.api_endpoint, + params={ + "user__id": str(user.id), + "active": "false", + "ordering": "-inserted_at" + } + ) + + if history: + total = f"({len(history)} previous nominations in total)" + start_reason = f"Watched: {textwrap.shorten(history[0]['reason'], width=500, placeholder='...')}" + end_reason = f"Unwatched: {textwrap.shorten(history[0]['end_reason'], width=500, placeholder='...')}" + msg += f"\n\nUser's previous watch reasons {total}:```{start_reason}\n\n{end_reason}```" + + await ctx.send(msg) + + @nomination_group.command(name='history', aliases=('info', 'search')) + @with_role(*MODERATION_ROLES) + async def history_command(self, ctx: Context, user: FetchedMember) -> None: + """Shows the specified user's nomination history.""" + result = await self.bot.api_client.get( + self.api_endpoint, + params={ + 'user__id': str(user.id), + 'ordering': "-active,-inserted_at" + } + ) + if not result: + await ctx.send(":warning: This user has never been nominated") + return + + embed = Embed( + title=f"Nominations for {user.display_name} `({user.id})`", + color=Color.blue() + ) + lines = [self._nomination_to_string(nomination) for nomination in result] + await LinePaginator.paginate( + lines, + ctx=ctx, + embed=embed, + empty=True, + max_lines=3, + max_size=1000 + ) + + @nomination_group.command(name='unwatch', aliases=('end', )) + @with_role(*MODERATION_ROLES) + async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: + """ + Ends the active nomination of the specified user with the given reason. + + Providing a `reason` is required. + """ + active_nomination = await self.bot.api_client.get( + self.api_endpoint, + params=ChainMap( + self.api_default_params, + {"user__id": str(user.id)} + ) + ) + + if not active_nomination: + await ctx.send(":x: The specified user does not have an active nomination") + return + + [nomination] = active_nomination + await self.bot.api_client.patch( + f"{self.api_endpoint}/{nomination['id']}", + json={'end_reason': reason, 'active': False} + ) + await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed") + self._remove_user(user.id) + + @nomination_group.group(name='edit', aliases=('e',), invoke_without_command=True) + @with_role(*MODERATION_ROLES) + async def nomination_edit_group(self, ctx: Context) -> None: + """Commands to edit nominations.""" + await ctx.send_help(ctx.command) + + @nomination_edit_group.command(name='reason') + @with_role(*MODERATION_ROLES) + async def edit_reason_command(self, ctx: Context, nomination_id: int, *, reason: str) -> None: + """ + Edits the reason/unnominate reason for the nomination with the given `id` depending on the status. + + If the nomination is active, the reason for nominating the user will be edited; + If the nomination is no longer active, the reason for ending the nomination will be edited instead. + """ + try: + nomination = await self.bot.api_client.get(f"{self.api_endpoint}/{nomination_id}") + except ResponseCodeError as e: + if e.response.status == 404: + self.log.trace(f"Nomination API 404: Can't nomination with id {nomination_id}") + await ctx.send(f":x: Can't find a nomination with id `{nomination_id}`") + return + else: + raise + + field = "reason" if nomination["active"] else "end_reason" + + self.log.trace(f"Changing {field} for nomination with id {nomination_id} to {reason}") + + await self.bot.api_client.patch( + f"{self.api_endpoint}/{nomination_id}", + json={field: reason} + ) + + await ctx.send(f":white_check_mark: Updated the {field} of the nomination!") + + def _nomination_to_string(self, nomination_object: dict) -> str: + """Creates a string representation of a nomination.""" + guild = self.bot.get_guild(Guild.id) + + actor_id = nomination_object["actor"] + actor = guild.get_member(actor_id) + + active = nomination_object["active"] + log.debug(active) + log.debug(type(nomination_object["inserted_at"])) + + start_date = time.format_infraction(nomination_object["inserted_at"]) + if active: + lines = textwrap.dedent( + f""" + =============== + Status: **Active** + Date: {start_date} + Actor: {actor.mention if actor else actor_id} + Reason: {nomination_object["reason"]} + Nomination ID: `{nomination_object["id"]}` + =============== + """ + ) + else: + end_date = time.format_infraction(nomination_object["ended_at"]) + lines = textwrap.dedent( + f""" + =============== + Status: Inactive + Date: {start_date} + Actor: {actor.mention if actor else actor_id} + Reason: {nomination_object["reason"]} + + End date: {end_date} + Unwatch reason: {nomination_object["end_reason"]} + Nomination ID: `{nomination_object["id"]}` + =============== + """ + ) + + return lines.strip() diff --git a/bot/cogs/moderation/watchchannels/watchchannel.py b/bot/cogs/moderation/watchchannels/watchchannel.py new file mode 100644 index 000000000..044077350 --- /dev/null +++ b/bot/cogs/moderation/watchchannels/watchchannel.py @@ -0,0 +1,348 @@ +import asyncio +import logging +import re +import textwrap +from abc import abstractmethod +from collections import defaultdict, deque +from dataclasses import dataclass +from typing import Optional + +import dateutil.parser +import discord +from discord import Color, DMChannel, Embed, HTTPException, Message, errors +from discord.ext.commands import Cog, Context + +from bot.api import ResponseCodeError +from bot.bot import Bot +from bot.cogs.moderation import ModLog +from bot.constants import BigBrother as BigBrotherConfig, Guild as GuildConfig, Icons +from bot.pagination import LinePaginator +from bot.utils import CogABCMeta, messages +from bot.utils.time import time_since + +log = logging.getLogger(__name__) + +URL_RE = re.compile(r"(https?://[^\s]+)") + + +@dataclass +class MessageHistory: + """Represents a watch channel's message history.""" + + last_author: Optional[int] = None + last_channel: Optional[int] = None + message_count: int = 0 + + +class WatchChannel(metaclass=CogABCMeta): + """ABC with functionality for relaying users' messages to a certain channel.""" + + @abstractmethod + def __init__( + self, + bot: Bot, + destination: int, + webhook_id: int, + api_endpoint: str, + api_default_params: dict, + logger: logging.Logger + ) -> None: + self.bot = bot + + self.destination = destination # E.g., Channels.big_brother_logs + self.webhook_id = webhook_id # E.g., Webhooks.big_brother + self.api_endpoint = api_endpoint # E.g., 'bot/infractions' + self.api_default_params = api_default_params # E.g., {'active': 'true', 'type': 'watch'} + self.log = logger # Logger of the child cog for a correct name in the logs + + self._consume_task = None + self.watched_users = defaultdict(dict) + self.message_queue = defaultdict(lambda: defaultdict(deque)) + self.consumption_queue = {} + self.retries = 5 + self.retry_delay = 10 + self.channel = None + self.webhook = None + self.message_history = MessageHistory() + + self._start = self.bot.loop.create_task(self.start_watchchannel()) + + @property + def modlog(self) -> ModLog: + """Provides access to the ModLog cog for alert purposes.""" + return self.bot.get_cog("ModLog") + + @property + def consuming_messages(self) -> bool: + """Checks if a consumption task is currently running.""" + if self._consume_task is None: + return False + + if self._consume_task.done(): + exc = self._consume_task.exception() + if exc: + self.log.exception( + "The message queue consume task has failed with:", + exc_info=exc + ) + return False + + return True + + async def start_watchchannel(self) -> None: + """Starts the watch channel by getting the channel, webhook, and user cache ready.""" + await self.bot.wait_until_guild_available() + + try: + self.channel = await self.bot.fetch_channel(self.destination) + except HTTPException: + self.log.exception(f"Failed to retrieve the text channel with id `{self.destination}`") + + try: + self.webhook = await self.bot.fetch_webhook(self.webhook_id) + except discord.HTTPException: + self.log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`") + + if self.channel is None or self.webhook is None: + self.log.error("Failed to start the watch channel; unloading the cog.") + + message = textwrap.dedent( + f""" + An error occurred while loading the text channel or webhook. + + TextChannel: {"**Failed to load**" if self.channel is None else "Loaded successfully"} + Webhook: {"**Failed to load**" if self.webhook is None else "Loaded successfully"} + + The Cog has been unloaded. + """ + ) + + await self.modlog.send_log_message( + title=f"Error: Failed to initialize the {self.__class__.__name__} watch channel", + text=message, + ping_everyone=True, + icon_url=Icons.token_removed, + colour=Color.red() + ) + + self.bot.remove_cog(self.__class__.__name__) + return + + if not await self.fetch_user_cache(): + await self.modlog.send_log_message( + title=f"Warning: Failed to retrieve user cache for the {self.__class__.__name__} watch channel", + text="Could not retrieve the list of watched users from the API and messages will not be relayed.", + ping_everyone=True, + icon_url=Icons.token_removed, + colour=Color.red() + ) + + async def fetch_user_cache(self) -> bool: + """ + Fetches watched users from the API and updates the watched user cache accordingly. + + This function returns `True` if the update succeeded. + """ + try: + data = await self.bot.api_client.get(self.api_endpoint, params=self.api_default_params) + except ResponseCodeError as err: + self.log.exception("Failed to fetch the watched users from the API", exc_info=err) + return False + + self.watched_users = defaultdict(dict) + + for entry in data: + user_id = entry.pop('user') + self.watched_users[user_id] = entry + + return True + + @Cog.listener() + async def on_message(self, msg: Message) -> None: + """Queues up messages sent by watched users.""" + if msg.author.id in self.watched_users: + if not self.consuming_messages: + self._consume_task = self.bot.loop.create_task(self.consume_messages()) + + self.log.trace(f"Received message: {msg.content} ({len(msg.attachments)} attachments)") + self.message_queue[msg.author.id][msg.channel.id].append(msg) + + async def consume_messages(self, delay_consumption: bool = True) -> None: + """Consumes the message queues to log watched users' messages.""" + if delay_consumption: + self.log.trace(f"Sleeping {BigBrotherConfig.log_delay} seconds before consuming message queue") + await asyncio.sleep(BigBrotherConfig.log_delay) + + self.log.trace("Started consuming the message queue") + + # If the previous consumption Task failed, first consume the existing comsumption_queue + if not self.consumption_queue: + self.consumption_queue = self.message_queue.copy() + self.message_queue.clear() + + for user_channel_queues in self.consumption_queue.values(): + for channel_queue in user_channel_queues.values(): + while channel_queue: + msg = channel_queue.popleft() + + self.log.trace(f"Consuming message {msg.id} ({len(msg.attachments)} attachments)") + await self.relay_message(msg) + + self.consumption_queue.clear() + + if self.message_queue: + self.log.trace("Channel queue not empty: Continuing consuming queues") + self._consume_task = self.bot.loop.create_task(self.consume_messages(delay_consumption=False)) + else: + self.log.trace("Done consuming messages.") + + async def webhook_send( + self, + content: Optional[str] = None, + username: Optional[str] = None, + avatar_url: Optional[str] = None, + embed: Optional[Embed] = None, + ) -> None: + """Sends a message to the webhook with the specified kwargs.""" + username = messages.sub_clyde(username) + try: + await self.webhook.send(content=content, username=username, avatar_url=avatar_url, embed=embed) + except discord.HTTPException as exc: + self.log.exception( + "Failed to send a message to the webhook", + exc_info=exc + ) + + async def relay_message(self, msg: Message) -> None: + """Relays the message to the relevant watch channel.""" + limit = BigBrotherConfig.header_message_limit + + if ( + msg.author.id != self.message_history.last_author + or msg.channel.id != self.message_history.last_channel + or self.message_history.message_count >= limit + ): + self.message_history = MessageHistory(last_author=msg.author.id, last_channel=msg.channel.id) + + await self.send_header(msg) + + cleaned_content = msg.clean_content + + if cleaned_content: + # Put all non-media URLs in a code block to prevent embeds + media_urls = {embed.url for embed in msg.embeds if embed.type in ("image", "video")} + for url in URL_RE.findall(cleaned_content): + if url not in media_urls: + cleaned_content = cleaned_content.replace(url, f"`{url}`") + await self.webhook_send( + cleaned_content, + username=msg.author.display_name, + avatar_url=msg.author.avatar_url + ) + + if msg.attachments: + try: + await messages.send_attachments(msg, self.webhook) + except (errors.Forbidden, errors.NotFound): + e = Embed( + description=":x: **This message contained an attachment, but it could not be retrieved**", + color=Color.red() + ) + await self.webhook_send( + embed=e, + username=msg.author.display_name, + avatar_url=msg.author.avatar_url + ) + except discord.HTTPException as exc: + self.log.exception( + "Failed to send an attachment to the webhook", + exc_info=exc + ) + + self.message_history.message_count += 1 + + async def send_header(self, msg: Message) -> None: + """Sends a header embed with information about the relayed messages to the watch channel.""" + user_id = msg.author.id + + guild = self.bot.get_guild(GuildConfig.id) + actor = guild.get_member(self.watched_users[user_id]['actor']) + actor = actor.display_name if actor else self.watched_users[user_id]['actor'] + + inserted_at = self.watched_users[user_id]['inserted_at'] + time_delta = self._get_time_delta(inserted_at) + + reason = self.watched_users[user_id]['reason'] + + if isinstance(msg.channel, DMChannel): + # If a watched user DMs the bot there won't be a channel name or jump URL + # This could technically include a GroupChannel but bot's can't be in those + message_jump = "via DM" + else: + message_jump = f"in [#{msg.channel.name}]({msg.jump_url})" + + footer = f"Added {time_delta} by {actor} | Reason: {reason}" + embed = Embed(description=f"{msg.author.mention} {message_jump}") + embed.set_footer(text=textwrap.shorten(footer, width=128, placeholder="...")) + + await self.webhook_send(embed=embed, username=msg.author.display_name, avatar_url=msg.author.avatar_url) + + async def list_watched_users( + self, ctx: Context, oldest_first: bool = False, update_cache: bool = True + ) -> None: + """ + Gives an overview of the watched user list for this channel. + + The optional kwarg `oldest_first` orders the list by oldest entry. + + The optional kwarg `update_cache` specifies whether the cache should + be refreshed by polling the API. + """ + if update_cache: + if not await self.fetch_user_cache(): + await ctx.send(f":x: Failed to update {self.__class__.__name__} user cache, serving from cache") + update_cache = False + + lines = [] + for user_id, user_data in self.watched_users.items(): + inserted_at = user_data['inserted_at'] + time_delta = self._get_time_delta(inserted_at) + lines.append(f"• <@{user_id}> (added {time_delta})") + + if oldest_first: + lines.reverse() + + lines = lines or ("There's nothing here yet.",) + + embed = Embed( + title=f"{self.__class__.__name__} watched users ({'updated' if update_cache else 'cached'})", + color=Color.blue() + ) + await LinePaginator.paginate(lines, ctx, embed, empty=False) + + @staticmethod + def _get_time_delta(time_string: str) -> str: + """Returns the time in human-readable time delta format.""" + date_time = dateutil.parser.isoparse(time_string).replace(tzinfo=None) + time_delta = time_since(date_time, precision="minutes", max_units=1) + + return time_delta + + def _remove_user(self, user_id: int) -> None: + """Removes a user from a watch channel.""" + self.watched_users.pop(user_id, None) + self.message_queue.pop(user_id, None) + self.consumption_queue.pop(user_id, None) + + def cog_unload(self) -> None: + """Takes care of unloading the cog and canceling the consumption task.""" + self.log.trace("Unloading the cog") + if self._consume_task and not self._consume_task.done(): + self._consume_task.cancel() + try: + self._consume_task.result() + except asyncio.CancelledError as e: + self.log.exception( + "The consume task was canceled. Messages may be lost.", + exc_info=e + ) diff --git a/bot/cogs/python_news.py b/bot/cogs/python_news.py deleted file mode 100644 index 0ab5738a4..000000000 --- a/bot/cogs/python_news.py +++ /dev/null @@ -1,232 +0,0 @@ -import logging -import typing as t -from datetime import date, datetime - -import discord -import feedparser -from bs4 import BeautifulSoup -from discord.ext.commands import Cog -from discord.ext.tasks import loop - -from bot import constants -from bot.bot import Bot -from bot.utils.webhooks import send_webhook - -PEPS_RSS_URL = "https://www.python.org/dev/peps/peps.rss/" - -RECENT_THREADS_TEMPLATE = "https://mail.python.org/archives/list/{name}@python.org/recent-threads" -THREAD_TEMPLATE_URL = "https://mail.python.org/archives/api/list/{name}@python.org/thread/{id}/" -MAILMAN_PROFILE_URL = "https://mail.python.org/archives/users/{id}/" -THREAD_URL = "https://mail.python.org/archives/list/{list}@python.org/thread/{id}/" - -AVATAR_URL = "https://www.python.org/static/opengraph-icon-200x200.png" - -log = logging.getLogger(__name__) - - -class PythonNews(Cog): - """Post new PEPs and Python News to `#python-news`.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.webhook_names = {} - self.webhook: t.Optional[discord.Webhook] = None - - self.bot.loop.create_task(self.get_webhook_names()) - self.bot.loop.create_task(self.get_webhook_and_channel()) - - async def start_tasks(self) -> None: - """Start the tasks for fetching new PEPs and mailing list messages.""" - self.fetch_new_media.start() - - @loop(minutes=20) - async def fetch_new_media(self) -> None: - """Fetch new mailing list messages and then new PEPs.""" - await self.post_maillist_news() - await self.post_pep_news() - - async def sync_maillists(self) -> None: - """Sync currently in-use maillists with API.""" - # Wait until guild is available to avoid running before everything is ready - await self.bot.wait_until_guild_available() - - response = await self.bot.api_client.get("bot/bot-settings/news") - for mail in constants.PythonNews.mail_lists: - if mail not in response["data"]: - response["data"][mail] = [] - - # Because we are handling PEPs differently, we don't include it to mail lists - if "pep" not in response["data"]: - response["data"]["pep"] = [] - - await self.bot.api_client.put("bot/bot-settings/news", json=response) - - async def get_webhook_names(self) -> None: - """Get webhook author names from maillist API.""" - await self.bot.wait_until_guild_available() - - async with self.bot.http_session.get("https://mail.python.org/archives/api/lists") as resp: - lists = await resp.json() - - for mail in lists: - if mail["name"].split("@")[0] in constants.PythonNews.mail_lists: - self.webhook_names[mail["name"].split("@")[0]] = mail["display_name"] - - async def post_pep_news(self) -> None: - """Fetch new PEPs and when they don't have announcement in #python-news, create it.""" - # Wait until everything is ready and http_session available - await self.bot.wait_until_guild_available() - await self.sync_maillists() - - async with self.bot.http_session.get(PEPS_RSS_URL) as resp: - data = feedparser.parse(await resp.text("utf-8")) - - news_listing = await self.bot.api_client.get("bot/bot-settings/news") - payload = news_listing.copy() - pep_numbers = news_listing["data"]["pep"] - - # Reverse entries to send oldest first - data["entries"].reverse() - for new in data["entries"]: - try: - new_datetime = datetime.strptime(new["published"], "%a, %d %b %Y %X %Z") - except ValueError: - log.warning(f"Wrong datetime format passed in PEP new: {new['published']}") - continue - pep_nr = new["title"].split(":")[0].split()[1] - if ( - pep_nr in pep_numbers - or new_datetime.date() < date.today() - ): - continue - - # Build an embed and send a webhook - embed = discord.Embed( - title=new["title"], - description=new["summary"], - timestamp=new_datetime, - url=new["link"], - colour=constants.Colours.soft_green - ) - embed.set_footer(text=data["feed"]["title"], icon_url=AVATAR_URL) - msg = await send_webhook( - webhook=self.webhook, - username=data["feed"]["title"], - embed=embed, - avatar_url=AVATAR_URL, - wait=True, - ) - payload["data"]["pep"].append(pep_nr) - - # Increase overall PEP new stat - self.bot.stats.incr("python_news.posted.pep") - - if msg.channel.is_news(): - log.trace("Publishing PEP annnouncement because it was in a news channel") - await msg.publish() - - # Apply new sent news to DB to avoid duplicate sending - await self.bot.api_client.put("bot/bot-settings/news", json=payload) - - async def post_maillist_news(self) -> None: - """Send new maillist threads to #python-news that is listed in configuration.""" - await self.bot.wait_until_guild_available() - await self.sync_maillists() - existing_news = await self.bot.api_client.get("bot/bot-settings/news") - payload = existing_news.copy() - - for maillist in constants.PythonNews.mail_lists: - async with self.bot.http_session.get(RECENT_THREADS_TEMPLATE.format(name=maillist)) as resp: - recents = BeautifulSoup(await resp.text(), features="lxml") - - # When a

element is present in the response then the mailing list - # has not had any activity during the current month, so therefore it - # can be ignored. - if recents.p: - continue - - for thread in recents.html.body.div.find_all("a", href=True): - # We want only these threads that have identifiers - if "latest" in thread["href"]: - continue - - thread_information, email_information = await self.get_thread_and_first_mail( - maillist, thread["href"].split("/")[-2] - ) - - try: - new_date = datetime.strptime(email_information["date"], "%Y-%m-%dT%X%z") - except ValueError: - log.warning(f"Invalid datetime from Thread email: {email_information['date']}") - continue - - if ( - thread_information["thread_id"] in existing_news["data"][maillist] - or 'Re: ' in thread_information["subject"] - or new_date.date() < date.today() - ): - continue - - content = email_information["content"] - link = THREAD_URL.format(id=thread["href"].split("/")[-2], list=maillist) - - # Build an embed and send a message to the webhook - embed = discord.Embed( - title=thread_information["subject"], - description=content[:500] + f"... [continue reading]({link})" if len(content) > 500 else content, - timestamp=new_date, - url=link, - colour=constants.Colours.soft_green - ) - embed.set_author( - name=f"{email_information['sender_name']} ({email_information['sender']['address']})", - url=MAILMAN_PROFILE_URL.format(id=email_information["sender"]["mailman_id"]), - ) - embed.set_footer( - text=f"Posted to {self.webhook_names[maillist]}", - icon_url=AVATAR_URL, - ) - msg = await send_webhook( - webhook=self.webhook, - username=self.webhook_names[maillist], - embed=embed, - avatar_url=AVATAR_URL, - wait=True, - ) - payload["data"][maillist].append(thread_information["thread_id"]) - - # Increase this specific maillist counter in stats - self.bot.stats.incr(f"python_news.posted.{maillist.replace('-', '_')}") - - if msg.channel.is_news(): - log.trace("Publishing mailing list message because it was in a news channel") - await msg.publish() - - await self.bot.api_client.put("bot/bot-settings/news", json=payload) - - async def get_thread_and_first_mail(self, maillist: str, thread_identifier: str) -> t.Tuple[t.Any, t.Any]: - """Get mail thread and first mail from mail.python.org based on `maillist` and `thread_identifier`.""" - async with self.bot.http_session.get( - THREAD_TEMPLATE_URL.format(name=maillist, id=thread_identifier) - ) as resp: - thread_information = await resp.json() - - async with self.bot.http_session.get(thread_information["starting_email"]) as resp: - email_information = await resp.json() - return thread_information, email_information - - async def get_webhook_and_channel(self) -> None: - """Storage #python-news channel Webhook and `TextChannel` to `News.webhook` and `channel`.""" - await self.bot.wait_until_guild_available() - self.webhook = await self.bot.fetch_webhook(constants.PythonNews.webhook) - - await self.start_tasks() - - def cog_unload(self) -> None: - """Stop news posting tasks on cog unload.""" - self.fetch_new_media.cancel() - - -def setup(bot: Bot) -> None: - """Add `News` cog.""" - bot.add_cog(PythonNews(bot)) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py deleted file mode 100644 index d853ab2ea..000000000 --- a/bot/cogs/reddit.py +++ /dev/null @@ -1,304 +0,0 @@ -import asyncio -import logging -import random -import textwrap -from collections import namedtuple -from datetime import datetime, timedelta -from typing import List - -from aiohttp import BasicAuth, ClientError -from discord import Colour, Embed, TextChannel -from discord.ext.commands import Cog, Context, group -from discord.ext.tasks import loop - -from bot.bot import Bot -from bot.constants import Channels, ERROR_REPLIES, Emojis, Reddit as RedditConfig, STAFF_ROLES, Webhooks -from bot.converters import Subreddit -from bot.decorators import with_role -from bot.pagination import LinePaginator -from bot.utils.messages import sub_clyde - -log = logging.getLogger(__name__) - -AccessToken = namedtuple("AccessToken", ["token", "expires_at"]) - - -class Reddit(Cog): - """Track subreddit posts and show detailed statistics about them.""" - - HEADERS = {"User-Agent": "python3:python-discord/bot:1.0.0 (by /u/PythonDiscord)"} - URL = "https://www.reddit.com" - OAUTH_URL = "https://oauth.reddit.com" - MAX_RETRIES = 3 - - def __init__(self, bot: Bot): - self.bot = bot - - self.webhook = None - self.access_token = None - self.client_auth = BasicAuth(RedditConfig.client_id, RedditConfig.secret) - - bot.loop.create_task(self.init_reddit_ready()) - self.auto_poster_loop.start() - - def cog_unload(self) -> None: - """Stop the loop task and revoke the access token when the cog is unloaded.""" - self.auto_poster_loop.cancel() - if self.access_token and self.access_token.expires_at > datetime.utcnow(): - asyncio.create_task(self.revoke_access_token()) - - async def init_reddit_ready(self) -> None: - """Sets the reddit webhook when the cog is loaded.""" - await self.bot.wait_until_guild_available() - if not self.webhook: - self.webhook = await self.bot.fetch_webhook(Webhooks.reddit) - - @property - def channel(self) -> TextChannel: - """Get the #reddit channel object from the bot's cache.""" - return self.bot.get_channel(Channels.reddit) - - async def get_access_token(self) -> None: - """ - Get a Reddit API OAuth2 access token and assign it to self.access_token. - - A token is valid for 1 hour. There will be MAX_RETRIES to get a token, after which the cog - will be unloaded and a ClientError raised if retrieval was still unsuccessful. - """ - for i in range(1, self.MAX_RETRIES + 1): - response = await self.bot.http_session.post( - url=f"{self.URL}/api/v1/access_token", - headers=self.HEADERS, - auth=self.client_auth, - data={ - "grant_type": "client_credentials", - "duration": "temporary" - } - ) - - if response.status == 200 and response.content_type == "application/json": - content = await response.json() - expiration = int(content["expires_in"]) - 60 # Subtract 1 minute for leeway. - self.access_token = AccessToken( - token=content["access_token"], - expires_at=datetime.utcnow() + timedelta(seconds=expiration) - ) - - log.debug(f"New token acquired; expires on UTC {self.access_token.expires_at}") - return - else: - log.debug( - f"Failed to get an access token: " - f"status {response.status} & content type {response.content_type}; " - f"retrying ({i}/{self.MAX_RETRIES})" - ) - - await asyncio.sleep(3) - - self.bot.remove_cog(self.qualified_name) - raise ClientError("Authentication with the Reddit API failed. Unloading the cog.") - - async def revoke_access_token(self) -> None: - """ - Revoke the OAuth2 access token for the Reddit API. - - For security reasons, it's good practice to revoke the token when it's no longer being used. - """ - response = await self.bot.http_session.post( - url=f"{self.URL}/api/v1/revoke_token", - headers=self.HEADERS, - auth=self.client_auth, - data={ - "token": self.access_token.token, - "token_type_hint": "access_token" - } - ) - - if response.status == 204 and response.content_type == "application/json": - self.access_token = None - else: - log.warning(f"Unable to revoke access token: status {response.status}.") - - async def fetch_posts(self, route: str, *, amount: int = 25, params: dict = None) -> List[dict]: - """A helper method to fetch a certain amount of Reddit posts at a given route.""" - # Reddit's JSON responses only provide 25 posts at most. - if not 25 >= amount > 0: - raise ValueError("Invalid amount of subreddit posts requested.") - - # Renew the token if necessary. - if not self.access_token or self.access_token.expires_at < datetime.utcnow(): - await self.get_access_token() - - url = f"{self.OAUTH_URL}/{route}" - for _ in range(self.MAX_RETRIES): - response = await self.bot.http_session.get( - url=url, - headers={**self.HEADERS, "Authorization": f"bearer {self.access_token.token}"}, - params=params - ) - if response.status == 200 and response.content_type == 'application/json': - # Got appropriate response - process and return. - content = await response.json() - posts = content["data"]["children"] - return posts[:amount] - - await asyncio.sleep(3) - - log.debug(f"Invalid response from: {url} - status code {response.status}, mimetype {response.content_type}") - return list() # Failed to get appropriate response within allowed number of retries. - - async def get_top_posts(self, subreddit: Subreddit, time: str = "all", amount: int = 5) -> Embed: - """ - Get the top amount of posts for a given subreddit within a specified timeframe. - - A time of "all" will get posts from all time, "day" will get top daily posts and "week" will get the top - weekly posts. - - The amount should be between 0 and 25 as Reddit's JSON requests only provide 25 posts at most. - """ - embed = Embed(description="") - - posts = await self.fetch_posts( - route=f"{subreddit}/top", - amount=amount, - params={"t": time} - ) - - if not posts: - embed.title = random.choice(ERROR_REPLIES) - embed.colour = Colour.red() - embed.description = ( - "Sorry! We couldn't find any posts from that subreddit. " - "If this problem persists, please let us know." - ) - - return embed - - for post in posts: - data = post["data"] - - text = data["selftext"] - if text: - text = textwrap.shorten(text, width=128, placeholder="...") - text += "\n" # Add newline to separate embed info - - ups = data["ups"] - comments = data["num_comments"] - author = data["author"] - - title = textwrap.shorten(data["title"], width=64, placeholder="...") - link = self.URL + data["permalink"] - - embed.description += ( - f"**[{title}]({link})**\n" - f"{text}" - f"{Emojis.upvotes} {ups} {Emojis.comments} {comments} {Emojis.user} {author}\n\n" - ) - - embed.colour = Colour.blurple() - return embed - - @loop() - async def auto_poster_loop(self) -> None: - """Post the top 5 posts daily, and the top 5 posts weekly.""" - # once we upgrade to d.py 1.3 this can be removed and the loop can use the `time=datetime.time.min` parameter - now = datetime.utcnow() - tomorrow = now + timedelta(days=1) - midnight_tomorrow = tomorrow.replace(hour=0, minute=0, second=0) - seconds_until = (midnight_tomorrow - now).total_seconds() - - await asyncio.sleep(seconds_until) - - await self.bot.wait_until_guild_available() - if not self.webhook: - await self.bot.fetch_webhook(Webhooks.reddit) - - if datetime.utcnow().weekday() == 0: - await self.top_weekly_posts() - # if it's a monday send the top weekly posts - - for subreddit in RedditConfig.subreddits: - top_posts = await self.get_top_posts(subreddit=subreddit, time="day") - username = sub_clyde(f"{subreddit} Top Daily Posts") - message = await self.webhook.send(username=username, embed=top_posts, wait=True) - - if message.channel.is_news(): - await message.publish() - - async def top_weekly_posts(self) -> None: - """Post a summary of the top posts.""" - for subreddit in RedditConfig.subreddits: - # Send and pin the new weekly posts. - top_posts = await self.get_top_posts(subreddit=subreddit, time="week") - username = sub_clyde(f"{subreddit} Top Weekly Posts") - message = await self.webhook.send(wait=True, username=username, embed=top_posts) - - if subreddit.lower() == "r/python": - if not self.channel: - log.warning("Failed to get #reddit channel to remove pins in the weekly loop.") - return - - # Remove the oldest pins so that only 12 remain at most. - pins = await self.channel.pins() - - while len(pins) >= 12: - await pins[-1].unpin() - del pins[-1] - - await message.pin() - - if message.channel.is_news(): - await message.publish() - - @group(name="reddit", invoke_without_command=True) - async def reddit_group(self, ctx: Context) -> None: - """View the top posts from various subreddits.""" - await ctx.send_help(ctx.command) - - @reddit_group.command(name="top") - async def top_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: - """Send the top posts of all time from a given subreddit.""" - async with ctx.typing(): - embed = await self.get_top_posts(subreddit=subreddit, time="all") - - await ctx.send(content=f"Here are the top {subreddit} posts of all time!", embed=embed) - - @reddit_group.command(name="daily") - async def daily_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: - """Send the top posts of today from a given subreddit.""" - async with ctx.typing(): - embed = await self.get_top_posts(subreddit=subreddit, time="day") - - await ctx.send(content=f"Here are today's top {subreddit} posts!", embed=embed) - - @reddit_group.command(name="weekly") - async def weekly_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: - """Send the top posts of this week from a given subreddit.""" - async with ctx.typing(): - embed = await self.get_top_posts(subreddit=subreddit, time="week") - - await ctx.send(content=f"Here are this week's top {subreddit} posts!", embed=embed) - - @with_role(*STAFF_ROLES) - @reddit_group.command(name="subreddits", aliases=("subs",)) - async def subreddits_command(self, ctx: Context) -> None: - """Send a paginated embed of all the subreddits we're relaying.""" - embed = Embed() - embed.title = "Relayed subreddits." - embed.colour = Colour.blurple() - - await LinePaginator.paginate( - RedditConfig.subreddits, - ctx, embed, - footer_text="Use the reddit commands along with these to view their posts.", - empty=False, - max_lines=15 - ) - - -def setup(bot: Bot) -> None: - """Load the Reddit cog.""" - if not RedditConfig.secret or not RedditConfig.client_id: - log.error("Credentials not provided, cog not loaded.") - return - bot.add_cog(Reddit(bot)) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py deleted file mode 100644 index 670493bcf..000000000 --- a/bot/cogs/reminders.py +++ /dev/null @@ -1,427 +0,0 @@ -import asyncio -import logging -import random -import textwrap -import typing as t -from datetime import datetime, timedelta -from operator import itemgetter - -import discord -from dateutil.parser import isoparse -from dateutil.relativedelta import relativedelta -from discord.ext.commands import Cog, Context, Greedy, group - -from bot.bot import Bot -from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, STAFF_ROLES -from bot.converters import Duration -from bot.pagination import LinePaginator -from bot.utils.checks import without_role_check -from bot.utils.messages import send_denial -from bot.utils.scheduling import Scheduler -from bot.utils.time import humanize_delta - -log = logging.getLogger(__name__) - -WHITELISTED_CHANNELS = Guild.reminder_whitelist -MAXIMUM_REMINDERS = 5 - -Mentionable = t.Union[discord.Member, discord.Role] - - -class Reminders(Cog): - """Provide in-channel reminder functionality.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.scheduler = Scheduler(self.__class__.__name__) - - self.bot.loop.create_task(self.reschedule_reminders()) - - def cog_unload(self) -> None: - """Cancel scheduled tasks.""" - self.scheduler.cancel_all() - - async def reschedule_reminders(self) -> None: - """Get all current reminders from the API and reschedule them.""" - await self.bot.wait_until_guild_available() - response = await self.bot.api_client.get( - 'bot/reminders', - params={'active': 'true'} - ) - - now = datetime.utcnow() - - for reminder in response: - is_valid, *_ = self.ensure_valid_reminder(reminder, cancel_task=False) - if not is_valid: - continue - - remind_at = isoparse(reminder['expiration']).replace(tzinfo=None) - - # If the reminder is already overdue ... - if remind_at < now: - late = relativedelta(now, remind_at) - await self.send_reminder(reminder, late) - else: - self.schedule_reminder(reminder) - - def ensure_valid_reminder( - self, - reminder: dict, - cancel_task: bool = True - ) -> t.Tuple[bool, discord.User, discord.TextChannel]: - """Ensure reminder author and channel can be fetched otherwise delete the reminder.""" - user = self.bot.get_user(reminder['author']) - channel = self.bot.get_channel(reminder['channel_id']) - is_valid = True - if not user or not channel: - is_valid = False - log.info( - f"Reminder {reminder['id']} invalid: " - f"User {reminder['author']}={user}, Channel {reminder['channel_id']}={channel}." - ) - asyncio.create_task(self._delete_reminder(reminder['id'], cancel_task)) - - return is_valid, user, channel - - @staticmethod - async def _send_confirmation( - ctx: Context, - on_success: str, - reminder_id: str, - delivery_dt: t.Optional[datetime], - ) -> None: - """Send an embed confirming the reminder change was made successfully.""" - embed = discord.Embed() - embed.colour = discord.Colour.green() - embed.title = random.choice(POSITIVE_REPLIES) - embed.description = on_success - - footer_str = f"ID: {reminder_id}" - if delivery_dt: - # Reminder deletion will have a `None` `delivery_dt` - footer_str = f"{footer_str}, Due: {delivery_dt.strftime('%Y-%m-%dT%H:%M:%S')}" - - embed.set_footer(text=footer_str) - - await ctx.send(embed=embed) - - @staticmethod - async def _check_mentions(ctx: Context, mentions: t.Iterable[Mentionable]) -> t.Tuple[bool, str]: - """ - Returns whether or not the list of mentions is allowed. - - Conditions: - - Role reminders are Mods+ - - Reminders for other users are Helpers+ - - If mentions aren't allowed, also return the type of mention(s) disallowed. - """ - if without_role_check(ctx, *STAFF_ROLES): - return False, "members/roles" - elif without_role_check(ctx, *MODERATION_ROLES): - return all(isinstance(mention, discord.Member) for mention in mentions), "roles" - else: - return True, "" - - @staticmethod - async def validate_mentions(ctx: Context, mentions: t.Iterable[Mentionable]) -> bool: - """ - Filter mentions to see if the user can mention, and sends a denial if not allowed. - - Returns whether or not the validation is successful. - """ - mentions_allowed, disallowed_mentions = await Reminders._check_mentions(ctx, mentions) - - if not mentions or mentions_allowed: - return True - else: - await send_denial(ctx, f"You can't mention other {disallowed_mentions} in your reminder!") - return False - - def get_mentionables(self, mention_ids: t.List[int]) -> t.Iterator[Mentionable]: - """Converts Role and Member ids to their corresponding objects if possible.""" - guild = self.bot.get_guild(Guild.id) - for mention_id in mention_ids: - if (mentionable := (guild.get_member(mention_id) or guild.get_role(mention_id))): - yield mentionable - - def schedule_reminder(self, reminder: dict) -> None: - """A coroutine which sends the reminder once the time is reached, and cancels the running task.""" - reminder_id = reminder["id"] - reminder_datetime = isoparse(reminder['expiration']).replace(tzinfo=None) - - async def _remind() -> None: - await self.send_reminder(reminder) - - log.debug(f"Deleting reminder {reminder_id} (the user has been reminded).") - await self._delete_reminder(reminder_id) - - self.scheduler.schedule_at(reminder_datetime, reminder_id, _remind()) - - async def _delete_reminder(self, reminder_id: str, cancel_task: bool = True) -> None: - """Delete a reminder from the database, given its ID, and cancel the running task.""" - await self.bot.api_client.delete('bot/reminders/' + str(reminder_id)) - - if cancel_task: - # Now we can remove it from the schedule list - self.scheduler.cancel(reminder_id) - - async def _edit_reminder(self, reminder_id: int, payload: dict) -> dict: - """ - Edits a reminder in the database given the ID and payload. - - Returns the edited reminder. - """ - # Send the request to update the reminder in the database - reminder = await self.bot.api_client.patch( - 'bot/reminders/' + str(reminder_id), - json=payload - ) - return reminder - - async def _reschedule_reminder(self, reminder: dict) -> None: - """Reschedule a reminder object.""" - log.trace(f"Cancelling old task #{reminder['id']}") - self.scheduler.cancel(reminder["id"]) - - log.trace(f"Scheduling new task #{reminder['id']}") - self.schedule_reminder(reminder) - - async def send_reminder(self, reminder: dict, late: relativedelta = None) -> None: - """Send the reminder.""" - is_valid, user, channel = self.ensure_valid_reminder(reminder) - if not is_valid: - return - - embed = discord.Embed() - embed.colour = discord.Colour.blurple() - embed.set_author( - icon_url=Icons.remind_blurple, - name="It has arrived!" - ) - - embed.description = f"Here's your reminder: `{reminder['content']}`." - - if reminder.get("jump_url"): # keep backward compatibility - embed.description += f"\n[Jump back to when you created the reminder]({reminder['jump_url']})" - - if late: - embed.colour = discord.Colour.red() - embed.set_author( - icon_url=Icons.remind_red, - name=f"Sorry it arrived {humanize_delta(late, max_units=2)} late!" - ) - - additional_mentions = ' '.join( - mentionable.mention for mentionable in self.get_mentionables(reminder["mentions"]) - ) - - await channel.send( - content=f"{user.mention} {additional_mentions}", - embed=embed - ) - await self._delete_reminder(reminder["id"]) - - @group(name="remind", aliases=("reminder", "reminders", "remindme"), invoke_without_command=True) - async def remind_group( - self, ctx: Context, mentions: Greedy[Mentionable], expiration: Duration, *, content: str - ) -> None: - """Commands for managing your reminders.""" - await ctx.invoke(self.new_reminder, mentions=mentions, expiration=expiration, content=content) - - @remind_group.command(name="new", aliases=("add", "create")) - async def new_reminder( - self, ctx: Context, mentions: Greedy[Mentionable], expiration: Duration, *, content: str - ) -> None: - """ - Set yourself a simple reminder. - - Expiration is parsed per: http://strftime.org/ - """ - # If the user is not staff, we need to verify whether or not to make a reminder at all. - if without_role_check(ctx, *STAFF_ROLES): - - # If they don't have permission to set a reminder in this channel - if ctx.channel.id not in WHITELISTED_CHANNELS: - await send_denial(ctx, "Sorry, you can't do that here!") - return - - # Get their current active reminders - active_reminders = await self.bot.api_client.get( - 'bot/reminders', - params={ - 'author__id': str(ctx.author.id) - } - ) - - # Let's limit this, so we don't get 10 000 - # reminders from kip or something like that :P - if len(active_reminders) > MAXIMUM_REMINDERS: - await send_denial(ctx, "You have too many active reminders!") - return - - # Remove duplicate mentions - mentions = set(mentions) - mentions.discard(ctx.author) - - # Filter mentions to see if the user can mention members/roles - if not await self.validate_mentions(ctx, mentions): - return - - mention_ids = [mention.id for mention in mentions] - - # Now we can attempt to actually set the reminder. - reminder = await self.bot.api_client.post( - 'bot/reminders', - json={ - 'author': ctx.author.id, - 'channel_id': ctx.message.channel.id, - 'jump_url': ctx.message.jump_url, - 'content': content, - 'expiration': expiration.isoformat(), - 'mentions': mention_ids, - } - ) - - now = datetime.utcnow() - timedelta(seconds=1) - humanized_delta = humanize_delta(relativedelta(expiration, now)) - mention_string = ( - f"Your reminder will arrive in {humanized_delta} " - f"and will mention {len(mentions)} other(s)!" - ) - - # Confirm to the user that it worked. - await self._send_confirmation( - ctx, - on_success=mention_string, - reminder_id=reminder["id"], - delivery_dt=expiration, - ) - - self.schedule_reminder(reminder) - - @remind_group.command(name="list") - async def list_reminders(self, ctx: Context) -> None: - """View a paginated embed of all reminders for your user.""" - # Get all the user's reminders from the database. - data = await self.bot.api_client.get( - 'bot/reminders', - params={'author__id': str(ctx.author.id)} - ) - - now = datetime.utcnow() - - # Make a list of tuples so it can be sorted by time. - reminders = sorted( - ( - (rem['content'], rem['expiration'], rem['id'], rem['mentions']) - for rem in data - ), - key=itemgetter(1) - ) - - lines = [] - - for content, remind_at, id_, mentions in reminders: - # Parse and humanize the time, make it pretty :D - remind_datetime = isoparse(remind_at).replace(tzinfo=None) - time = humanize_delta(relativedelta(remind_datetime, now)) - - mentions = ", ".join( - # Both Role and User objects have the `name` attribute - mention.name for mention in self.get_mentionables(mentions) - ) - mention_string = f"\n**Mentions:** {mentions}" if mentions else "" - - text = textwrap.dedent(f""" - **Reminder #{id_}:** *expires in {time}* (ID: {id_}){mention_string} - {content} - """).strip() - - lines.append(text) - - embed = discord.Embed() - embed.colour = discord.Colour.blurple() - embed.title = f"Reminders for {ctx.author}" - - # Remind the user that they have no reminders :^) - if not lines: - embed.description = "No active reminders could be found." - await ctx.send(embed=embed) - return - - # Construct the embed and paginate it. - embed.colour = discord.Colour.blurple() - - await LinePaginator.paginate( - lines, - ctx, embed, - max_lines=3, - empty=True - ) - - @remind_group.group(name="edit", aliases=("change", "modify"), invoke_without_command=True) - async def edit_reminder_group(self, ctx: Context) -> None: - """Commands for modifying your current reminders.""" - await ctx.send_help(ctx.command) - - @edit_reminder_group.command(name="duration", aliases=("time",)) - async def edit_reminder_duration(self, ctx: Context, id_: int, expiration: Duration) -> None: - """ - Edit one of your reminder's expiration. - - Expiration is parsed per: http://strftime.org/ - """ - await self.edit_reminder(ctx, id_, {'expiration': expiration.isoformat()}) - - @edit_reminder_group.command(name="content", aliases=("reason",)) - async def edit_reminder_content(self, ctx: Context, id_: int, *, content: str) -> None: - """Edit one of your reminder's content.""" - await self.edit_reminder(ctx, id_, {"content": content}) - - @edit_reminder_group.command(name="mentions", aliases=("pings",)) - async def edit_reminder_mentions(self, ctx: Context, id_: int, mentions: Greedy[Mentionable]) -> None: - """Edit one of your reminder's mentions.""" - # Remove duplicate mentions - mentions = set(mentions) - mentions.discard(ctx.author) - - # Filter mentions to see if the user can mention members/roles - if not await self.validate_mentions(ctx, mentions): - return - - mention_ids = [mention.id for mention in mentions] - await self.edit_reminder(ctx, id_, {"mentions": mention_ids}) - - async def edit_reminder(self, ctx: Context, id_: int, payload: dict) -> None: - """Edits a reminder with the given payload, then sends a confirmation message.""" - reminder = await self._edit_reminder(id_, payload) - - # Parse the reminder expiration back into a datetime - expiration = isoparse(reminder["expiration"]).replace(tzinfo=None) - - # Send a confirmation message to the channel - await self._send_confirmation( - ctx, - on_success="That reminder has been edited successfully!", - reminder_id=id_, - delivery_dt=expiration, - ) - await self._reschedule_reminder(reminder) - - @remind_group.command("delete", aliases=("remove", "cancel")) - async def delete_reminder(self, ctx: Context, id_: int) -> None: - """Delete one of your active reminders.""" - await self._delete_reminder(id_) - await self._send_confirmation( - ctx, - on_success="That reminder has been deleted successfully!", - reminder_id=id_, - delivery_dt=None, - ) - - -def setup(bot: Bot) -> None: - """Load the Reminders cog.""" - bot.add_cog(Reminders(bot)) diff --git a/bot/cogs/security.py b/bot/cogs/security.py deleted file mode 100644 index c680c5e27..000000000 --- a/bot/cogs/security.py +++ /dev/null @@ -1,31 +0,0 @@ -import logging - -from discord.ext.commands import Cog, Context, NoPrivateMessage - -from bot.bot import Bot - -log = logging.getLogger(__name__) - - -class Security(Cog): - """Security-related helpers.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.bot.check(self.check_not_bot) # Global commands check - no bots can run any commands at all - self.bot.check(self.check_on_guild) # Global commands check - commands can't be run in a DM - - def check_not_bot(self, ctx: Context) -> bool: - """Check if the context is a bot user.""" - return not ctx.author.bot - - def check_on_guild(self, ctx: Context) -> bool: - """Check if the context is in a guild.""" - if ctx.guild is None: - raise NoPrivateMessage("This command cannot be used in private messages.") - return True - - -def setup(bot: Bot) -> None: - """Load the Security cog.""" - bot.add_cog(Security(bot)) diff --git a/bot/cogs/site.py b/bot/cogs/site.py deleted file mode 100644 index ac29daa1d..000000000 --- a/bot/cogs/site.py +++ /dev/null @@ -1,146 +0,0 @@ -import logging - -from discord import Colour, Embed -from discord.ext.commands import Cog, Context, group - -from bot.bot import Bot -from bot.constants import URLs -from bot.pagination import LinePaginator - -log = logging.getLogger(__name__) - -PAGES_URL = f"{URLs.site_schema}{URLs.site}/pages" - - -class Site(Cog): - """Commands for linking to different parts of the site.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @group(name="site", aliases=("s",), invoke_without_command=True) - async def site_group(self, ctx: Context) -> None: - """Commands for getting info about our website.""" - await ctx.send_help(ctx.command) - - @site_group.command(name="home", aliases=("about",)) - async def site_main(self, ctx: Context) -> None: - """Info about the website itself.""" - url = f"{URLs.site_schema}{URLs.site}/" - - embed = Embed(title="Python Discord website") - embed.set_footer(text=url) - embed.colour = Colour.blurple() - embed.description = ( - f"[Our official website]({url}) is an open-source community project " - "created with Python and Django. It contains information about the server " - "itself, lets you sign up for upcoming events, has its own wiki, contains " - "a list of valuable learning resources, and much more." - ) - - await ctx.send(embed=embed) - - @site_group.command(name="resources") - async def site_resources(self, ctx: Context) -> None: - """Info about the site's Resources page.""" - learning_url = f"{PAGES_URL}/resources" - - embed = Embed(title="Resources") - embed.set_footer(text=f"{learning_url}") - embed.colour = Colour.blurple() - embed.description = ( - f"The [Resources page]({learning_url}) on our website contains a " - "list of hand-selected learning resources that we regularly recommend " - f"to both beginners and experts." - ) - - await ctx.send(embed=embed) - - @site_group.command(name="tools") - async def site_tools(self, ctx: Context) -> None: - """Info about the site's Tools page.""" - tools_url = f"{PAGES_URL}/resources/tools" - - embed = Embed(title="Tools") - embed.set_footer(text=f"{tools_url}") - embed.colour = Colour.blurple() - embed.description = ( - f"The [Tools page]({tools_url}) on our website contains a " - f"couple of the most popular tools for programming in Python." - ) - - await ctx.send(embed=embed) - - @site_group.command(name="help") - async def site_help(self, ctx: Context) -> None: - """Info about the site's Getting Help page.""" - url = f"{PAGES_URL}/resources/guides/asking-good-questions" - - embed = Embed(title="Asking Good Questions") - embed.set_footer(text=url) - embed.colour = Colour.blurple() - embed.description = ( - "Asking the right question about something that's new to you can sometimes be tricky. " - f"To help with this, we've created a [guide to asking good questions]({url}) on our website. " - "It contains everything you need to get the very best help from our community." - ) - - await ctx.send(embed=embed) - - @site_group.command(name="faq") - async def site_faq(self, ctx: Context) -> None: - """Info about the site's FAQ page.""" - url = f"{PAGES_URL}/frequently-asked-questions" - - embed = Embed(title="FAQ") - embed.set_footer(text=url) - embed.colour = Colour.blurple() - embed.description = ( - "As the largest Python community on Discord, we get hundreds of questions every day. " - "Many of these questions have been asked before. We've compiled a list of the most " - "frequently asked questions along with their answers, which can be found on " - f"our [FAQ page]({url})." - ) - - await ctx.send(embed=embed) - - @site_group.command(aliases=['r', 'rule'], name='rules') - async def site_rules(self, ctx: Context, *rules: int) -> None: - """Provides a link to all rules or, if specified, displays specific rule(s).""" - rules_embed = Embed(title='Rules', color=Colour.blurple()) - rules_embed.url = f"{PAGES_URL}/rules" - - if not rules: - # Rules were not submitted. Return the default description. - rules_embed.description = ( - "The rules and guidelines that apply to this community can be found on" - f" our [rules page]({PAGES_URL}/rules). We expect" - " all members of the community to have read and understood these." - ) - - await ctx.send(embed=rules_embed) - return - - full_rules = await self.bot.api_client.get('rules', params={'link_format': 'md'}) - invalid_indices = tuple( - pick - for pick in rules - if pick < 1 or pick > len(full_rules) - ) - - if invalid_indices: - indices = ', '.join(map(str, invalid_indices)) - await ctx.send(f":x: Invalid rule indices: {indices}") - return - - for rule in rules: - self.bot.stats.incr(f"rule_uses.{rule}") - - final_rules = tuple(f"**{pick}.** {full_rules[pick - 1]}" for pick in rules) - - await LinePaginator.paginate(final_rules, ctx, rules_embed, max_lines=3) - - -def setup(bot: Bot) -> None: - """Load the Site cog.""" - bot.add_cog(Site(bot)) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py deleted file mode 100644 index 52c8b6f88..000000000 --- a/bot/cogs/snekbox.py +++ /dev/null @@ -1,349 +0,0 @@ -import asyncio -import contextlib -import datetime -import logging -import re -import textwrap -from functools import partial -from signal import Signals -from typing import Optional, Tuple - -from discord import HTTPException, Message, NotFound, Reaction, User -from discord.ext.commands import Cog, Context, command, guild_only - -from bot.bot import Bot -from bot.constants import Categories, Channels, Roles, URLs -from bot.decorators import in_whitelist -from bot.utils.messages import wait_for_deletion - -log = logging.getLogger(__name__) - -ESCAPE_REGEX = re.compile("[`\u202E\u200B]{3,}") -FORMATTED_CODE_REGEX = re.compile( - r"^\s*" # any leading whitespace from the beginning of the string - r"(?P(?P```)|``?)" # code delimiter: 1-3 backticks; (?P=block) only matches if it's a block - r"(?(block)(?:(?P[a-z]+)\n)?)" # if we're in a block, match optional language (only letters plus newline) - r"(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code - r"(?P.*?)" # extract all code inside the markup - r"\s*" # any more whitespace before the end of the code markup - r"(?P=delim)" # match the exact same delimiter from the start again - r"\s*$", # any trailing whitespace until the end of the string - re.DOTALL | re.IGNORECASE # "." also matches newlines, case insensitive -) -RAW_CODE_REGEX = re.compile( - r"^(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code - r"(?P.*?)" # extract all the rest as code - r"\s*$", # any trailing whitespace until the end of the string - re.DOTALL # "." also matches newlines -) - -MAX_PASTE_LEN = 1000 - -# `!eval` command whitelists -EVAL_CHANNELS = (Channels.bot_commands, Channels.esoteric) -EVAL_CATEGORIES = (Categories.help_available, Categories.help_in_use) -EVAL_ROLES = (Roles.helpers, Roles.moderators, Roles.admins, Roles.owners, Roles.python_community, Roles.partners) - -SIGKILL = 9 - -REEVAL_EMOJI = '\U0001f501' # :repeat: -REEVAL_TIMEOUT = 30 - - -class Snekbox(Cog): - """Safe evaluation of Python code using Snekbox.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.jobs = {} - - async def post_eval(self, code: str) -> dict: - """Send a POST request to the Snekbox API to evaluate code and return the results.""" - url = URLs.snekbox_eval_api - data = {"input": code} - async with self.bot.http_session.post(url, json=data, raise_for_status=True) as resp: - return await resp.json() - - async def upload_output(self, output: str) -> Optional[str]: - """Upload the eval output to a paste service and return a URL to it if successful.""" - log.trace("Uploading full output to paste service...") - - if len(output) > MAX_PASTE_LEN: - log.info("Full output is too long to upload") - return "too long to upload" - - url = URLs.paste_service.format(key="documents") - try: - async with self.bot.http_session.post(url, data=output, raise_for_status=True) as resp: - data = await resp.json() - - if "key" in data: - return URLs.paste_service.format(key=data["key"]) - except Exception: - # 400 (Bad Request) means there are too many characters - log.exception("Failed to upload full output to paste service!") - - @staticmethod - def prepare_input(code: str) -> str: - """Extract code from the Markdown, format it, and insert it into the code template.""" - match = FORMATTED_CODE_REGEX.fullmatch(code) - if match: - code, block, lang, delim = match.group("code", "block", "lang", "delim") - code = textwrap.dedent(code) - if block: - info = (f"'{lang}' highlighted" if lang else "plain") + " code block" - else: - info = f"{delim}-enclosed inline code" - log.trace(f"Extracted {info} for evaluation:\n{code}") - else: - code = textwrap.dedent(RAW_CODE_REGEX.fullmatch(code).group("code")) - log.trace( - f"Eval message contains unformatted or badly formatted code, " - f"stripping whitespace only:\n{code}" - ) - - return code - - @staticmethod - def get_results_message(results: dict) -> Tuple[str, str]: - """Return a user-friendly message and error corresponding to the process's return code.""" - stdout, returncode = results["stdout"], results["returncode"] - msg = f"Your eval job has completed with return code {returncode}" - error = "" - - if returncode is None: - msg = "Your eval job has failed" - error = stdout.strip() - elif returncode == 128 + SIGKILL: - msg = "Your eval job timed out or ran out of memory" - elif returncode == 255: - msg = "Your eval job has failed" - error = "A fatal NsJail error occurred" - else: - # Try to append signal's name if one exists - try: - name = Signals(returncode - 128).name - msg = f"{msg} ({name})" - except ValueError: - pass - - return msg, error - - @staticmethod - def get_status_emoji(results: dict) -> str: - """Return an emoji corresponding to the status code or lack of output in result.""" - if not results["stdout"].strip(): # No output - return ":warning:" - elif results["returncode"] == 0: # No error - return ":white_check_mark:" - else: # Exception - return ":x:" - - async def format_output(self, output: str) -> Tuple[str, Optional[str]]: - """ - Format the output and return a tuple of the formatted output and a URL to the full output. - - Prepend each line with a line number. Truncate if there are over 10 lines or 1000 characters - and upload the full output to a paste service. - """ - log.trace("Formatting output...") - - output = output.rstrip("\n") - original_output = output # To be uploaded to a pasting service if needed - paste_link = None - - if "<@" in output: - output = output.replace("<@", "<@\u200B") # Zero-width space - - if " 0: - output = [f"{i:03d} | {line}" for i, line in enumerate(output.split('\n'), 1)] - output = output[:11] # Limiting to only 11 lines - output = "\n".join(output) - - if lines > 10: - truncated = True - if len(output) >= 1000: - output = f"{output[:1000]}\n... (truncated - too long, too many lines)" - else: - output = f"{output}\n... (truncated - too many lines)" - elif len(output) >= 1000: - truncated = True - output = f"{output[:1000]}\n... (truncated - too long)" - - if truncated: - paste_link = await self.upload_output(original_output) - - output = output or "[No output]" - - return output, paste_link - - async def send_eval(self, ctx: Context, code: str) -> Message: - """ - Evaluate code, format it, and send the output to the corresponding channel. - - Return the bot response. - """ - async with ctx.typing(): - results = await self.post_eval(code) - msg, error = self.get_results_message(results) - - if error: - output, paste_link = error, None - else: - output, paste_link = await self.format_output(results["stdout"]) - - icon = self.get_status_emoji(results) - msg = f"{ctx.author.mention} {icon} {msg}.\n\n```\n{output}\n```" - if paste_link: - msg = f"{msg}\nFull output: {paste_link}" - - # Collect stats of eval fails + successes - if icon == ":x:": - self.bot.stats.incr("snekbox.python.fail") - else: - self.bot.stats.incr("snekbox.python.success") - - filter_cog = self.bot.get_cog("Filtering") - filter_triggered = False - if filter_cog: - filter_triggered = await filter_cog.filter_eval(msg, ctx.message) - if filter_triggered: - response = await ctx.send("Attempt to circumvent filter detected. Moderator team has been alerted.") - else: - response = await ctx.send(msg) - self.bot.loop.create_task( - wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot) - ) - - log.info(f"{ctx.author}'s job had a return code of {results['returncode']}") - return response - - async def continue_eval(self, ctx: Context, response: Message) -> Optional[str]: - """ - Check if the eval session should continue. - - Return the new code to evaluate or None if the eval session should be terminated. - """ - _predicate_eval_message_edit = partial(predicate_eval_message_edit, ctx) - _predicate_emoji_reaction = partial(predicate_eval_emoji_reaction, ctx) - - with contextlib.suppress(NotFound): - try: - _, new_message = await self.bot.wait_for( - 'message_edit', - check=_predicate_eval_message_edit, - timeout=REEVAL_TIMEOUT - ) - await ctx.message.add_reaction(REEVAL_EMOJI) - await self.bot.wait_for( - 'reaction_add', - check=_predicate_emoji_reaction, - timeout=10 - ) - - code = await self.get_code(new_message) - await ctx.message.clear_reactions() - with contextlib.suppress(HTTPException): - await response.delete() - - except asyncio.TimeoutError: - await ctx.message.clear_reactions() - return None - - return code - - async def get_code(self, message: Message) -> Optional[str]: - """ - Return the code from `message` to be evaluated. - - If the message is an invocation of the eval command, return the first argument or None if it - doesn't exist. Otherwise, return the full content of the message. - """ - log.trace(f"Getting context for message {message.id}.") - new_ctx = await self.bot.get_context(message) - - if new_ctx.command is self.eval_command: - log.trace(f"Message {message.id} invokes eval command.") - split = message.content.split(maxsplit=1) - code = split[1] if len(split) > 1 else None - else: - log.trace(f"Message {message.id} does not invoke eval command.") - code = message.content - - return code - - @command(name="eval", aliases=("e",)) - @guild_only() - @in_whitelist(channels=EVAL_CHANNELS, categories=EVAL_CATEGORIES, roles=EVAL_ROLES) - async def eval_command(self, ctx: Context, *, code: str = None) -> None: - """ - Run Python code and get the results. - - This command supports multiple lines of code, including code wrapped inside a formatted code - block. Code can be re-evaluated by editing the original message within 10 seconds and - clicking the reaction that subsequently appears. - - We've done our best to make this sandboxed, but do let us know if you manage to find an - issue with it! - """ - if ctx.author.id in self.jobs: - await ctx.send( - f"{ctx.author.mention} You've already got a job running - " - "please wait for it to finish!" - ) - return - - if not code: # None or empty string - await ctx.send_help(ctx.command) - return - - if Roles.helpers in (role.id for role in ctx.author.roles): - self.bot.stats.incr("snekbox_usages.roles.helpers") - else: - self.bot.stats.incr("snekbox_usages.roles.developers") - - if ctx.channel.category_id == Categories.help_in_use: - self.bot.stats.incr("snekbox_usages.channels.help") - elif ctx.channel.id == Channels.bot_commands: - self.bot.stats.incr("snekbox_usages.channels.bot_commands") - else: - self.bot.stats.incr("snekbox_usages.channels.topical") - - log.info(f"Received code from {ctx.author} for evaluation:\n{code}") - - while True: - self.jobs[ctx.author.id] = datetime.datetime.now() - code = self.prepare_input(code) - try: - response = await self.send_eval(ctx, code) - finally: - del self.jobs[ctx.author.id] - - code = await self.continue_eval(ctx, response) - if not code: - break - log.info(f"Re-evaluating code from message {ctx.message.id}:\n{code}") - - -def predicate_eval_message_edit(ctx: Context, old_msg: Message, new_msg: Message) -> bool: - """Return True if the edited message is the context message and the content was indeed modified.""" - return new_msg.id == ctx.message.id and old_msg.content != new_msg.content - - -def predicate_eval_emoji_reaction(ctx: Context, reaction: Reaction, user: User) -> bool: - """Return True if the reaction REEVAL_EMOJI was added by the context message author on this message.""" - return reaction.message.id == ctx.message.id and user.id == ctx.author.id and str(reaction) == REEVAL_EMOJI - - -def setup(bot: Bot) -> None: - """Load the Snekbox cog.""" - bot.add_cog(Snekbox(bot)) diff --git a/bot/cogs/source.py b/bot/cogs/source.py deleted file mode 100644 index 205e0ba81..000000000 --- a/bot/cogs/source.py +++ /dev/null @@ -1,141 +0,0 @@ -import inspect -from pathlib import Path -from typing import Optional, Tuple, Union - -from discord import Embed -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import URLs - -SourceType = Union[commands.HelpCommand, commands.Command, commands.Cog, str, commands.ExtensionNotLoaded] - - -class SourceConverter(commands.Converter): - """Convert an argument into a help command, tag, command, or cog.""" - - async def convert(self, ctx: commands.Context, argument: str) -> SourceType: - """Convert argument into source object.""" - if argument.lower().startswith("help"): - return ctx.bot.help_command - - cog = ctx.bot.get_cog(argument) - if cog: - return cog - - cmd = ctx.bot.get_command(argument) - if cmd: - return cmd - - tags_cog = ctx.bot.get_cog("Tags") - show_tag = True - - if not tags_cog: - show_tag = False - elif argument.lower() in tags_cog._cache: - return argument.lower() - - raise commands.BadArgument( - f"Unable to convert `{argument}` to valid command{', tag,' if show_tag else ''} or Cog." - ) - - -class BotSource(commands.Cog): - """Displays information about the bot's source code.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @commands.command(name="source", aliases=("src",)) - async def source_command(self, ctx: commands.Context, *, source_item: SourceConverter = None) -> None: - """Display information and a GitHub link to the source code of a command, tag, or cog.""" - if not source_item: - embed = Embed(title="Bot's GitHub Repository") - embed.add_field(name="Repository", value=f"[Go to GitHub]({URLs.github_bot_repo})") - embed.set_thumbnail(url="https://avatars1.githubusercontent.com/u/9919") - await ctx.send(embed=embed) - return - - embed = await self.build_embed(source_item) - await ctx.send(embed=embed) - - def get_source_link(self, source_item: SourceType) -> Tuple[str, str, Optional[int]]: - """ - Build GitHub link of source item, return this link, file location and first line number. - - Raise BadArgument if `source_item` is a dynamically-created object (e.g. via internal eval). - """ - if isinstance(source_item, commands.Command): - if source_item.cog_name == "Alias": - cmd_name = source_item.callback.__name__.replace("_alias", "") - cmd = self.bot.get_command(cmd_name.replace("_", " ")) - src = cmd.callback.__code__ - filename = src.co_filename - else: - src = source_item.callback.__code__ - filename = src.co_filename - elif isinstance(source_item, str): - tags_cog = self.bot.get_cog("Tags") - filename = tags_cog._cache[source_item]["location"] - else: - src = type(source_item) - try: - filename = inspect.getsourcefile(src) - except TypeError: - raise commands.BadArgument("Cannot get source for a dynamically-created object.") - - if not isinstance(source_item, str): - try: - lines, first_line_no = inspect.getsourcelines(src) - except OSError: - raise commands.BadArgument("Cannot get source for a dynamically-created object.") - - lines_extension = f"#L{first_line_no}-L{first_line_no+len(lines)-1}" - else: - first_line_no = None - lines_extension = "" - - # Handle tag file location differently than others to avoid errors in some cases - if not first_line_no: - file_location = Path(filename).relative_to("/bot/") - else: - file_location = Path(filename).relative_to(Path.cwd()).as_posix() - - url = f"{URLs.github_bot_repo}/blob/master/{file_location}{lines_extension}" - - return url, file_location, first_line_no or None - - async def build_embed(self, source_object: SourceType) -> Optional[Embed]: - """Build embed based on source object.""" - url, location, first_line = self.get_source_link(source_object) - - if isinstance(source_object, commands.HelpCommand): - title = "Help Command" - description = source_object.__doc__.splitlines()[1] - elif isinstance(source_object, commands.Command): - if source_object.cog_name == "Alias": - cmd_name = source_object.callback.__name__.replace("_alias", "") - cmd = self.bot.get_command(cmd_name.replace("_", " ")) - description = cmd.short_doc - else: - description = source_object.short_doc - - title = f"Command: {source_object.qualified_name}" - elif isinstance(source_object, str): - title = f"Tag: {source_object}" - description = "" - else: - title = f"Cog: {source_object.qualified_name}" - description = source_object.description.splitlines()[0] - - embed = Embed(title=title, description=description) - embed.add_field(name="Source Code", value=f"[Go to GitHub]({url})") - line_text = f":{first_line}" if first_line else "" - embed.set_footer(text=f"{location}{line_text}") - - return embed - - -def setup(bot: Bot) -> None: - """Load the BotSource cog.""" - bot.add_cog(BotSource(bot)) diff --git a/bot/cogs/stats.py b/bot/cogs/stats.py deleted file mode 100644 index d42f55466..000000000 --- a/bot/cogs/stats.py +++ /dev/null @@ -1,129 +0,0 @@ -import string -from datetime import datetime - -from discord import Member, Message, Status -from discord.ext.commands import Cog, Context -from discord.ext.tasks import loop - -from bot.bot import Bot -from bot.constants import Categories, Channels, Guild, Stats as StatConf - - -CHANNEL_NAME_OVERRIDES = { - Channels.off_topic_0: "off_topic_0", - Channels.off_topic_1: "off_topic_1", - Channels.off_topic_2: "off_topic_2", - Channels.staff_lounge: "staff_lounge" -} - -ALLOWED_CHARS = string.ascii_letters + string.digits + "_" - - -class Stats(Cog): - """A cog which provides a way to hook onto Discord events and forward to stats.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.last_presence_update = None - self.update_guild_boost.start() - - @Cog.listener() - async def on_message(self, message: Message) -> None: - """Report message events in the server to statsd.""" - if message.guild is None: - return - - if message.guild.id != Guild.id: - return - - cat = getattr(message.channel, "category", None) - if cat is not None and cat.id == Categories.modmail: - if message.channel.id != Channels.incidents: - # Do not report modmail channels to stats, there are too many - # of them for interesting statistics to be drawn out of this. - return - - reformatted_name = message.channel.name.replace('-', '_') - - if CHANNEL_NAME_OVERRIDES.get(message.channel.id): - reformatted_name = CHANNEL_NAME_OVERRIDES.get(message.channel.id) - - reformatted_name = "".join(char for char in reformatted_name if char in ALLOWED_CHARS) - - stat_name = f"channels.{reformatted_name}" - self.bot.stats.incr(stat_name) - - # Increment the total message count - self.bot.stats.incr("messages") - - @Cog.listener() - async def on_command_completion(self, ctx: Context) -> None: - """Report completed commands to statsd.""" - command_name = ctx.command.qualified_name.replace(" ", "_") - - self.bot.stats.incr(f"commands.{command_name}") - - @Cog.listener() - async def on_member_join(self, member: Member) -> None: - """Update member count stat on member join.""" - if member.guild.id != Guild.id: - return - - self.bot.stats.gauge("guild.total_members", len(member.guild.members)) - - @Cog.listener() - async def on_member_leave(self, member: Member) -> None: - """Update member count stat on member leave.""" - if member.guild.id != Guild.id: - return - - self.bot.stats.gauge("guild.total_members", len(member.guild.members)) - - @Cog.listener() - async def on_member_update(self, _before: Member, after: Member) -> None: - """Update presence estimates on member update.""" - if after.guild.id != Guild.id: - return - - if self.last_presence_update: - if (datetime.now() - self.last_presence_update).seconds < StatConf.presence_update_timeout: - return - - self.last_presence_update = datetime.now() - - online = 0 - idle = 0 - dnd = 0 - offline = 0 - - for member in after.guild.members: - if member.status is Status.online: - online += 1 - elif member.status is Status.dnd: - dnd += 1 - elif member.status is Status.idle: - idle += 1 - elif member.status is Status.offline: - offline += 1 - - self.bot.stats.gauge("guild.status.online", online) - self.bot.stats.gauge("guild.status.idle", idle) - self.bot.stats.gauge("guild.status.do_not_disturb", dnd) - self.bot.stats.gauge("guild.status.offline", offline) - - @loop(hours=1) - async def update_guild_boost(self) -> None: - """Post the server boost level and tier every hour.""" - await self.bot.wait_until_guild_available() - g = self.bot.get_guild(Guild.id) - self.bot.stats.gauge("boost.amount", g.premium_subscription_count) - self.bot.stats.gauge("boost.tier", g.premium_tier) - - def cog_unload(self) -> None: - """Stop the boost statistic task on unload of the Cog.""" - self.update_guild_boost.stop() - - -def setup(bot: Bot) -> None: - """Load the stats cog.""" - bot.add_cog(Stats(bot)) diff --git a/bot/cogs/sync/__init__.py b/bot/cogs/sync/__init__.py deleted file mode 100644 index fe7df4e9b..000000000 --- a/bot/cogs/sync/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from bot.bot import Bot -from .cog import Sync - - -def setup(bot: Bot) -> None: - """Load the Sync cog.""" - bot.add_cog(Sync(bot)) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py deleted file mode 100644 index 5ace957e7..000000000 --- a/bot/cogs/sync/cog.py +++ /dev/null @@ -1,180 +0,0 @@ -import logging -from typing import Any, Dict - -from discord import Member, Role, User -from discord.ext import commands -from discord.ext.commands import Cog, Context - -from bot import constants -from bot.api import ResponseCodeError -from bot.bot import Bot -from bot.cogs.sync import syncers - -log = logging.getLogger(__name__) - - -class Sync(Cog): - """Captures relevant events and sends them to the site.""" - - def __init__(self, bot: Bot) -> None: - self.bot = bot - self.role_syncer = syncers.RoleSyncer(self.bot) - self.user_syncer = syncers.UserSyncer(self.bot) - - self.bot.loop.create_task(self.sync_guild()) - - async def sync_guild(self) -> None: - """Syncs the roles/users of the guild with the database.""" - await self.bot.wait_until_guild_available() - - guild = self.bot.get_guild(constants.Guild.id) - if guild is None: - return - - for syncer in (self.role_syncer, self.user_syncer): - await syncer.sync(guild) - - async def patch_user(self, user_id: int, json: Dict[str, Any], ignore_404: bool = False) -> None: - """Send a PATCH request to partially update a user in the database.""" - try: - await self.bot.api_client.patch(f"bot/users/{user_id}", json=json) - except ResponseCodeError as e: - if e.response.status != 404: - raise - if not ignore_404: - log.warning("Unable to update user, got 404. Assuming race condition from join event.") - - @Cog.listener() - async def on_guild_role_create(self, role: Role) -> None: - """Adds newly create role to the database table over the API.""" - if role.guild.id != constants.Guild.id: - return - - await self.bot.api_client.post( - 'bot/roles', - json={ - 'colour': role.colour.value, - 'id': role.id, - 'name': role.name, - 'permissions': role.permissions.value, - 'position': role.position, - } - ) - - @Cog.listener() - async def on_guild_role_delete(self, role: Role) -> None: - """Deletes role from the database when it's deleted from the guild.""" - if role.guild.id != constants.Guild.id: - return - - await self.bot.api_client.delete(f'bot/roles/{role.id}') - - @Cog.listener() - async def on_guild_role_update(self, before: Role, after: Role) -> None: - """Syncs role with the database if any of the stored attributes were updated.""" - if after.guild.id != constants.Guild.id: - return - - was_updated = ( - before.name != after.name - or before.colour != after.colour - or before.permissions != after.permissions - or before.position != after.position - ) - - if was_updated: - await self.bot.api_client.put( - f'bot/roles/{after.id}', - json={ - 'colour': after.colour.value, - 'id': after.id, - 'name': after.name, - 'permissions': after.permissions.value, - 'position': after.position, - } - ) - - @Cog.listener() - async def on_member_join(self, member: Member) -> None: - """ - Adds a new user or updates existing user to the database when a member joins the guild. - - If the joining member is a user that is already known to the database (i.e., a user that - previously left), it will update the user's information. If the user is not yet known by - the database, the user is added. - """ - if member.guild.id != constants.Guild.id: - return - - packed = { - 'discriminator': int(member.discriminator), - 'id': member.id, - 'in_guild': True, - 'name': member.name, - 'roles': sorted(role.id for role in member.roles) - } - - got_error = False - - try: - # First try an update of the user to set the `in_guild` field and other - # fields that may have changed since the last time we've seen them. - await self.bot.api_client.put(f'bot/users/{member.id}', json=packed) - - except ResponseCodeError as e: - # If we didn't get 404, something else broke - propagate it up. - if e.response.status != 404: - raise - - got_error = True # yikes - - if got_error: - # If we got `404`, the user is new. Create them. - await self.bot.api_client.post('bot/users', json=packed) - - @Cog.listener() - async def on_member_remove(self, member: Member) -> None: - """Set the in_guild field to False when a member leaves the guild.""" - if member.guild.id != constants.Guild.id: - return - - await self.patch_user(member.id, json={"in_guild": False}) - - @Cog.listener() - async def on_member_update(self, before: Member, after: Member) -> None: - """Update the roles of the member in the database if a change is detected.""" - if after.guild.id != constants.Guild.id: - return - - if before.roles != after.roles: - updated_information = {"roles": sorted(role.id for role in after.roles)} - await self.patch_user(after.id, json=updated_information) - - @Cog.listener() - async def on_user_update(self, before: User, after: User) -> None: - """Update the user information in the database if a relevant change is detected.""" - attrs = ("name", "discriminator") - if any(getattr(before, attr) != getattr(after, attr) for attr in attrs): - updated_information = { - "name": after.name, - "discriminator": int(after.discriminator), - } - # A 404 likely means the user is in another guild. - await self.patch_user(after.id, json=updated_information, ignore_404=True) - - @commands.group(name='sync') - @commands.has_permissions(administrator=True) - async def sync_group(self, ctx: Context) -> None: - """Run synchronizations between the bot and site manually.""" - - @sync_group.command(name='roles') - @commands.has_permissions(administrator=True) - async def sync_roles_command(self, ctx: Context) -> None: - """Manually synchronise the guild's roles with the roles on the site.""" - await self.role_syncer.sync(ctx.guild, ctx) - - @sync_group.command(name='users') - @commands.has_permissions(administrator=True) - async def sync_users_command(self, ctx: Context) -> None: - """Manually synchronise the guild's users with the users on the site.""" - await self.user_syncer.sync(ctx.guild, ctx) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py deleted file mode 100644 index f7ba811bc..000000000 --- a/bot/cogs/sync/syncers.py +++ /dev/null @@ -1,347 +0,0 @@ -import abc -import asyncio -import logging -import typing as t -from collections import namedtuple -from functools import partial - -import discord -from discord import Guild, HTTPException, Member, Message, Reaction, User -from discord.ext.commands import Context - -from bot import constants -from bot.api import ResponseCodeError -from bot.bot import Bot - -log = logging.getLogger(__name__) - -# These objects are declared as namedtuples because tuples are hashable, -# something that we make use of when diffing site roles against guild roles. -_Role = namedtuple('Role', ('id', 'name', 'colour', 'permissions', 'position')) -_User = namedtuple('User', ('id', 'name', 'discriminator', 'roles', 'in_guild')) -_Diff = namedtuple('Diff', ('created', 'updated', 'deleted')) - - -class Syncer(abc.ABC): - """Base class for synchronising the database with objects in the Discord cache.""" - - _CORE_DEV_MENTION = f"<@&{constants.Roles.core_developers}> " - _REACTION_EMOJIS = (constants.Emojis.check_mark, constants.Emojis.cross_mark) - - def __init__(self, bot: Bot) -> None: - self.bot = bot - - @property - @abc.abstractmethod - def name(self) -> str: - """The name of the syncer; used in output messages and logging.""" - raise NotImplementedError # pragma: no cover - - async def _send_prompt(self, message: t.Optional[Message] = None) -> t.Optional[Message]: - """ - Send a prompt to confirm or abort a sync using reactions and return the sent message. - - If a message is given, it is edited to display the prompt and reactions. Otherwise, a new - message is sent to the dev-core channel and mentions the core developers role. If the - channel cannot be retrieved, return None. - """ - log.trace(f"Sending {self.name} sync confirmation prompt.") - - msg_content = ( - f'Possible cache issue while syncing {self.name}s. ' - f'More than {constants.Sync.max_diff} {self.name}s were changed. ' - f'React to confirm or abort the sync.' - ) - - # Send to core developers if it's an automatic sync. - if not message: - log.trace("Message not provided for confirmation; creating a new one in dev-core.") - channel = self.bot.get_channel(constants.Channels.dev_core) - - if not channel: - log.debug("Failed to get the dev-core channel from cache; attempting to fetch it.") - try: - channel = await self.bot.fetch_channel(constants.Channels.dev_core) - except HTTPException: - log.exception( - f"Failed to fetch channel for sending sync confirmation prompt; " - f"aborting {self.name} sync." - ) - return None - - allowed_roles = [discord.Object(constants.Roles.core_developers)] - message = await channel.send( - f"{self._CORE_DEV_MENTION}{msg_content}", - allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles) - ) - else: - await message.edit(content=msg_content) - - # Add the initial reactions. - log.trace(f"Adding reactions to {self.name} syncer confirmation prompt.") - for emoji in self._REACTION_EMOJIS: - await message.add_reaction(emoji) - - return message - - def _reaction_check( - self, - author: Member, - message: Message, - reaction: Reaction, - user: t.Union[Member, User] - ) -> bool: - """ - Return True if the `reaction` is a valid confirmation or abort reaction on `message`. - - If the `author` of the prompt is a bot, then a reaction by any core developer will be - considered valid. Otherwise, the author of the reaction (`user`) will have to be the - `author` of the prompt. - """ - # For automatic syncs, check for the core dev role instead of an exact author - has_role = any(constants.Roles.core_developers == role.id for role in user.roles) - return ( - reaction.message.id == message.id - and not user.bot - and (has_role if author.bot else user == author) - and str(reaction.emoji) in self._REACTION_EMOJIS - ) - - async def _wait_for_confirmation(self, author: Member, message: Message) -> bool: - """ - Wait for a confirmation reaction by `author` on `message` and return True if confirmed. - - Uses the `_reaction_check` function to determine if a reaction is valid. - - If there is no reaction within `bot.constants.Sync.confirm_timeout` seconds, return False. - To acknowledge the reaction (or lack thereof), `message` will be edited. - """ - # Preserve the core-dev role mention in the message edits so users aren't confused about - # where notifications came from. - mention = self._CORE_DEV_MENTION if author.bot else "" - - reaction = None - try: - log.trace(f"Waiting for a reaction to the {self.name} syncer confirmation prompt.") - reaction, _ = await self.bot.wait_for( - 'reaction_add', - check=partial(self._reaction_check, author, message), - timeout=constants.Sync.confirm_timeout - ) - except asyncio.TimeoutError: - # reaction will remain none thus sync will be aborted in the finally block below. - log.debug(f"The {self.name} syncer confirmation prompt timed out.") - - if str(reaction) == constants.Emojis.check_mark: - log.trace(f"The {self.name} syncer was confirmed.") - await message.edit(content=f':ok_hand: {mention}{self.name} sync will proceed.') - return True - else: - log.info(f"The {self.name} syncer was aborted or timed out!") - await message.edit( - content=f':warning: {mention}{self.name} sync aborted or timed out!' - ) - return False - - @abc.abstractmethod - async def _get_diff(self, guild: Guild) -> _Diff: - """Return the difference between the cache of `guild` and the database.""" - raise NotImplementedError # pragma: no cover - - @abc.abstractmethod - async def _sync(self, diff: _Diff) -> None: - """Perform the API calls for synchronisation.""" - raise NotImplementedError # pragma: no cover - - async def _get_confirmation_result( - self, - diff_size: int, - author: Member, - message: t.Optional[Message] = None - ) -> t.Tuple[bool, t.Optional[Message]]: - """ - Prompt for confirmation and return a tuple of the result and the prompt message. - - `diff_size` is the size of the diff of the sync. If it is greater than - `bot.constants.Sync.max_diff`, the prompt will be sent. The `author` is the invoked of the - sync and the `message` is an extant message to edit to display the prompt. - - If confirmed or no confirmation was needed, the result is True. The returned message will - either be the given `message` or a new one which was created when sending the prompt. - """ - log.trace(f"Determining if confirmation prompt should be sent for {self.name} syncer.") - if diff_size > constants.Sync.max_diff: - message = await self._send_prompt(message) - if not message: - return False, None # Couldn't get channel. - - confirmed = await self._wait_for_confirmation(author, message) - if not confirmed: - return False, message # Sync aborted. - - return True, message - - async def sync(self, guild: Guild, ctx: t.Optional[Context] = None) -> None: - """ - Synchronise the database with the cache of `guild`. - - If the differences between the cache and the database are greater than - `bot.constants.Sync.max_diff`, then a confirmation prompt will be sent to the dev-core - channel. The confirmation can be optionally redirect to `ctx` instead. - """ - log.info(f"Starting {self.name} syncer.") - - message = None - author = self.bot.user - if ctx: - message = await ctx.send(f"📊 Synchronising {self.name}s.") - author = ctx.author - - diff = await self._get_diff(guild) - diff_dict = diff._asdict() # Ugly method for transforming the NamedTuple into a dict - totals = {k: len(v) for k, v in diff_dict.items() if v is not None} - diff_size = sum(totals.values()) - - confirmed, message = await self._get_confirmation_result(diff_size, author, message) - if not confirmed: - return - - # Preserve the core-dev role mention in the message edits so users aren't confused about - # where notifications came from. - mention = self._CORE_DEV_MENTION if author.bot else "" - - try: - await self._sync(diff) - except ResponseCodeError as e: - log.exception(f"{self.name} syncer failed!") - - # Don't show response text because it's probably some really long HTML. - results = f"status {e.status}\n```{e.response_json or 'See log output for details'}```" - content = f":x: {mention}Synchronisation of {self.name}s failed: {results}" - else: - results = ", ".join(f"{name} `{total}`" for name, total in totals.items()) - log.info(f"{self.name} syncer finished: {results}.") - content = f":ok_hand: {mention}Synchronisation of {self.name}s complete: {results}" - - if message: - await message.edit(content=content) - - -class RoleSyncer(Syncer): - """Synchronise the database with roles in the cache.""" - - name = "role" - - async def _get_diff(self, guild: Guild) -> _Diff: - """Return the difference of roles between the cache of `guild` and the database.""" - log.trace("Getting the diff for roles.") - roles = await self.bot.api_client.get('bot/roles') - - # Pack DB roles and guild roles into one common, hashable format. - # They're hashable so that they're easily comparable with sets later. - db_roles = {_Role(**role_dict) for role_dict in roles} - guild_roles = { - _Role( - id=role.id, - name=role.name, - colour=role.colour.value, - permissions=role.permissions.value, - position=role.position, - ) - for role in guild.roles - } - - guild_role_ids = {role.id for role in guild_roles} - api_role_ids = {role.id for role in db_roles} - new_role_ids = guild_role_ids - api_role_ids - deleted_role_ids = api_role_ids - guild_role_ids - - # New roles are those which are on the cached guild but not on the - # DB guild, going by the role ID. We need to send them in for creation. - roles_to_create = {role for role in guild_roles if role.id in new_role_ids} - roles_to_update = guild_roles - db_roles - roles_to_create - roles_to_delete = {role for role in db_roles if role.id in deleted_role_ids} - - return _Diff(roles_to_create, roles_to_update, roles_to_delete) - - async def _sync(self, diff: _Diff) -> None: - """Synchronise the database with the role cache of `guild`.""" - log.trace("Syncing created roles...") - for role in diff.created: - await self.bot.api_client.post('bot/roles', json=role._asdict()) - - log.trace("Syncing updated roles...") - for role in diff.updated: - await self.bot.api_client.put(f'bot/roles/{role.id}', json=role._asdict()) - - log.trace("Syncing deleted roles...") - for role in diff.deleted: - await self.bot.api_client.delete(f'bot/roles/{role.id}') - - -class UserSyncer(Syncer): - """Synchronise the database with users in the cache.""" - - name = "user" - - async def _get_diff(self, guild: Guild) -> _Diff: - """Return the difference of users between the cache of `guild` and the database.""" - log.trace("Getting the diff for users.") - users = await self.bot.api_client.get('bot/users') - - # Pack DB roles and guild roles into one common, hashable format. - # They're hashable so that they're easily comparable with sets later. - db_users = { - user_dict['id']: _User( - roles=tuple(sorted(user_dict.pop('roles'))), - **user_dict - ) - for user_dict in users - } - guild_users = { - member.id: _User( - id=member.id, - name=member.name, - discriminator=int(member.discriminator), - roles=tuple(sorted(role.id for role in member.roles)), - in_guild=True - ) - for member in guild.members - } - - users_to_create = set() - users_to_update = set() - - for db_user in db_users.values(): - guild_user = guild_users.get(db_user.id) - if guild_user is not None: - if db_user != guild_user: - users_to_update.add(guild_user) - - elif db_user.in_guild: - # The user is known in the DB but not the guild, and the - # DB currently specifies that the user is a member of the guild. - # This means that the user has left since the last sync. - # Update the `in_guild` attribute of the user on the site - # to signify that the user left. - new_api_user = db_user._replace(in_guild=False) - users_to_update.add(new_api_user) - - new_user_ids = set(guild_users.keys()) - set(db_users.keys()) - for user_id in new_user_ids: - # The user is known on the guild but not on the API. This means - # that the user has joined since the last sync. Create it. - new_user = guild_users[user_id] - users_to_create.add(new_user) - - return _Diff(users_to_create, users_to_update, None) - - async def _sync(self, diff: _Diff) -> None: - """Synchronise the database with the user cache of `guild`.""" - log.trace("Syncing created users...") - for user in diff.created: - await self.bot.api_client.post('bot/users', json=user._asdict()) - - log.trace("Syncing updated users...") - for user in diff.updated: - await self.bot.api_client.put(f'bot/users/{user.id}', json=user._asdict()) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py deleted file mode 100644 index 3d76c5c08..000000000 --- a/bot/cogs/tags.py +++ /dev/null @@ -1,277 +0,0 @@ -import logging -import re -import time -from pathlib import Path -from typing import Callable, Dict, Iterable, List, Optional - -from discord import Colour, Embed, Member -from discord.ext.commands import Cog, Context, group - -from bot import constants -from bot.bot import Bot -from bot.converters import TagNameConverter -from bot.pagination import LinePaginator -from bot.utils.messages import wait_for_deletion - -log = logging.getLogger(__name__) - -TEST_CHANNELS = ( - constants.Channels.bot_commands, - constants.Channels.helpers -) - -REGEX_NON_ALPHABET = re.compile(r"[^a-z]", re.MULTILINE & re.IGNORECASE) -FOOTER_TEXT = f"To show a tag, type {constants.Bot.prefix}tags ." - - -class Tags(Cog): - """Save new tags and fetch existing tags.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.tag_cooldowns = {} - self._cache = self.get_tags() - - @staticmethod - def get_tags() -> dict: - """Get all tags.""" - cache = {} - - base_path = Path("bot", "resources", "tags") - for file in base_path.glob("**/*"): - if file.is_file(): - tag_title = file.stem - tag = { - "title": tag_title, - "embed": { - "description": file.read_text(encoding="utf8"), - }, - "restricted_to": "developers", - "location": f"/bot/{file}" - } - - # Convert to a list to allow negative indexing. - parents = list(file.relative_to(base_path).parents) - if len(parents) > 1: - # -1 would be '.' hence -2 is used as the index. - tag["restricted_to"] = parents[-2].name - - cache[tag_title] = tag - - return cache - - @staticmethod - def check_accessibility(user: Member, tag: dict) -> bool: - """Check if user can access a tag.""" - return tag["restricted_to"].lower() in [role.name.lower() for role in user.roles] - - @staticmethod - def _fuzzy_search(search: str, target: str) -> float: - """A simple scoring algorithm based on how many letters are found / total, with order in mind.""" - current, index = 0, 0 - _search = REGEX_NON_ALPHABET.sub('', search.lower()) - _targets = iter(REGEX_NON_ALPHABET.split(target.lower())) - _target = next(_targets) - try: - while True: - while index < len(_target) and _search[current] == _target[index]: - current += 1 - index += 1 - index, _target = 0, next(_targets) - except (StopIteration, IndexError): - pass - return current / len(_search) * 100 - - def _get_suggestions(self, tag_name: str, thresholds: Optional[List[int]] = None) -> List[str]: - """Return a list of suggested tags.""" - scores: Dict[str, int] = { - tag_title: Tags._fuzzy_search(tag_name, tag['title']) - for tag_title, tag in self._cache.items() - } - - thresholds = thresholds or [100, 90, 80, 70, 60] - - for threshold in thresholds: - suggestions = [ - self._cache[tag_title] - for tag_title, matching_score in scores.items() - if matching_score >= threshold - ] - if suggestions: - return suggestions - - return [] - - def _get_tag(self, tag_name: str) -> list: - """Get a specific tag.""" - found = [self._cache.get(tag_name.lower(), None)] - if not found[0]: - return self._get_suggestions(tag_name) - return found - - def _get_tags_via_content(self, check: Callable[[Iterable], bool], keywords: str, user: Member) -> list: - """ - Search for tags via contents. - - `predicate` will be the built-in any, all, or a custom callable. Must return a bool. - """ - keywords_processed: List[str] = [] - for keyword in keywords.split(','): - keyword_sanitized = keyword.strip().casefold() - if not keyword_sanitized: - # this happens when there are leading / trailing / consecutive comma. - continue - keywords_processed.append(keyword_sanitized) - - if not keywords_processed: - # after sanitizing, we can end up with an empty list, for example when keywords is ',' - # in that case, we simply want to search for such keywords directly instead. - keywords_processed = [keywords] - - matching_tags = [] - for tag in self._cache.values(): - matches = (query in tag['embed']['description'].casefold() for query in keywords_processed) - if self.check_accessibility(user, tag) and check(matches): - matching_tags.append(tag) - - return matching_tags - - async def _send_matching_tags(self, ctx: Context, keywords: str, matching_tags: list) -> None: - """Send the result of matching tags to user.""" - if not matching_tags: - pass - elif len(matching_tags) == 1: - await ctx.send(embed=Embed().from_dict(matching_tags[0]['embed'])) - else: - is_plural = keywords.strip().count(' ') > 0 or keywords.strip().count(',') > 0 - embed = Embed( - title=f"Here are the tags containing the given keyword{'s' * is_plural}:", - description='\n'.join(tag['title'] for tag in matching_tags[:10]) - ) - await LinePaginator.paginate( - sorted(f"**»** {tag['title']}" for tag in matching_tags), - ctx, - embed, - footer_text=FOOTER_TEXT, - empty=False, - max_lines=15 - ) - - @group(name='tags', aliases=('tag', 't'), invoke_without_command=True) - async def tags_group(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None: - """Show all known tags, a single tag, or run a subcommand.""" - await ctx.invoke(self.get_command, tag_name=tag_name) - - @tags_group.group(name='search', invoke_without_command=True) - async def search_tag_content(self, ctx: Context, *, keywords: str) -> None: - """ - Search inside tags' contents for tags. Allow searching for multiple keywords separated by comma. - - Only search for tags that has ALL the keywords. - """ - matching_tags = self._get_tags_via_content(all, keywords, ctx.author) - await self._send_matching_tags(ctx, keywords, matching_tags) - - @search_tag_content.command(name='any') - async def search_tag_content_any_keyword(self, ctx: Context, *, keywords: Optional[str] = 'any') -> None: - """ - Search inside tags' contents for tags. Allow searching for multiple keywords separated by comma. - - Search for tags that has ANY of the keywords. - """ - matching_tags = self._get_tags_via_content(any, keywords or 'any', ctx.author) - await self._send_matching_tags(ctx, keywords, matching_tags) - - @tags_group.command(name='get', aliases=('show', 'g')) - async def get_command(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None: - """Get a specified tag, or a list of all tags if no tag is specified.""" - - def _command_on_cooldown(tag_name: str) -> bool: - """ - Check if the command is currently on cooldown, on a per-tag, per-channel basis. - - The cooldown duration is set in constants.py. - """ - now = time.time() - - cooldown_conditions = ( - tag_name - and tag_name in self.tag_cooldowns - and (now - self.tag_cooldowns[tag_name]["time"]) < constants.Cooldowns.tags - and self.tag_cooldowns[tag_name]["channel"] == ctx.channel.id - ) - - if cooldown_conditions: - return True - return False - - if _command_on_cooldown(tag_name): - time_elapsed = time.time() - self.tag_cooldowns[tag_name]["time"] - time_left = constants.Cooldowns.tags - time_elapsed - log.info( - f"{ctx.author} tried to get the '{tag_name}' tag, but the tag is on cooldown. " - f"Cooldown ends in {time_left:.1f} seconds." - ) - return - - if tag_name is not None: - temp_founds = self._get_tag(tag_name) - - founds = [] - - for found_tag in temp_founds: - if self.check_accessibility(ctx.author, found_tag): - founds.append(found_tag) - - if len(founds) == 1: - tag = founds[0] - if ctx.channel.id not in TEST_CHANNELS: - self.tag_cooldowns[tag_name] = { - "time": time.time(), - "channel": ctx.channel.id - } - - self.bot.stats.incr(f"tags.usages.{tag['title'].replace('-', '_')}") - - await wait_for_deletion( - await ctx.send(embed=Embed.from_dict(tag['embed'])), - [ctx.author.id], - client=self.bot - ) - elif founds and len(tag_name) >= 3: - await wait_for_deletion( - await ctx.send( - embed=Embed( - title='Did you mean ...', - description='\n'.join(tag['title'] for tag in founds[:10]) - ) - ), - [ctx.author.id], - client=self.bot - ) - - else: - tags = self._cache.values() - if not tags: - await ctx.send(embed=Embed( - description="**There are no tags in the database!**", - colour=Colour.red() - )) - else: - embed: Embed = Embed(title="**Current tags**") - await LinePaginator.paginate( - sorted( - f"**»** {tag['title']}" for tag in tags - if self.check_accessibility(ctx.author, tag) - ), - ctx, - embed, - footer_text=FOOTER_TEXT, - empty=False, - max_lines=15 - ) - - -def setup(bot: Bot) -> None: - """Load the Tags cog.""" - bot.add_cog(Tags(bot)) diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py deleted file mode 100644 index ef979f222..000000000 --- a/bot/cogs/token_remover.py +++ /dev/null @@ -1,182 +0,0 @@ -import base64 -import binascii -import logging -import re -import typing as t - -from discord import Colour, Message, NotFound -from discord.ext.commands import Cog - -from bot import utils -from bot.bot import Bot -from bot.cogs.moderation import ModLog -from bot.constants import Channels, Colours, Event, Icons - -log = logging.getLogger(__name__) - -LOG_MESSAGE = ( - "Censored a seemingly valid token sent by {author} (`{author_id}`) in {channel}, " - "token was `{user_id}.{timestamp}.{hmac}`" -) -DELETION_MESSAGE_TEMPLATE = ( - "Hey {mention}! I noticed you posted a seemingly valid Discord API " - "token in your message and have removed your message. " - "This means that your token has been **compromised**. " - "Please change your token **immediately** at: " - "\n\n" - "Feel free to re-post it with the token removed. " - "If you believe this was a mistake, please let us know!" -) -DISCORD_EPOCH = 1_420_070_400 -TOKEN_EPOCH = 1_293_840_000 - -# Three parts delimited by dots: user ID, creation timestamp, HMAC. -# The HMAC isn't parsed further, but it's in the regex to ensure it at least exists in the string. -# Each part only matches base64 URL-safe characters. -# Padding has never been observed, but the padding character '=' is matched just in case. -TOKEN_RE = re.compile(r"([\w\-=]+)\.([\w\-=]+)\.([\w\-=]+)", re.ASCII) - - -class Token(t.NamedTuple): - """A Discord Bot token.""" - - user_id: str - timestamp: str - hmac: str - - -class TokenRemover(Cog): - """Scans messages for potential discord.py bot tokens and removes them.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @property - def mod_log(self) -> ModLog: - """Get currently loaded ModLog cog instance.""" - return self.bot.get_cog("ModLog") - - @Cog.listener() - async def on_message(self, msg: Message) -> None: - """ - Check each message for a string that matches Discord's token pattern. - - See: https://discordapp.com/developers/docs/reference#snowflakes - """ - # Ignore DMs; can't delete messages in there anyway. - if not msg.guild or msg.author.bot: - return - - found_token = self.find_token_in_message(msg) - if found_token: - await self.take_action(msg, found_token) - - @Cog.listener() - async def on_message_edit(self, before: Message, after: Message) -> None: - """ - Check each edit for a string that matches Discord's token pattern. - - See: https://discordapp.com/developers/docs/reference#snowflakes - """ - await self.on_message(after) - - async def take_action(self, msg: Message, found_token: Token) -> None: - """Remove the `msg` containing the `found_token` and send a mod log message.""" - self.mod_log.ignore(Event.message_delete, msg.id) - - try: - await msg.delete() - except NotFound: - log.debug(f"Failed to remove token in message {msg.id}: message already deleted.") - return - - await msg.channel.send(DELETION_MESSAGE_TEMPLATE.format(mention=msg.author.mention)) - - log_message = self.format_log_message(msg, found_token) - log.debug(log_message) - - # Send pretty mod log embed to mod-alerts - await self.mod_log.send_log_message( - icon_url=Icons.token_removed, - colour=Colour(Colours.soft_red), - title="Token removed!", - text=log_message, - thumbnail=msg.author.avatar_url_as(static_format="png"), - channel_id=Channels.mod_alerts, - ) - - self.bot.stats.incr("tokens.removed_tokens") - - @staticmethod - def format_log_message(msg: Message, token: Token) -> str: - """Return the log message to send for `token` being censored in `msg`.""" - return LOG_MESSAGE.format( - author=msg.author, - author_id=msg.author.id, - channel=msg.channel.mention, - user_id=token.user_id, - timestamp=token.timestamp, - hmac='x' * len(token.hmac), - ) - - @classmethod - def find_token_in_message(cls, msg: Message) -> t.Optional[Token]: - """Return a seemingly valid token found in `msg` or `None` if no token is found.""" - # Use finditer rather than search to guard against method calls prematurely returning the - # token check (e.g. `message.channel.send` also matches our token pattern) - for match in TOKEN_RE.finditer(msg.content): - token = Token(*match.groups()) - if cls.is_valid_user_id(token.user_id) and cls.is_valid_timestamp(token.timestamp): - # Short-circuit on first match - return token - - # No matching substring - return - - @staticmethod - def is_valid_user_id(b64_content: str) -> bool: - """ - Check potential token to see if it contains a valid Discord user ID. - - See: https://discordapp.com/developers/docs/reference#snowflakes - """ - b64_content = utils.pad_base64(b64_content) - - try: - decoded_bytes = base64.urlsafe_b64decode(b64_content) - string = decoded_bytes.decode('utf-8') - - # isdigit on its own would match a lot of other Unicode characters, hence the isascii. - return string.isascii() and string.isdigit() - except (binascii.Error, ValueError): - return False - - @staticmethod - def is_valid_timestamp(b64_content: str) -> bool: - """ - Return True if `b64_content` decodes to a valid timestamp. - - If the timestamp is greater than the Discord epoch, it's probably valid. - See: https://i.imgur.com/7WdehGn.png - """ - b64_content = utils.pad_base64(b64_content) - - try: - decoded_bytes = base64.urlsafe_b64decode(b64_content) - timestamp = int.from_bytes(decoded_bytes, byteorder="big") - except (binascii.Error, ValueError) as e: - log.debug(f"Failed to decode token timestamp '{b64_content}': {e}") - return False - - # Seems like newer tokens don't need the epoch added, but add anyway since an upper bound - # is not checked. - if timestamp + TOKEN_EPOCH >= DISCORD_EPOCH: - return True - else: - log.debug(f"Invalid token timestamp '{b64_content}': smaller than Discord epoch") - return False - - -def setup(bot: Bot) -> None: - """Load the TokenRemover cog.""" - bot.add_cog(TokenRemover(bot)) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py deleted file mode 100644 index d96abbd5a..000000000 --- a/bot/cogs/utils.py +++ /dev/null @@ -1,265 +0,0 @@ -import difflib -import logging -import re -import unicodedata -from email.parser import HeaderParser -from io import StringIO -from typing import Tuple, Union - -from discord import Colour, Embed, utils -from discord.ext.commands import BadArgument, Cog, Context, clean_content, command - -from bot.bot import Bot -from bot.constants import Channels, MODERATION_ROLES, STAFF_ROLES -from bot.decorators import in_whitelist, with_role -from bot.pagination import LinePaginator -from bot.utils import messages - -log = logging.getLogger(__name__) - -ZEN_OF_PYTHON = """\ -Beautiful is better than ugly. -Explicit is better than implicit. -Simple is better than complex. -Complex is better than complicated. -Flat is better than nested. -Sparse is better than dense. -Readability counts. -Special cases aren't special enough to break the rules. -Although practicality beats purity. -Errors should never pass silently. -Unless explicitly silenced. -In the face of ambiguity, refuse the temptation to guess. -There should be one-- and preferably only one --obvious way to do it. -Although that way may not be obvious at first unless you're Dutch. -Now is better than never. -Although never is often better than *right* now. -If the implementation is hard to explain, it's a bad idea. -If the implementation is easy to explain, it may be a good idea. -Namespaces are one honking great idea -- let's do more of those! -""" - -ICON_URL = "https://www.python.org/static/opengraph-icon-200x200.png" - - -class Utils(Cog): - """A selection of utilities which don't have a clear category.""" - - def __init__(self, bot: Bot): - self.bot = bot - - self.base_pep_url = "http://www.python.org/dev/peps/pep-" - self.base_github_pep_url = "https://raw.githubusercontent.com/python/peps/master/pep-" - - @command(name='pep', aliases=('get_pep', 'p')) - async def pep_command(self, ctx: Context, pep_number: str) -> None: - """Fetches information about a PEP and sends it to the channel.""" - if pep_number.isdigit(): - pep_number = int(pep_number) - else: - await ctx.send_help(ctx.command) - return - - # Handle PEP 0 directly because it's not in .rst or .txt so it can't be accessed like other PEPs. - if pep_number == 0: - return await self.send_pep_zero(ctx) - - possible_extensions = ['.txt', '.rst'] - found_pep = False - for extension in possible_extensions: - # Attempt to fetch the PEP - pep_url = f"{self.base_github_pep_url}{pep_number:04}{extension}" - log.trace(f"Requesting PEP {pep_number} with {pep_url}") - response = await self.bot.http_session.get(pep_url) - - if response.status == 200: - log.trace("PEP found") - found_pep = True - - pep_content = await response.text() - - # Taken from https://github.com/python/peps/blob/master/pep0/pep.py#L179 - pep_header = HeaderParser().parse(StringIO(pep_content)) - - # Assemble the embed - pep_embed = Embed( - title=f"**PEP {pep_number} - {pep_header['Title']}**", - description=f"[Link]({self.base_pep_url}{pep_number:04})", - ) - - pep_embed.set_thumbnail(url=ICON_URL) - - # Add the interesting information - fields_to_check = ("Status", "Python-Version", "Created", "Type") - for field in fields_to_check: - # Check for a PEP metadata field that is present but has an empty value - # embed field values can't contain an empty string - if pep_header.get(field, ""): - pep_embed.add_field(name=field, value=pep_header[field]) - - elif response.status != 404: - # any response except 200 and 404 is expected - found_pep = True # actually not, but it's easier to display this way - log.trace(f"The user requested PEP {pep_number}, but the response had an unexpected status code: " - f"{response.status}.\n{response.text}") - - error_message = "Unexpected HTTP error during PEP search. Please let us know." - pep_embed = Embed(title="Unexpected error", description=error_message) - pep_embed.colour = Colour.red() - break - - if not found_pep: - log.trace("PEP was not found") - not_found = f"PEP {pep_number} does not exist." - pep_embed = Embed(title="PEP not found", description=not_found) - pep_embed.colour = Colour.red() - - await ctx.message.channel.send(embed=pep_embed) - - @command() - @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_ROLES) - async def charinfo(self, ctx: Context, *, characters: str) -> None: - """Shows you information on up to 50 unicode characters.""" - match = re.match(r"<(a?):(\w+):(\d+)>", characters) - if match: - return await messages.send_denial( - ctx, - "**Non-Character Detected**\n" - "Only unicode characters can be processed, but a custom Discord emoji " - "was found. Please remove it and try again." - ) - - if len(characters) > 50: - return await messages.send_denial(ctx, f"Too many characters ({len(characters)}/50)") - - def get_info(char: str) -> Tuple[str, str]: - digit = f"{ord(char):x}" - if len(digit) <= 4: - u_code = f"\\u{digit:>04}" - else: - u_code = f"\\U{digit:>08}" - url = f"https://www.compart.com/en/unicode/U+{digit:>04}" - name = f"[{unicodedata.name(char, '')}]({url})" - info = f"`{u_code.ljust(10)}`: {name} - {utils.escape_markdown(char)}" - return info, u_code - - char_list, raw_list = zip(*(get_info(c) for c in characters)) - embed = Embed().set_author(name="Character Info") - - if len(characters) > 1: - # Maximum length possible is 502 out of 1024, so there's no need to truncate. - embed.add_field(name='Full Raw Text', value=f"`{''.join(raw_list)}`", inline=False) - - await LinePaginator.paginate(char_list, ctx, embed, max_lines=10, max_size=2000, empty=False) - - @command() - async def zen(self, ctx: Context, *, search_value: Union[int, str, None] = None) -> None: - """ - Show the Zen of Python. - - Without any arguments, the full Zen will be produced. - If an integer is provided, the line with that index will be produced. - If a string is provided, the line which matches best will be produced. - """ - embed = Embed( - colour=Colour.blurple(), - title="The Zen of Python", - description=ZEN_OF_PYTHON - ) - - if search_value is None: - embed.title += ", by Tim Peters" - await ctx.send(embed=embed) - return - - zen_lines = ZEN_OF_PYTHON.splitlines() - - # handle if it's an index int - if isinstance(search_value, int): - upper_bound = len(zen_lines) - 1 - lower_bound = -1 * upper_bound - if not (lower_bound <= search_value <= upper_bound): - raise BadArgument(f"Please provide an index between {lower_bound} and {upper_bound}.") - - embed.title += f" (line {search_value % len(zen_lines)}):" - embed.description = zen_lines[search_value] - await ctx.send(embed=embed) - return - - # Try to handle first exact word due difflib.SequenceMatched may use some other similar word instead - # exact word. - for i, line in enumerate(zen_lines): - for word in line.split(): - if word.lower() == search_value.lower(): - embed.title += f" (line {i}):" - embed.description = line - await ctx.send(embed=embed) - return - - # handle if it's a search string and not exact word - matcher = difflib.SequenceMatcher(None, search_value.lower()) - - best_match = "" - match_index = 0 - best_ratio = 0 - - for index, line in enumerate(zen_lines): - matcher.set_seq2(line.lower()) - - # the match ratio needs to be adjusted because, naturally, - # longer lines will have worse ratios than shorter lines when - # fuzzy searching for keywords. this seems to work okay. - adjusted_ratio = (len(line) - 5) ** 0.5 * matcher.ratio() - - if adjusted_ratio > best_ratio: - best_ratio = adjusted_ratio - best_match = line - match_index = index - - if not best_match: - raise BadArgument("I didn't get a match! Please try again with a different search term.") - - embed.title += f" (line {match_index}):" - embed.description = best_match - await ctx.send(embed=embed) - - @command(aliases=("poll",)) - @with_role(*MODERATION_ROLES) - async def vote(self, ctx: Context, title: clean_content(fix_channel_mentions=True), *options: str) -> None: - """ - Build a quick voting poll with matching reactions with the provided options. - - A maximum of 20 options can be provided, as Discord supports a max of 20 - reactions on a single message. - """ - if len(title) > 256: - raise BadArgument("The title cannot be longer than 256 characters.") - if len(options) < 2: - raise BadArgument("Please provide at least 2 options.") - if len(options) > 20: - raise BadArgument("I can only handle 20 options!") - - codepoint_start = 127462 # represents "regional_indicator_a" unicode value - options = {chr(i): f"{chr(i)} - {v}" for i, v in enumerate(options, start=codepoint_start)} - embed = Embed(title=title, description="\n".join(options.values())) - message = await ctx.send(embed=embed) - for reaction in options: - await message.add_reaction(reaction) - - async def send_pep_zero(self, ctx: Context) -> None: - """Send information about PEP 0.""" - pep_embed = Embed( - title="**PEP 0 - Index of Python Enhancement Proposals (PEPs)**", - description="[Link](https://www.python.org/dev/peps/)" - ) - pep_embed.set_thumbnail(url=ICON_URL) - pep_embed.add_field(name="Status", value="Active") - pep_embed.add_field(name="Created", value="13-Jul-2000") - pep_embed.add_field(name="Type", value="Informational") - - await ctx.send(embed=pep_embed) - - -def setup(bot: Bot) -> None: - """Load the Utils cog.""" - bot.add_cog(Utils(bot)) diff --git a/bot/cogs/utils/__init__.py b/bot/cogs/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bot/cogs/utils/bot.py b/bot/cogs/utils/bot.py new file mode 100644 index 000000000..71ed54f60 --- /dev/null +++ b/bot/cogs/utils/bot.py @@ -0,0 +1,385 @@ +import ast +import logging +import re +import time +from typing import Optional, Tuple + +from discord import Embed, Message, RawMessageUpdateEvent, TextChannel +from discord.ext.commands import Cog, Context, command, group + +from bot.bot import Bot +from bot.cogs.filters.token_remover import TokenRemover +from bot.constants import Categories, Channels, DEBUG_MODE, Guild, MODERATION_ROLES, Roles, URLs +from bot.decorators import with_role +from bot.utils.messages import wait_for_deletion + +log = logging.getLogger(__name__) + +RE_MARKDOWN = re.compile(r'([*_~`|>])') + + +class BotCog(Cog, name="Bot"): + """Bot information commands.""" + + def __init__(self, bot: Bot): + self.bot = bot + + # Stores allowed channels plus epoch time since last call. + self.channel_cooldowns = { + Channels.python_discussion: 0, + } + + # These channels will also work, but will not be subject to cooldown + self.channel_whitelist = ( + Channels.bot_commands, + ) + + # Stores improperly formatted Python codeblock message ids and the corresponding bot message + self.codeblock_message_ids = {} + + @group(invoke_without_command=True, name="bot", hidden=True) + @with_role(Roles.verified) + async def botinfo_group(self, ctx: Context) -> None: + """Bot informational commands.""" + await ctx.send_help(ctx.command) + + @botinfo_group.command(name='about', aliases=('info',), hidden=True) + @with_role(Roles.verified) + async def about_command(self, ctx: Context) -> None: + """Get information about the bot.""" + embed = Embed( + description="A utility bot designed just for the Python server! Try `!help` for more info.", + url="https://github.com/python-discord/bot" + ) + + embed.add_field(name="Total Users", value=str(len(self.bot.get_guild(Guild.id).members))) + embed.set_author( + name="Python Bot", + url="https://github.com/python-discord/bot", + icon_url=URLs.bot_avatar + ) + + await ctx.send(embed=embed) + + @command(name='echo', aliases=('print',)) + @with_role(*MODERATION_ROLES) + async def echo_command(self, ctx: Context, channel: Optional[TextChannel], *, text: str) -> None: + """Repeat the given message in either a specified channel or the current channel.""" + if channel is None: + await ctx.send(text) + else: + await channel.send(text) + + @command(name='embed') + @with_role(*MODERATION_ROLES) + async def embed_command(self, ctx: Context, channel: Optional[TextChannel], *, text: str) -> None: + """Send the input within an embed to either a specified channel or the current channel.""" + embed = Embed(description=text) + + if channel is None: + await ctx.send(embed=embed) + else: + await channel.send(embed=embed) + + def codeblock_stripping(self, msg: str, bad_ticks: bool) -> Optional[Tuple[Tuple[str, ...], str]]: + """ + Strip msg in order to find Python code. + + Tries to strip out Python code out of msg and returns the stripped block or + None if the block is a valid Python codeblock. + """ + if msg.count("\n") >= 3: + # Filtering valid Python codeblocks and exiting if a valid Python codeblock is found. + if re.search("```(?:py|python)\n(.*?)```", msg, re.IGNORECASE | re.DOTALL) and not bad_ticks: + log.trace( + "Someone wrote a message that was already a " + "valid Python syntax highlighted code block. No action taken." + ) + return None + + else: + # Stripping backticks from every line of the message. + log.trace(f"Stripping backticks from message.\n\n{msg}\n\n") + content = "" + for line in msg.splitlines(keepends=True): + content += line.strip("`") + + content = content.strip() + + # Remove "Python" or "Py" from start of the message if it exists. + log.trace(f"Removing 'py' or 'python' from message.\n\n{content}\n\n") + pycode = False + if content.lower().startswith("python"): + content = content[6:] + pycode = True + elif content.lower().startswith("py"): + content = content[2:] + pycode = True + + if pycode: + content = content.splitlines(keepends=True) + + # Check if there might be code in the first line, and preserve it. + first_line = content[0] + if " " in content[0]: + first_space = first_line.index(" ") + content[0] = first_line[first_space:] + content = "".join(content) + + # If there's no code we can just get rid of the first line. + else: + content = "".join(content[1:]) + + # Strip it again to remove any leading whitespace. This is neccessary + # if the first line of the message looked like ```python + old = content.strip() + + # Strips REPL code out of the message if there is any. + content, repl_code = self.repl_stripping(old) + if old != content: + return (content, old), repl_code + + # Try to apply indentation fixes to the code. + content = self.fix_indentation(content) + + # Check if the code contains backticks, if it does ignore the message. + if "`" in content: + log.trace("Detected ` inside the code, won't reply") + return None + else: + log.trace(f"Returning message.\n\n{content}\n\n") + return (content,), repl_code + + def fix_indentation(self, msg: str) -> str: + """Attempts to fix badly indented code.""" + def unindent(code: str, skip_spaces: int = 0) -> str: + """Unindents all code down to the number of spaces given in skip_spaces.""" + final = "" + current = code[0] + leading_spaces = 0 + + # Get numbers of spaces before code in the first line. + while current == " ": + current = code[leading_spaces + 1] + leading_spaces += 1 + leading_spaces -= skip_spaces + + # If there are any, remove that number of spaces from every line. + if leading_spaces > 0: + for line in code.splitlines(keepends=True): + line = line[leading_spaces:] + final += line + return final + else: + return code + + # Apply fix for "all lines are overindented" case. + msg = unindent(msg) + + # If the first line does not end with a colon, we can be + # certain the next line will be on the same indentation level. + # + # If it does end with a colon, we will need to indent all successive + # lines one additional level. + first_line = msg.splitlines()[0] + code = "".join(msg.splitlines(keepends=True)[1:]) + if not first_line.endswith(":"): + msg = f"{first_line}\n{unindent(code)}" + else: + msg = f"{first_line}\n{unindent(code, 4)}" + return msg + + def repl_stripping(self, msg: str) -> Tuple[str, bool]: + """ + Strip msg in order to extract Python code out of REPL output. + + Tries to strip out REPL Python code out of msg and returns the stripped msg. + + Returns True for the boolean if REPL code was found in the input msg. + """ + final = "" + for line in msg.splitlines(keepends=True): + if line.startswith(">>>") or line.startswith("..."): + final += line[4:] + log.trace(f"Formatted: \n\n{msg}\n\n to \n\n{final}\n\n") + if not final: + log.trace(f"Found no REPL code in \n\n{msg}\n\n") + return msg, False + else: + log.trace(f"Found REPL code in \n\n{msg}\n\n") + return final.rstrip(), True + + def has_bad_ticks(self, msg: Message) -> bool: + """Check to see if msg contains ticks that aren't '`'.""" + not_backticks = [ + "'''", '"""', "\u00b4\u00b4\u00b4", "\u2018\u2018\u2018", "\u2019\u2019\u2019", + "\u2032\u2032\u2032", "\u201c\u201c\u201c", "\u201d\u201d\u201d", "\u2033\u2033\u2033", + "\u3003\u3003\u3003" + ] + + return msg.content[:3] in not_backticks + + @Cog.listener() + async def on_message(self, msg: Message) -> None: + """ + Detect poorly formatted Python code in new messages. + + If poorly formatted code is detected, send the user a helpful message explaining how to do + properly formatted Python syntax highlighting codeblocks. + """ + is_help_channel = ( + getattr(msg.channel, "category", None) + and msg.channel.category.id in (Categories.help_available, Categories.help_in_use) + ) + parse_codeblock = ( + ( + is_help_channel + or msg.channel.id in self.channel_cooldowns + or msg.channel.id in self.channel_whitelist + ) + and not msg.author.bot + and len(msg.content.splitlines()) > 3 + and not TokenRemover.find_token_in_message(msg) + ) + + if parse_codeblock: # no token in the msg + on_cooldown = (time.time() - self.channel_cooldowns.get(msg.channel.id, 0)) < 300 + if not on_cooldown or DEBUG_MODE: + try: + if self.has_bad_ticks(msg): + ticks = msg.content[:3] + content = self.codeblock_stripping(f"```{msg.content[3:-3]}```", True) + if content is None: + return + + content, repl_code = content + + if len(content) == 2: + content = content[1] + else: + content = content[0] + + space_left = 204 + if len(content) >= space_left: + current_length = 0 + lines_walked = 0 + for line in content.splitlines(keepends=True): + if current_length + len(line) > space_left or lines_walked == 10: + break + current_length += len(line) + lines_walked += 1 + content = content[:current_length] + "#..." + content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) + howto = ( + "It looks like you are trying to paste code into this channel.\n\n" + "You seem to be using the wrong symbols to indicate where the codeblock should start. " + f"The correct symbols would be \\`\\`\\`, not `{ticks}`.\n\n" + "**Here is an example of how it should look:**\n" + f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" + "**This will result in the following:**\n" + f"```python\n{content}\n```" + ) + + else: + howto = "" + content = self.codeblock_stripping(msg.content, False) + if content is None: + return + + content, repl_code = content + # Attempts to parse the message into an AST node. + # Invalid Python code will raise a SyntaxError. + tree = ast.parse(content[0]) + + # Multiple lines of single words could be interpreted as expressions. + # This check is to avoid all nodes being parsed as expressions. + # (e.g. words over multiple lines) + if not all(isinstance(node, ast.Expr) for node in tree.body) or repl_code: + # Shorten the code to 10 lines and/or 204 characters. + space_left = 204 + if content and repl_code: + content = content[1] + else: + content = content[0] + + if len(content) >= space_left: + current_length = 0 + lines_walked = 0 + for line in content.splitlines(keepends=True): + if current_length + len(line) > space_left or lines_walked == 10: + break + current_length += len(line) + lines_walked += 1 + content = content[:current_length] + "#..." + + content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) + howto += ( + "It looks like you're trying to paste code into this channel.\n\n" + "Discord has support for Markdown, which allows you to post code with full " + "syntax highlighting. Please use these whenever you paste code, as this " + "helps improve the legibility and makes it easier for us to help you.\n\n" + f"**To do this, use the following method:**\n" + f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" + "**This will result in the following:**\n" + f"```python\n{content}\n```" + ) + + log.debug(f"{msg.author} posted something that needed to be put inside python code " + "blocks. Sending the user some instructions.") + else: + log.trace("The code consists only of expressions, not sending instructions") + + if howto != "": + # Increase amount of codeblock correction in stats + self.bot.stats.incr("codeblock_corrections") + howto_embed = Embed(description=howto) + bot_message = await msg.channel.send(f"Hey {msg.author.mention}!", embed=howto_embed) + self.codeblock_message_ids[msg.id] = bot_message.id + + self.bot.loop.create_task( + wait_for_deletion(bot_message, user_ids=(msg.author.id,), client=self.bot) + ) + else: + return + + if msg.channel.id not in self.channel_whitelist: + self.channel_cooldowns[msg.channel.id] = time.time() + + except SyntaxError: + log.trace( + f"{msg.author} posted in a help channel, and when we tried to parse it as Python code, " + "ast.parse raised a SyntaxError. This probably just means it wasn't Python code. " + f"The message that was posted was:\n\n{msg.content}\n\n" + ) + + @Cog.listener() + async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None: + """Check to see if an edited message (previously called out) still contains poorly formatted code.""" + if ( + # Checks to see if the message was called out by the bot + payload.message_id not in self.codeblock_message_ids + # Makes sure that there is content in the message + or payload.data.get("content") is None + # Makes sure there's a channel id in the message payload + or payload.data.get("channel_id") is None + ): + return + + # Retrieve channel and message objects for use later + channel = self.bot.get_channel(int(payload.data.get("channel_id"))) + user_message = await channel.fetch_message(payload.message_id) + + # Checks to see if the user has corrected their codeblock. If it's fixed, has_fixed_codeblock will be None + has_fixed_codeblock = self.codeblock_stripping(payload.data.get("content"), self.has_bad_ticks(user_message)) + + # If the message is fixed, delete the bot message and the entry from the id dictionary + if has_fixed_codeblock is None: + bot_message = await channel.fetch_message(self.codeblock_message_ids[payload.message_id]) + await bot_message.delete() + del self.codeblock_message_ids[payload.message_id] + log.trace("User's incorrect code block has been fixed. Removing bot formatting message.") + + +def setup(bot: Bot) -> None: + """Load the Bot cog.""" + bot.add_cog(BotCog(bot)) diff --git a/bot/cogs/utils/clean.py b/bot/cogs/utils/clean.py new file mode 100644 index 000000000..f436e531a --- /dev/null +++ b/bot/cogs/utils/clean.py @@ -0,0 +1,272 @@ +import logging +import random +import re +from typing import Iterable, Optional + +from discord import Colour, Embed, Message, TextChannel, User +from discord.ext import commands +from discord.ext.commands import Cog, Context, group + +from bot.bot import Bot +from bot.cogs.moderation import ModLog +from bot.constants import ( + Channels, CleanMessages, Colours, Event, Icons, MODERATION_ROLES, NEGATIVE_REPLIES +) +from bot.decorators import with_role + +log = logging.getLogger(__name__) + + +class Clean(Cog): + """ + A cog that allows messages to be deleted in bulk, while applying various filters. + + You can delete messages sent by a specific user, messages sent by bots, all messages, or messages that match a + specific regular expression. + + The deleted messages are saved and uploaded to the database via an API endpoint, and a URL is returned which can be + used to view the messages in the Discord dark theme style. + """ + + def __init__(self, bot: Bot): + self.bot = bot + self.cleaning = False + + @property + def mod_log(self) -> ModLog: + """Get currently loaded ModLog cog instance.""" + return self.bot.get_cog("ModLog") + + async def _clean_messages( + self, + amount: int, + ctx: Context, + channels: Iterable[TextChannel], + bots_only: bool = False, + user: User = None, + regex: Optional[str] = None, + until_message: Optional[Message] = None, + ) -> None: + """A helper function that does the actual message cleaning.""" + def predicate_bots_only(message: Message) -> bool: + """Return 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) -> bool: + """Check if the regex provided in _clean_messages matches the message content or any embed attributes.""" + 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="Please wait for the currently ongoing clean operation to complete." + ) + 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 + + # Default to using the invoking context's channel + if not channels: + channels = [ctx.channel] + + # Delete the invocation first + self.mod_log.ignore(Event.message_delete, ctx.message.id) + await ctx.message.delete() + + messages = [] + message_ids = [] + self.cleaning = True + + # Find the IDs of the messages to delete. IDs are needed in order to ignore mod log events. + for channel in channels: + async for message in channel.history(limit=amount): + + # If at any point the cancel command is invoked, we should stop. + if not self.cleaning: + return + + # If we are looking for specific message. + if until_message: + + # we could use ID's here however in case if the message we are looking for gets deleted, + # we won't have a way to figure that out thus checking for datetime should be more reliable + if message.created_at < until_message.created_at: + # means we have found the message until which we were supposed to be deleting. + break + + # Since we will be using `delete_messages` method of a TextChannel and we need message objects to + # use it as well as to send logs we will start appending messages here instead adding them from + # purge. + messages.append(message) + + # If the message passes predicate, let's save it. + if predicate is None or predicate(message): + message_ids.append(message.id) + + self.cleaning = False + + # Now let's delete the actual messages with purge. + self.mod_log.ignore(Event.message_delete, *message_ids) + for channel in channels: + if until_message: + for i in range(0, len(messages), 100): + # while purge automatically handles the amount of messages + # delete_messages only allows for up to 100 messages at once + # thus we need to paginate the amount to always be <= 100 + await channel.delete_messages(messages[i:i + 100]) + else: + messages += await channel.purge(limit=amount, check=predicate) + + # Reverse the list to restore chronological order + if messages: + messages = reversed(messages) + log_url = await self.mod_log.upload_log(messages, ctx.author.id) + 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) + return + + # Build the embed and send it + target_channels = ", ".join(channel.mention for channel in channels) + + message = ( + f"**{len(message_ids)}** messages deleted in {target_channels} by **{ctx.author.name}**\n\n" + f"A log of the deleted messages can be found [here]({log_url})." + ) + + await self.mod_log.send_log_message( + icon_url=Icons.message_bulk_delete, + colour=Colour(Colours.soft_red), + title="Bulk message delete", + text=message, + channel_id=Channels.mod_log, + ) + + @group(invoke_without_command=True, name="clean", aliases=["purge"]) + @with_role(*MODERATION_ROLES) + async def clean_group(self, ctx: Context) -> None: + """Commands for cleaning messages in channels.""" + await ctx.send_help(ctx.command) + + @clean_group.command(name="user", aliases=["users"]) + @with_role(*MODERATION_ROLES) + async def clean_user( + self, + ctx: Context, + user: User, + amount: Optional[int] = 10, + channels: commands.Greedy[TextChannel] = None + ) -> None: + """Delete messages posted by the provided user, stop cleaning after traversing `amount` messages.""" + await self._clean_messages(amount, ctx, user=user, channels=channels) + + @clean_group.command(name="all", aliases=["everything"]) + @with_role(*MODERATION_ROLES) + async def clean_all( + self, + ctx: Context, + amount: Optional[int] = 10, + channels: commands.Greedy[TextChannel] = None + ) -> None: + """Delete all messages, regardless of poster, stop cleaning after traversing `amount` messages.""" + await self._clean_messages(amount, ctx, channels=channels) + + @clean_group.command(name="bots", aliases=["bot"]) + @with_role(*MODERATION_ROLES) + async def clean_bots( + self, + ctx: Context, + amount: Optional[int] = 10, + channels: commands.Greedy[TextChannel] = None + ) -> None: + """Delete all messages posted by a bot, stop cleaning after traversing `amount` messages.""" + await self._clean_messages(amount, ctx, bots_only=True, channels=channels) + + @clean_group.command(name="regex", aliases=["word", "expression"]) + @with_role(*MODERATION_ROLES) + async def clean_regex( + self, + ctx: Context, + regex: str, + amount: Optional[int] = 10, + channels: commands.Greedy[TextChannel] = None + ) -> None: + """Delete all messages that match a certain regex, stop cleaning after traversing `amount` messages.""" + await self._clean_messages(amount, ctx, regex=regex, channels=channels) + + @clean_group.command(name="message", aliases=["messages"]) + @with_role(*MODERATION_ROLES) + async def clean_message(self, ctx: Context, message: Message) -> None: + """Delete all messages until certain message, stop cleaning after hitting the `message`.""" + await self._clean_messages( + CleanMessages.message_limit, + ctx, + channels=[message.channel], + until_message=message + ) + + @clean_group.command(name="stop", aliases=["cancel", "abort"]) + @with_role(*MODERATION_ROLES) + async def clean_cancel(self, ctx: Context) -> None: + """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, delete_after=10) + + +def setup(bot: Bot) -> None: + """Load the Clean cog.""" + bot.add_cog(Clean(bot)) diff --git a/bot/cogs/utils/eval.py b/bot/cogs/utils/eval.py new file mode 100644 index 000000000..eb8bfb1cf --- /dev/null +++ b/bot/cogs/utils/eval.py @@ -0,0 +1,202 @@ +import contextlib +import inspect +import logging +import pprint +import re +import textwrap +import traceback +from io import StringIO +from typing import Any, Optional, Tuple + +import discord +from discord.ext.commands import Cog, Context, group + +from bot.bot import Bot +from bot.constants import Roles +from bot.decorators import with_role +from bot.interpreter import Interpreter + +log = logging.getLogger(__name__) + + +class CodeEval(Cog): + """Owner and admin feature that evaluates code and returns the result to the channel.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.env = {} + self.ln = 0 + self.stdout = StringIO() + + self.interpreter = Interpreter(bot) + + def _format(self, inp: str, out: Any) -> Tuple[str, Optional[discord.Embed]]: + """Format the eval output into a string & attempt to format it into an Embed.""" + self._ = out + + res = "" + + # Erase temp input we made + if inp.startswith("_ = "): + inp = inp[4:] + + # Get all non-empty lines + lines = [line for line in inp.split("\n") if line.strip()] + if len(lines) != 1: + lines += [""] + + # Create the input dialog + for i, line in enumerate(lines): + if i == 0: + # Start dialog + start = f"In [{self.ln}]: " + + else: + # Indent the 3 dots correctly; + # Normally, it's something like + # In [X]: + # ...: + # + # But if it's + # In [XX]: + # ...: + # + # You can see it doesn't look right. + # This code simply indents the dots + # far enough to align them. + # we first `str()` the line number + # then we get the length + # and use `str.rjust()` + # to indent it. + start = "...: ".rjust(len(str(self.ln)) + 7) + + if i == len(lines) - 2: + if line.startswith("return"): + line = line[6:].strip() + + # Combine everything + res += (start + line + "\n") + + self.stdout.seek(0) + text = self.stdout.read() + self.stdout.close() + self.stdout = StringIO() + + if text: + res += (text + "\n") + + if out is None: + # No output, return the input statement + return (res, None) + + res += f"Out[{self.ln}]: " + + if isinstance(out, discord.Embed): + # We made an embed? Send that as embed + res += "" + res = (res, out) + + else: + if (isinstance(out, str) and out.startswith("Traceback (most recent call last):\n")): + # Leave out the traceback message + out = "\n" + "\n".join(out.split("\n")[1:]) + + if isinstance(out, str): + pretty = out + else: + pretty = pprint.pformat(out, compact=True, width=60) + + if pretty != str(out): + # We're using the pretty version, start on the next line + res += "\n" + + if pretty.count("\n") > 20: + # Text too long, shorten + li = pretty.split("\n") + + pretty = ("\n".join(li[:3]) # First 3 lines + + "\n ...\n" # Ellipsis to indicate removed lines + + "\n".join(li[-3:])) # last 3 lines + + # Add the output + res += pretty + res = (res, None) + + return res # Return (text, embed) + + async def _eval(self, ctx: Context, code: str) -> Optional[discord.Message]: + """Eval the input code string & send an embed to the invoking context.""" + self.ln += 1 + + if code.startswith("exit"): + self.ln = 0 + self.env = {} + return await ctx.send("```Reset history!```") + + env = { + "message": ctx.message, + "author": ctx.message.author, + "channel": ctx.channel, + "guild": ctx.guild, + "ctx": ctx, + "self": self, + "bot": self.bot, + "inspect": inspect, + "discord": discord, + "contextlib": contextlib + } + + self.env.update(env) + + # Ignore this code, it works + code_ = """ +async def func(): # (None,) -> Any + try: + with contextlib.redirect_stdout(self.stdout): +{0} + if '_' in locals(): + if inspect.isawaitable(_): + _ = await _ + return _ + finally: + self.env.update(locals()) +""".format(textwrap.indent(code, ' ')) + + try: + exec(code_, self.env) # noqa: B102,S102 + func = self.env['func'] + res = await func() + + except Exception: + res = traceback.format_exc() + + out, embed = self._format(code, res) + await ctx.send(f"```py\n{out}```", embed=embed) + + @group(name='internal', aliases=('int',)) + @with_role(Roles.owners, Roles.admins) + async def internal_group(self, ctx: Context) -> None: + """Internal commands. Top secret!""" + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) + + @internal_group.command(name='eval', aliases=('e',)) + @with_role(Roles.admins, Roles.owners) + async def eval(self, ctx: Context, *, code: str) -> None: + """Run eval in a REPL-like format.""" + code = code.strip("`") + if re.match('py(thon)?\n', code): + code = "\n".join(code.split("\n")[1:]) + + if not re.search( # Check if it's an expression + r"^(return|import|for|while|def|class|" + r"from|exit|[a-zA-Z0-9]+\s*=)", code, re.M) and len( + code.split("\n")) == 1: + code = "_ = " + code + + await self._eval(ctx, code) + + +def setup(bot: Bot) -> None: + """Load the CodeEval cog.""" + bot.add_cog(CodeEval(bot)) diff --git a/bot/cogs/utils/extensions.py b/bot/cogs/utils/extensions.py new file mode 100644 index 000000000..365f198ff --- /dev/null +++ b/bot/cogs/utils/extensions.py @@ -0,0 +1,236 @@ +import functools +import logging +import typing as t +from enum import Enum +from pkgutil import iter_modules + +from discord import Colour, Embed +from discord.ext import commands +from discord.ext.commands import Context, group + +from bot.bot import Bot +from bot.constants import Emojis, MODERATION_ROLES, Roles, URLs +from bot.pagination import LinePaginator +from bot.utils.checks import with_role_check + +log = logging.getLogger(__name__) + +UNLOAD_BLACKLIST = {"bot.cogs.extensions", "bot.cogs.modlog"} +EXTENSIONS = frozenset( + ext.name + for ext in iter_modules(("bot/cogs",), "bot.cogs.") + if ext.name[-1] != "_" +) + + +class Action(Enum): + """Represents an action to perform on an extension.""" + + # Need to be partial otherwise they are considered to be function definitions. + LOAD = functools.partial(Bot.load_extension) + UNLOAD = functools.partial(Bot.unload_extension) + RELOAD = functools.partial(Bot.reload_extension) + + +class Extension(commands.Converter): + """ + Fully qualify the name of an extension and ensure it exists. + + The * and ** values bypass this when used with the reload command. + """ + + async def convert(self, ctx: Context, argument: str) -> str: + """Fully qualify the name of an extension and ensure it exists.""" + # Special values to reload all extensions + if argument == "*" or argument == "**": + return argument + + argument = argument.lower() + + if "." not in argument: + argument = f"bot.cogs.{argument}" + + if argument in EXTENSIONS: + return argument + else: + raise commands.BadArgument(f":x: Could not find the extension `{argument}`.") + + +class Extensions(commands.Cog): + """Extension management commands.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @group(name="extensions", aliases=("ext", "exts", "c", "cogs"), invoke_without_command=True) + async def extensions_group(self, ctx: Context) -> None: + """Load, unload, reload, and list loaded extensions.""" + await ctx.send_help(ctx.command) + + @extensions_group.command(name="load", aliases=("l",)) + async def load_command(self, ctx: Context, *extensions: Extension) -> None: + r""" + Load extensions given their fully qualified or unqualified names. + + If '\*' or '\*\*' is given as the name, all unloaded extensions will be loaded. + """ # noqa: W605 + if not extensions: + await ctx.send_help(ctx.command) + return + + if "*" in extensions or "**" in extensions: + extensions = set(EXTENSIONS) - set(self.bot.extensions.keys()) + + msg = self.batch_manage(Action.LOAD, *extensions) + await ctx.send(msg) + + @extensions_group.command(name="unload", aliases=("ul",)) + async def unload_command(self, ctx: Context, *extensions: Extension) -> None: + r""" + Unload currently loaded extensions given their fully qualified or unqualified names. + + If '\*' or '\*\*' is given as the name, all loaded extensions will be unloaded. + """ # noqa: W605 + if not extensions: + await ctx.send_help(ctx.command) + return + + blacklisted = "\n".join(UNLOAD_BLACKLIST & set(extensions)) + + if blacklisted: + msg = f":x: The following extension(s) may not be unloaded:```{blacklisted}```" + else: + if "*" in extensions or "**" in extensions: + extensions = set(self.bot.extensions.keys()) - UNLOAD_BLACKLIST + + msg = self.batch_manage(Action.UNLOAD, *extensions) + + await ctx.send(msg) + + @extensions_group.command(name="reload", aliases=("r",)) + async def reload_command(self, ctx: Context, *extensions: Extension) -> None: + r""" + Reload extensions given their fully qualified or unqualified names. + + If an extension fails to be reloaded, it will be rolled-back to the prior working state. + + If '\*' is given as the name, all currently loaded extensions will be reloaded. + If '\*\*' is given as the name, all extensions, including unloaded ones, will be reloaded. + """ # noqa: W605 + if not extensions: + await ctx.send_help(ctx.command) + return + + if "**" in extensions: + extensions = EXTENSIONS + elif "*" in extensions: + extensions = set(self.bot.extensions.keys()) | set(extensions) + extensions.remove("*") + + msg = self.batch_manage(Action.RELOAD, *extensions) + + await ctx.send(msg) + + @extensions_group.command(name="list", aliases=("all",)) + async def list_command(self, ctx: Context) -> None: + """ + Get a list of all extensions, including their loaded status. + + Grey indicates that the extension is unloaded. + Green indicates that the extension is currently loaded. + """ + embed = Embed() + lines = [] + + embed.colour = Colour.blurple() + embed.set_author( + name="Extensions List", + url=URLs.github_bot_repo, + icon_url=URLs.bot_avatar + ) + + for ext in sorted(list(EXTENSIONS)): + if ext in self.bot.extensions: + status = Emojis.status_online + else: + status = Emojis.status_offline + + ext = ext.rsplit(".", 1)[1] + lines.append(f"{status} {ext}") + + log.debug(f"{ctx.author} requested a list of all cogs. Returning a paginated list.") + await LinePaginator.paginate(lines, ctx, embed, max_size=300, empty=False) + + def batch_manage(self, action: Action, *extensions: str) -> str: + """ + Apply an action to multiple extensions and return a message with the results. + + If only one extension is given, it is deferred to `manage()`. + """ + if len(extensions) == 1: + msg, _ = self.manage(action, extensions[0]) + return msg + + verb = action.name.lower() + failures = {} + + for extension in extensions: + _, error = self.manage(action, extension) + if error: + failures[extension] = error + + emoji = ":x:" if failures else ":ok_hand:" + msg = f"{emoji} {len(extensions) - len(failures)} / {len(extensions)} extensions {verb}ed." + + if failures: + failures = "\n".join(f"{ext}\n {err}" for ext, err in failures.items()) + msg += f"\nFailures:```{failures}```" + + log.debug(f"Batch {verb}ed extensions.") + + return msg + + def manage(self, action: Action, ext: str) -> t.Tuple[str, t.Optional[str]]: + """Apply an action to an extension and return the status message and any error message.""" + verb = action.name.lower() + error_msg = None + + try: + action.value(self.bot, ext) + except (commands.ExtensionAlreadyLoaded, commands.ExtensionNotLoaded): + if action is Action.RELOAD: + # When reloading, just load the extension if it was not loaded. + return self.manage(Action.LOAD, ext) + + msg = f":x: Extension `{ext}` is already {verb}ed." + log.debug(msg[4:]) + except Exception as e: + if hasattr(e, "original"): + e = e.original + + log.exception(f"Extension '{ext}' failed to {verb}.") + + error_msg = f"{e.__class__.__name__}: {e}" + msg = f":x: Failed to {verb} extension `{ext}`:\n```{error_msg}```" + else: + msg = f":ok_hand: Extension successfully {verb}ed: `{ext}`." + log.debug(msg[10:]) + + return msg, error_msg + + # This cannot be static (must have a __func__ attribute). + def cog_check(self, ctx: Context) -> bool: + """Only allow moderators and core developers to invoke the commands in this cog.""" + return with_role_check(ctx, *MODERATION_ROLES, Roles.core_developers) + + # This cannot be static (must have a __func__ attribute). + async def cog_command_error(self, ctx: Context, error: Exception) -> None: + """Handle BadArgument errors locally to prevent the help command from showing.""" + if isinstance(error, commands.BadArgument): + await ctx.send(str(error)) + error.handled = True + + +def setup(bot: Bot) -> None: + """Load the Extensions cog.""" + bot.add_cog(Extensions(bot)) diff --git a/bot/cogs/utils/jams.py b/bot/cogs/utils/jams.py new file mode 100644 index 000000000..b3102db2f --- /dev/null +++ b/bot/cogs/utils/jams.py @@ -0,0 +1,150 @@ +import logging +import typing as t + +from discord import CategoryChannel, Guild, Member, PermissionOverwrite, Role +from discord.ext import commands +from more_itertools import unique_everseen + +from bot.bot import Bot +from bot.constants import Roles +from bot.decorators import with_role + +log = logging.getLogger(__name__) + +MAX_CHANNELS = 50 +CATEGORY_NAME = "Code Jam" + + +class CodeJams(commands.Cog): + """Manages the code-jam related parts of our server.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @commands.command() + @with_role(Roles.admins) + async def createteam(self, ctx: commands.Context, team_name: str, members: commands.Greedy[Member]) -> None: + """ + Create team channels (voice and text) in the Code Jams category, assign roles, and add overwrites for the team. + + The first user passed will always be the team leader. + """ + # Ignore duplicate members + members = list(unique_everseen(members)) + + # We had a little issue during Code Jam 4 here, the greedy converter did it's job + # and ignored anything which wasn't a valid argument which left us with teams of + # two members or at some times even 1 member. This fixes that by checking that there + # are always 3 members in the members list. + if len(members) < 3: + await ctx.send( + ":no_entry_sign: One of your arguments was invalid\n" + f"There must be a minimum of 3 valid members in your team. Found: {len(members)}" + " members" + ) + return + + team_channel = await self.create_channels(ctx.guild, team_name, members) + await self.add_roles(ctx.guild, members) + + await ctx.send( + f":ok_hand: Team created: {team_channel}\n" + f"**Team Leader:** {members[0].mention}\n" + f"**Team Members:** {' '.join(member.mention for member in members[1:])}" + ) + + async def get_category(self, guild: Guild) -> CategoryChannel: + """ + Return a code jam category. + + If all categories are full or none exist, create a new category. + """ + for category in guild.categories: + # Need 2 available spaces: one for the text channel and one for voice. + if category.name == CATEGORY_NAME and MAX_CHANNELS - len(category.channels) >= 2: + return category + + return await self.create_category(guild) + + @staticmethod + async def create_category(guild: Guild) -> CategoryChannel: + """Create a new code jam category and return it.""" + log.info("Creating a new code jam category.") + + category_overwrites = { + guild.default_role: PermissionOverwrite(read_messages=False), + guild.me: PermissionOverwrite(read_messages=True) + } + + return await guild.create_category_channel( + CATEGORY_NAME, + overwrites=category_overwrites, + reason="It's code jam time!" + ) + + @staticmethod + def get_overwrites(members: t.List[Member], guild: Guild) -> t.Dict[t.Union[Member, Role], PermissionOverwrite]: + """Get code jam team channels permission overwrites.""" + # First member is always the team leader + team_channel_overwrites = { + members[0]: PermissionOverwrite( + manage_messages=True, + read_messages=True, + manage_webhooks=True, + connect=True + ), + guild.default_role: PermissionOverwrite(read_messages=False, connect=False), + guild.get_role(Roles.verified): PermissionOverwrite( + read_messages=False, + connect=False + ) + } + + # Rest of members should just have read_messages + for member in members[1:]: + team_channel_overwrites[member] = PermissionOverwrite( + read_messages=True, + connect=True + ) + + return team_channel_overwrites + + async def create_channels(self, guild: Guild, team_name: str, members: t.List[Member]) -> str: + """Create team text and voice channels. Return the mention for the text channel.""" + # Get permission overwrites and category + team_channel_overwrites = self.get_overwrites(members, guild) + code_jam_category = await self.get_category(guild) + + # Create a text channel for the team + team_channel = await guild.create_text_channel( + team_name, + overwrites=team_channel_overwrites, + category=code_jam_category + ) + + # Create a voice channel for the team + team_voice_name = " ".join(team_name.split("-")).title() + + await guild.create_voice_channel( + team_voice_name, + overwrites=team_channel_overwrites, + category=code_jam_category + ) + + return team_channel.mention + + @staticmethod + async def add_roles(guild: Guild, members: t.List[Member]) -> None: + """Assign team leader and jammer roles.""" + # Assign team leader role + await members[0].add_roles(guild.get_role(Roles.team_leaders)) + + # Assign rest of roles + jammer_role = guild.get_role(Roles.jammers) + for member in members: + await member.add_roles(jammer_role) + + +def setup(bot: Bot) -> None: + """Load the CodeJams cog.""" + bot.add_cog(CodeJams(bot)) diff --git a/bot/cogs/utils/reminders.py b/bot/cogs/utils/reminders.py new file mode 100644 index 000000000..670493bcf --- /dev/null +++ b/bot/cogs/utils/reminders.py @@ -0,0 +1,427 @@ +import asyncio +import logging +import random +import textwrap +import typing as t +from datetime import datetime, timedelta +from operator import itemgetter + +import discord +from dateutil.parser import isoparse +from dateutil.relativedelta import relativedelta +from discord.ext.commands import Cog, Context, Greedy, group + +from bot.bot import Bot +from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, STAFF_ROLES +from bot.converters import Duration +from bot.pagination import LinePaginator +from bot.utils.checks import without_role_check +from bot.utils.messages import send_denial +from bot.utils.scheduling import Scheduler +from bot.utils.time import humanize_delta + +log = logging.getLogger(__name__) + +WHITELISTED_CHANNELS = Guild.reminder_whitelist +MAXIMUM_REMINDERS = 5 + +Mentionable = t.Union[discord.Member, discord.Role] + + +class Reminders(Cog): + """Provide in-channel reminder functionality.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.scheduler = Scheduler(self.__class__.__name__) + + self.bot.loop.create_task(self.reschedule_reminders()) + + def cog_unload(self) -> None: + """Cancel scheduled tasks.""" + self.scheduler.cancel_all() + + async def reschedule_reminders(self) -> None: + """Get all current reminders from the API and reschedule them.""" + await self.bot.wait_until_guild_available() + response = await self.bot.api_client.get( + 'bot/reminders', + params={'active': 'true'} + ) + + now = datetime.utcnow() + + for reminder in response: + is_valid, *_ = self.ensure_valid_reminder(reminder, cancel_task=False) + if not is_valid: + continue + + remind_at = isoparse(reminder['expiration']).replace(tzinfo=None) + + # If the reminder is already overdue ... + if remind_at < now: + late = relativedelta(now, remind_at) + await self.send_reminder(reminder, late) + else: + self.schedule_reminder(reminder) + + def ensure_valid_reminder( + self, + reminder: dict, + cancel_task: bool = True + ) -> t.Tuple[bool, discord.User, discord.TextChannel]: + """Ensure reminder author and channel can be fetched otherwise delete the reminder.""" + user = self.bot.get_user(reminder['author']) + channel = self.bot.get_channel(reminder['channel_id']) + is_valid = True + if not user or not channel: + is_valid = False + log.info( + f"Reminder {reminder['id']} invalid: " + f"User {reminder['author']}={user}, Channel {reminder['channel_id']}={channel}." + ) + asyncio.create_task(self._delete_reminder(reminder['id'], cancel_task)) + + return is_valid, user, channel + + @staticmethod + async def _send_confirmation( + ctx: Context, + on_success: str, + reminder_id: str, + delivery_dt: t.Optional[datetime], + ) -> None: + """Send an embed confirming the reminder change was made successfully.""" + embed = discord.Embed() + embed.colour = discord.Colour.green() + embed.title = random.choice(POSITIVE_REPLIES) + embed.description = on_success + + footer_str = f"ID: {reminder_id}" + if delivery_dt: + # Reminder deletion will have a `None` `delivery_dt` + footer_str = f"{footer_str}, Due: {delivery_dt.strftime('%Y-%m-%dT%H:%M:%S')}" + + embed.set_footer(text=footer_str) + + await ctx.send(embed=embed) + + @staticmethod + async def _check_mentions(ctx: Context, mentions: t.Iterable[Mentionable]) -> t.Tuple[bool, str]: + """ + Returns whether or not the list of mentions is allowed. + + Conditions: + - Role reminders are Mods+ + - Reminders for other users are Helpers+ + + If mentions aren't allowed, also return the type of mention(s) disallowed. + """ + if without_role_check(ctx, *STAFF_ROLES): + return False, "members/roles" + elif without_role_check(ctx, *MODERATION_ROLES): + return all(isinstance(mention, discord.Member) for mention in mentions), "roles" + else: + return True, "" + + @staticmethod + async def validate_mentions(ctx: Context, mentions: t.Iterable[Mentionable]) -> bool: + """ + Filter mentions to see if the user can mention, and sends a denial if not allowed. + + Returns whether or not the validation is successful. + """ + mentions_allowed, disallowed_mentions = await Reminders._check_mentions(ctx, mentions) + + if not mentions or mentions_allowed: + return True + else: + await send_denial(ctx, f"You can't mention other {disallowed_mentions} in your reminder!") + return False + + def get_mentionables(self, mention_ids: t.List[int]) -> t.Iterator[Mentionable]: + """Converts Role and Member ids to their corresponding objects if possible.""" + guild = self.bot.get_guild(Guild.id) + for mention_id in mention_ids: + if (mentionable := (guild.get_member(mention_id) or guild.get_role(mention_id))): + yield mentionable + + def schedule_reminder(self, reminder: dict) -> None: + """A coroutine which sends the reminder once the time is reached, and cancels the running task.""" + reminder_id = reminder["id"] + reminder_datetime = isoparse(reminder['expiration']).replace(tzinfo=None) + + async def _remind() -> None: + await self.send_reminder(reminder) + + log.debug(f"Deleting reminder {reminder_id} (the user has been reminded).") + await self._delete_reminder(reminder_id) + + self.scheduler.schedule_at(reminder_datetime, reminder_id, _remind()) + + async def _delete_reminder(self, reminder_id: str, cancel_task: bool = True) -> None: + """Delete a reminder from the database, given its ID, and cancel the running task.""" + await self.bot.api_client.delete('bot/reminders/' + str(reminder_id)) + + if cancel_task: + # Now we can remove it from the schedule list + self.scheduler.cancel(reminder_id) + + async def _edit_reminder(self, reminder_id: int, payload: dict) -> dict: + """ + Edits a reminder in the database given the ID and payload. + + Returns the edited reminder. + """ + # Send the request to update the reminder in the database + reminder = await self.bot.api_client.patch( + 'bot/reminders/' + str(reminder_id), + json=payload + ) + return reminder + + async def _reschedule_reminder(self, reminder: dict) -> None: + """Reschedule a reminder object.""" + log.trace(f"Cancelling old task #{reminder['id']}") + self.scheduler.cancel(reminder["id"]) + + log.trace(f"Scheduling new task #{reminder['id']}") + self.schedule_reminder(reminder) + + async def send_reminder(self, reminder: dict, late: relativedelta = None) -> None: + """Send the reminder.""" + is_valid, user, channel = self.ensure_valid_reminder(reminder) + if not is_valid: + return + + embed = discord.Embed() + embed.colour = discord.Colour.blurple() + embed.set_author( + icon_url=Icons.remind_blurple, + name="It has arrived!" + ) + + embed.description = f"Here's your reminder: `{reminder['content']}`." + + if reminder.get("jump_url"): # keep backward compatibility + embed.description += f"\n[Jump back to when you created the reminder]({reminder['jump_url']})" + + if late: + embed.colour = discord.Colour.red() + embed.set_author( + icon_url=Icons.remind_red, + name=f"Sorry it arrived {humanize_delta(late, max_units=2)} late!" + ) + + additional_mentions = ' '.join( + mentionable.mention for mentionable in self.get_mentionables(reminder["mentions"]) + ) + + await channel.send( + content=f"{user.mention} {additional_mentions}", + embed=embed + ) + await self._delete_reminder(reminder["id"]) + + @group(name="remind", aliases=("reminder", "reminders", "remindme"), invoke_without_command=True) + async def remind_group( + self, ctx: Context, mentions: Greedy[Mentionable], expiration: Duration, *, content: str + ) -> None: + """Commands for managing your reminders.""" + await ctx.invoke(self.new_reminder, mentions=mentions, expiration=expiration, content=content) + + @remind_group.command(name="new", aliases=("add", "create")) + async def new_reminder( + self, ctx: Context, mentions: Greedy[Mentionable], expiration: Duration, *, content: str + ) -> None: + """ + Set yourself a simple reminder. + + Expiration is parsed per: http://strftime.org/ + """ + # If the user is not staff, we need to verify whether or not to make a reminder at all. + if without_role_check(ctx, *STAFF_ROLES): + + # If they don't have permission to set a reminder in this channel + if ctx.channel.id not in WHITELISTED_CHANNELS: + await send_denial(ctx, "Sorry, you can't do that here!") + return + + # Get their current active reminders + active_reminders = await self.bot.api_client.get( + 'bot/reminders', + params={ + 'author__id': str(ctx.author.id) + } + ) + + # Let's limit this, so we don't get 10 000 + # reminders from kip or something like that :P + if len(active_reminders) > MAXIMUM_REMINDERS: + await send_denial(ctx, "You have too many active reminders!") + return + + # Remove duplicate mentions + mentions = set(mentions) + mentions.discard(ctx.author) + + # Filter mentions to see if the user can mention members/roles + if not await self.validate_mentions(ctx, mentions): + return + + mention_ids = [mention.id for mention in mentions] + + # Now we can attempt to actually set the reminder. + reminder = await self.bot.api_client.post( + 'bot/reminders', + json={ + 'author': ctx.author.id, + 'channel_id': ctx.message.channel.id, + 'jump_url': ctx.message.jump_url, + 'content': content, + 'expiration': expiration.isoformat(), + 'mentions': mention_ids, + } + ) + + now = datetime.utcnow() - timedelta(seconds=1) + humanized_delta = humanize_delta(relativedelta(expiration, now)) + mention_string = ( + f"Your reminder will arrive in {humanized_delta} " + f"and will mention {len(mentions)} other(s)!" + ) + + # Confirm to the user that it worked. + await self._send_confirmation( + ctx, + on_success=mention_string, + reminder_id=reminder["id"], + delivery_dt=expiration, + ) + + self.schedule_reminder(reminder) + + @remind_group.command(name="list") + async def list_reminders(self, ctx: Context) -> None: + """View a paginated embed of all reminders for your user.""" + # Get all the user's reminders from the database. + data = await self.bot.api_client.get( + 'bot/reminders', + params={'author__id': str(ctx.author.id)} + ) + + now = datetime.utcnow() + + # Make a list of tuples so it can be sorted by time. + reminders = sorted( + ( + (rem['content'], rem['expiration'], rem['id'], rem['mentions']) + for rem in data + ), + key=itemgetter(1) + ) + + lines = [] + + for content, remind_at, id_, mentions in reminders: + # Parse and humanize the time, make it pretty :D + remind_datetime = isoparse(remind_at).replace(tzinfo=None) + time = humanize_delta(relativedelta(remind_datetime, now)) + + mentions = ", ".join( + # Both Role and User objects have the `name` attribute + mention.name for mention in self.get_mentionables(mentions) + ) + mention_string = f"\n**Mentions:** {mentions}" if mentions else "" + + text = textwrap.dedent(f""" + **Reminder #{id_}:** *expires in {time}* (ID: {id_}){mention_string} + {content} + """).strip() + + lines.append(text) + + embed = discord.Embed() + embed.colour = discord.Colour.blurple() + embed.title = f"Reminders for {ctx.author}" + + # Remind the user that they have no reminders :^) + if not lines: + embed.description = "No active reminders could be found." + await ctx.send(embed=embed) + return + + # Construct the embed and paginate it. + embed.colour = discord.Colour.blurple() + + await LinePaginator.paginate( + lines, + ctx, embed, + max_lines=3, + empty=True + ) + + @remind_group.group(name="edit", aliases=("change", "modify"), invoke_without_command=True) + async def edit_reminder_group(self, ctx: Context) -> None: + """Commands for modifying your current reminders.""" + await ctx.send_help(ctx.command) + + @edit_reminder_group.command(name="duration", aliases=("time",)) + async def edit_reminder_duration(self, ctx: Context, id_: int, expiration: Duration) -> None: + """ + Edit one of your reminder's expiration. + + Expiration is parsed per: http://strftime.org/ + """ + await self.edit_reminder(ctx, id_, {'expiration': expiration.isoformat()}) + + @edit_reminder_group.command(name="content", aliases=("reason",)) + async def edit_reminder_content(self, ctx: Context, id_: int, *, content: str) -> None: + """Edit one of your reminder's content.""" + await self.edit_reminder(ctx, id_, {"content": content}) + + @edit_reminder_group.command(name="mentions", aliases=("pings",)) + async def edit_reminder_mentions(self, ctx: Context, id_: int, mentions: Greedy[Mentionable]) -> None: + """Edit one of your reminder's mentions.""" + # Remove duplicate mentions + mentions = set(mentions) + mentions.discard(ctx.author) + + # Filter mentions to see if the user can mention members/roles + if not await self.validate_mentions(ctx, mentions): + return + + mention_ids = [mention.id for mention in mentions] + await self.edit_reminder(ctx, id_, {"mentions": mention_ids}) + + async def edit_reminder(self, ctx: Context, id_: int, payload: dict) -> None: + """Edits a reminder with the given payload, then sends a confirmation message.""" + reminder = await self._edit_reminder(id_, payload) + + # Parse the reminder expiration back into a datetime + expiration = isoparse(reminder["expiration"]).replace(tzinfo=None) + + # Send a confirmation message to the channel + await self._send_confirmation( + ctx, + on_success="That reminder has been edited successfully!", + reminder_id=id_, + delivery_dt=expiration, + ) + await self._reschedule_reminder(reminder) + + @remind_group.command("delete", aliases=("remove", "cancel")) + async def delete_reminder(self, ctx: Context, id_: int) -> None: + """Delete one of your active reminders.""" + await self._delete_reminder(id_) + await self._send_confirmation( + ctx, + on_success="That reminder has been deleted successfully!", + reminder_id=id_, + delivery_dt=None, + ) + + +def setup(bot: Bot) -> None: + """Load the Reminders cog.""" + bot.add_cog(Reminders(bot)) diff --git a/bot/cogs/utils/snekbox.py b/bot/cogs/utils/snekbox.py new file mode 100644 index 000000000..52c8b6f88 --- /dev/null +++ b/bot/cogs/utils/snekbox.py @@ -0,0 +1,349 @@ +import asyncio +import contextlib +import datetime +import logging +import re +import textwrap +from functools import partial +from signal import Signals +from typing import Optional, Tuple + +from discord import HTTPException, Message, NotFound, Reaction, User +from discord.ext.commands import Cog, Context, command, guild_only + +from bot.bot import Bot +from bot.constants import Categories, Channels, Roles, URLs +from bot.decorators import in_whitelist +from bot.utils.messages import wait_for_deletion + +log = logging.getLogger(__name__) + +ESCAPE_REGEX = re.compile("[`\u202E\u200B]{3,}") +FORMATTED_CODE_REGEX = re.compile( + r"^\s*" # any leading whitespace from the beginning of the string + r"(?P(?P```)|``?)" # code delimiter: 1-3 backticks; (?P=block) only matches if it's a block + r"(?(block)(?:(?P[a-z]+)\n)?)" # if we're in a block, match optional language (only letters plus newline) + r"(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code + r"(?P.*?)" # extract all code inside the markup + r"\s*" # any more whitespace before the end of the code markup + r"(?P=delim)" # match the exact same delimiter from the start again + r"\s*$", # any trailing whitespace until the end of the string + re.DOTALL | re.IGNORECASE # "." also matches newlines, case insensitive +) +RAW_CODE_REGEX = re.compile( + r"^(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code + r"(?P.*?)" # extract all the rest as code + r"\s*$", # any trailing whitespace until the end of the string + re.DOTALL # "." also matches newlines +) + +MAX_PASTE_LEN = 1000 + +# `!eval` command whitelists +EVAL_CHANNELS = (Channels.bot_commands, Channels.esoteric) +EVAL_CATEGORIES = (Categories.help_available, Categories.help_in_use) +EVAL_ROLES = (Roles.helpers, Roles.moderators, Roles.admins, Roles.owners, Roles.python_community, Roles.partners) + +SIGKILL = 9 + +REEVAL_EMOJI = '\U0001f501' # :repeat: +REEVAL_TIMEOUT = 30 + + +class Snekbox(Cog): + """Safe evaluation of Python code using Snekbox.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.jobs = {} + + async def post_eval(self, code: str) -> dict: + """Send a POST request to the Snekbox API to evaluate code and return the results.""" + url = URLs.snekbox_eval_api + data = {"input": code} + async with self.bot.http_session.post(url, json=data, raise_for_status=True) as resp: + return await resp.json() + + async def upload_output(self, output: str) -> Optional[str]: + """Upload the eval output to a paste service and return a URL to it if successful.""" + log.trace("Uploading full output to paste service...") + + if len(output) > MAX_PASTE_LEN: + log.info("Full output is too long to upload") + return "too long to upload" + + url = URLs.paste_service.format(key="documents") + try: + async with self.bot.http_session.post(url, data=output, raise_for_status=True) as resp: + data = await resp.json() + + if "key" in data: + return URLs.paste_service.format(key=data["key"]) + except Exception: + # 400 (Bad Request) means there are too many characters + log.exception("Failed to upload full output to paste service!") + + @staticmethod + def prepare_input(code: str) -> str: + """Extract code from the Markdown, format it, and insert it into the code template.""" + match = FORMATTED_CODE_REGEX.fullmatch(code) + if match: + code, block, lang, delim = match.group("code", "block", "lang", "delim") + code = textwrap.dedent(code) + if block: + info = (f"'{lang}' highlighted" if lang else "plain") + " code block" + else: + info = f"{delim}-enclosed inline code" + log.trace(f"Extracted {info} for evaluation:\n{code}") + else: + code = textwrap.dedent(RAW_CODE_REGEX.fullmatch(code).group("code")) + log.trace( + f"Eval message contains unformatted or badly formatted code, " + f"stripping whitespace only:\n{code}" + ) + + return code + + @staticmethod + def get_results_message(results: dict) -> Tuple[str, str]: + """Return a user-friendly message and error corresponding to the process's return code.""" + stdout, returncode = results["stdout"], results["returncode"] + msg = f"Your eval job has completed with return code {returncode}" + error = "" + + if returncode is None: + msg = "Your eval job has failed" + error = stdout.strip() + elif returncode == 128 + SIGKILL: + msg = "Your eval job timed out or ran out of memory" + elif returncode == 255: + msg = "Your eval job has failed" + error = "A fatal NsJail error occurred" + else: + # Try to append signal's name if one exists + try: + name = Signals(returncode - 128).name + msg = f"{msg} ({name})" + except ValueError: + pass + + return msg, error + + @staticmethod + def get_status_emoji(results: dict) -> str: + """Return an emoji corresponding to the status code or lack of output in result.""" + if not results["stdout"].strip(): # No output + return ":warning:" + elif results["returncode"] == 0: # No error + return ":white_check_mark:" + else: # Exception + return ":x:" + + async def format_output(self, output: str) -> Tuple[str, Optional[str]]: + """ + Format the output and return a tuple of the formatted output and a URL to the full output. + + Prepend each line with a line number. Truncate if there are over 10 lines or 1000 characters + and upload the full output to a paste service. + """ + log.trace("Formatting output...") + + output = output.rstrip("\n") + original_output = output # To be uploaded to a pasting service if needed + paste_link = None + + if "<@" in output: + output = output.replace("<@", "<@\u200B") # Zero-width space + + if " 0: + output = [f"{i:03d} | {line}" for i, line in enumerate(output.split('\n'), 1)] + output = output[:11] # Limiting to only 11 lines + output = "\n".join(output) + + if lines > 10: + truncated = True + if len(output) >= 1000: + output = f"{output[:1000]}\n... (truncated - too long, too many lines)" + else: + output = f"{output}\n... (truncated - too many lines)" + elif len(output) >= 1000: + truncated = True + output = f"{output[:1000]}\n... (truncated - too long)" + + if truncated: + paste_link = await self.upload_output(original_output) + + output = output or "[No output]" + + return output, paste_link + + async def send_eval(self, ctx: Context, code: str) -> Message: + """ + Evaluate code, format it, and send the output to the corresponding channel. + + Return the bot response. + """ + async with ctx.typing(): + results = await self.post_eval(code) + msg, error = self.get_results_message(results) + + if error: + output, paste_link = error, None + else: + output, paste_link = await self.format_output(results["stdout"]) + + icon = self.get_status_emoji(results) + msg = f"{ctx.author.mention} {icon} {msg}.\n\n```\n{output}\n```" + if paste_link: + msg = f"{msg}\nFull output: {paste_link}" + + # Collect stats of eval fails + successes + if icon == ":x:": + self.bot.stats.incr("snekbox.python.fail") + else: + self.bot.stats.incr("snekbox.python.success") + + filter_cog = self.bot.get_cog("Filtering") + filter_triggered = False + if filter_cog: + filter_triggered = await filter_cog.filter_eval(msg, ctx.message) + if filter_triggered: + response = await ctx.send("Attempt to circumvent filter detected. Moderator team has been alerted.") + else: + response = await ctx.send(msg) + self.bot.loop.create_task( + wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot) + ) + + log.info(f"{ctx.author}'s job had a return code of {results['returncode']}") + return response + + async def continue_eval(self, ctx: Context, response: Message) -> Optional[str]: + """ + Check if the eval session should continue. + + Return the new code to evaluate or None if the eval session should be terminated. + """ + _predicate_eval_message_edit = partial(predicate_eval_message_edit, ctx) + _predicate_emoji_reaction = partial(predicate_eval_emoji_reaction, ctx) + + with contextlib.suppress(NotFound): + try: + _, new_message = await self.bot.wait_for( + 'message_edit', + check=_predicate_eval_message_edit, + timeout=REEVAL_TIMEOUT + ) + await ctx.message.add_reaction(REEVAL_EMOJI) + await self.bot.wait_for( + 'reaction_add', + check=_predicate_emoji_reaction, + timeout=10 + ) + + code = await self.get_code(new_message) + await ctx.message.clear_reactions() + with contextlib.suppress(HTTPException): + await response.delete() + + except asyncio.TimeoutError: + await ctx.message.clear_reactions() + return None + + return code + + async def get_code(self, message: Message) -> Optional[str]: + """ + Return the code from `message` to be evaluated. + + If the message is an invocation of the eval command, return the first argument or None if it + doesn't exist. Otherwise, return the full content of the message. + """ + log.trace(f"Getting context for message {message.id}.") + new_ctx = await self.bot.get_context(message) + + if new_ctx.command is self.eval_command: + log.trace(f"Message {message.id} invokes eval command.") + split = message.content.split(maxsplit=1) + code = split[1] if len(split) > 1 else None + else: + log.trace(f"Message {message.id} does not invoke eval command.") + code = message.content + + return code + + @command(name="eval", aliases=("e",)) + @guild_only() + @in_whitelist(channels=EVAL_CHANNELS, categories=EVAL_CATEGORIES, roles=EVAL_ROLES) + async def eval_command(self, ctx: Context, *, code: str = None) -> None: + """ + Run Python code and get the results. + + This command supports multiple lines of code, including code wrapped inside a formatted code + block. Code can be re-evaluated by editing the original message within 10 seconds and + clicking the reaction that subsequently appears. + + We've done our best to make this sandboxed, but do let us know if you manage to find an + issue with it! + """ + if ctx.author.id in self.jobs: + await ctx.send( + f"{ctx.author.mention} You've already got a job running - " + "please wait for it to finish!" + ) + return + + if not code: # None or empty string + await ctx.send_help(ctx.command) + return + + if Roles.helpers in (role.id for role in ctx.author.roles): + self.bot.stats.incr("snekbox_usages.roles.helpers") + else: + self.bot.stats.incr("snekbox_usages.roles.developers") + + if ctx.channel.category_id == Categories.help_in_use: + self.bot.stats.incr("snekbox_usages.channels.help") + elif ctx.channel.id == Channels.bot_commands: + self.bot.stats.incr("snekbox_usages.channels.bot_commands") + else: + self.bot.stats.incr("snekbox_usages.channels.topical") + + log.info(f"Received code from {ctx.author} for evaluation:\n{code}") + + while True: + self.jobs[ctx.author.id] = datetime.datetime.now() + code = self.prepare_input(code) + try: + response = await self.send_eval(ctx, code) + finally: + del self.jobs[ctx.author.id] + + code = await self.continue_eval(ctx, response) + if not code: + break + log.info(f"Re-evaluating code from message {ctx.message.id}:\n{code}") + + +def predicate_eval_message_edit(ctx: Context, old_msg: Message, new_msg: Message) -> bool: + """Return True if the edited message is the context message and the content was indeed modified.""" + return new_msg.id == ctx.message.id and old_msg.content != new_msg.content + + +def predicate_eval_emoji_reaction(ctx: Context, reaction: Reaction, user: User) -> bool: + """Return True if the reaction REEVAL_EMOJI was added by the context message author on this message.""" + return reaction.message.id == ctx.message.id and user.id == ctx.author.id and str(reaction) == REEVAL_EMOJI + + +def setup(bot: Bot) -> None: + """Load the Snekbox cog.""" + bot.add_cog(Snekbox(bot)) diff --git a/bot/cogs/utils/utils.py b/bot/cogs/utils/utils.py new file mode 100644 index 000000000..d96abbd5a --- /dev/null +++ b/bot/cogs/utils/utils.py @@ -0,0 +1,265 @@ +import difflib +import logging +import re +import unicodedata +from email.parser import HeaderParser +from io import StringIO +from typing import Tuple, Union + +from discord import Colour, Embed, utils +from discord.ext.commands import BadArgument, Cog, Context, clean_content, command + +from bot.bot import Bot +from bot.constants import Channels, MODERATION_ROLES, STAFF_ROLES +from bot.decorators import in_whitelist, with_role +from bot.pagination import LinePaginator +from bot.utils import messages + +log = logging.getLogger(__name__) + +ZEN_OF_PYTHON = """\ +Beautiful is better than ugly. +Explicit is better than implicit. +Simple is better than complex. +Complex is better than complicated. +Flat is better than nested. +Sparse is better than dense. +Readability counts. +Special cases aren't special enough to break the rules. +Although practicality beats purity. +Errors should never pass silently. +Unless explicitly silenced. +In the face of ambiguity, refuse the temptation to guess. +There should be one-- and preferably only one --obvious way to do it. +Although that way may not be obvious at first unless you're Dutch. +Now is better than never. +Although never is often better than *right* now. +If the implementation is hard to explain, it's a bad idea. +If the implementation is easy to explain, it may be a good idea. +Namespaces are one honking great idea -- let's do more of those! +""" + +ICON_URL = "https://www.python.org/static/opengraph-icon-200x200.png" + + +class Utils(Cog): + """A selection of utilities which don't have a clear category.""" + + def __init__(self, bot: Bot): + self.bot = bot + + self.base_pep_url = "http://www.python.org/dev/peps/pep-" + self.base_github_pep_url = "https://raw.githubusercontent.com/python/peps/master/pep-" + + @command(name='pep', aliases=('get_pep', 'p')) + async def pep_command(self, ctx: Context, pep_number: str) -> None: + """Fetches information about a PEP and sends it to the channel.""" + if pep_number.isdigit(): + pep_number = int(pep_number) + else: + await ctx.send_help(ctx.command) + return + + # Handle PEP 0 directly because it's not in .rst or .txt so it can't be accessed like other PEPs. + if pep_number == 0: + return await self.send_pep_zero(ctx) + + possible_extensions = ['.txt', '.rst'] + found_pep = False + for extension in possible_extensions: + # Attempt to fetch the PEP + pep_url = f"{self.base_github_pep_url}{pep_number:04}{extension}" + log.trace(f"Requesting PEP {pep_number} with {pep_url}") + response = await self.bot.http_session.get(pep_url) + + if response.status == 200: + log.trace("PEP found") + found_pep = True + + pep_content = await response.text() + + # Taken from https://github.com/python/peps/blob/master/pep0/pep.py#L179 + pep_header = HeaderParser().parse(StringIO(pep_content)) + + # Assemble the embed + pep_embed = Embed( + title=f"**PEP {pep_number} - {pep_header['Title']}**", + description=f"[Link]({self.base_pep_url}{pep_number:04})", + ) + + pep_embed.set_thumbnail(url=ICON_URL) + + # Add the interesting information + fields_to_check = ("Status", "Python-Version", "Created", "Type") + for field in fields_to_check: + # Check for a PEP metadata field that is present but has an empty value + # embed field values can't contain an empty string + if pep_header.get(field, ""): + pep_embed.add_field(name=field, value=pep_header[field]) + + elif response.status != 404: + # any response except 200 and 404 is expected + found_pep = True # actually not, but it's easier to display this way + log.trace(f"The user requested PEP {pep_number}, but the response had an unexpected status code: " + f"{response.status}.\n{response.text}") + + error_message = "Unexpected HTTP error during PEP search. Please let us know." + pep_embed = Embed(title="Unexpected error", description=error_message) + pep_embed.colour = Colour.red() + break + + if not found_pep: + log.trace("PEP was not found") + not_found = f"PEP {pep_number} does not exist." + pep_embed = Embed(title="PEP not found", description=not_found) + pep_embed.colour = Colour.red() + + await ctx.message.channel.send(embed=pep_embed) + + @command() + @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_ROLES) + async def charinfo(self, ctx: Context, *, characters: str) -> None: + """Shows you information on up to 50 unicode characters.""" + match = re.match(r"<(a?):(\w+):(\d+)>", characters) + if match: + return await messages.send_denial( + ctx, + "**Non-Character Detected**\n" + "Only unicode characters can be processed, but a custom Discord emoji " + "was found. Please remove it and try again." + ) + + if len(characters) > 50: + return await messages.send_denial(ctx, f"Too many characters ({len(characters)}/50)") + + def get_info(char: str) -> Tuple[str, str]: + digit = f"{ord(char):x}" + if len(digit) <= 4: + u_code = f"\\u{digit:>04}" + else: + u_code = f"\\U{digit:>08}" + url = f"https://www.compart.com/en/unicode/U+{digit:>04}" + name = f"[{unicodedata.name(char, '')}]({url})" + info = f"`{u_code.ljust(10)}`: {name} - {utils.escape_markdown(char)}" + return info, u_code + + char_list, raw_list = zip(*(get_info(c) for c in characters)) + embed = Embed().set_author(name="Character Info") + + if len(characters) > 1: + # Maximum length possible is 502 out of 1024, so there's no need to truncate. + embed.add_field(name='Full Raw Text', value=f"`{''.join(raw_list)}`", inline=False) + + await LinePaginator.paginate(char_list, ctx, embed, max_lines=10, max_size=2000, empty=False) + + @command() + async def zen(self, ctx: Context, *, search_value: Union[int, str, None] = None) -> None: + """ + Show the Zen of Python. + + Without any arguments, the full Zen will be produced. + If an integer is provided, the line with that index will be produced. + If a string is provided, the line which matches best will be produced. + """ + embed = Embed( + colour=Colour.blurple(), + title="The Zen of Python", + description=ZEN_OF_PYTHON + ) + + if search_value is None: + embed.title += ", by Tim Peters" + await ctx.send(embed=embed) + return + + zen_lines = ZEN_OF_PYTHON.splitlines() + + # handle if it's an index int + if isinstance(search_value, int): + upper_bound = len(zen_lines) - 1 + lower_bound = -1 * upper_bound + if not (lower_bound <= search_value <= upper_bound): + raise BadArgument(f"Please provide an index between {lower_bound} and {upper_bound}.") + + embed.title += f" (line {search_value % len(zen_lines)}):" + embed.description = zen_lines[search_value] + await ctx.send(embed=embed) + return + + # Try to handle first exact word due difflib.SequenceMatched may use some other similar word instead + # exact word. + for i, line in enumerate(zen_lines): + for word in line.split(): + if word.lower() == search_value.lower(): + embed.title += f" (line {i}):" + embed.description = line + await ctx.send(embed=embed) + return + + # handle if it's a search string and not exact word + matcher = difflib.SequenceMatcher(None, search_value.lower()) + + best_match = "" + match_index = 0 + best_ratio = 0 + + for index, line in enumerate(zen_lines): + matcher.set_seq2(line.lower()) + + # the match ratio needs to be adjusted because, naturally, + # longer lines will have worse ratios than shorter lines when + # fuzzy searching for keywords. this seems to work okay. + adjusted_ratio = (len(line) - 5) ** 0.5 * matcher.ratio() + + if adjusted_ratio > best_ratio: + best_ratio = adjusted_ratio + best_match = line + match_index = index + + if not best_match: + raise BadArgument("I didn't get a match! Please try again with a different search term.") + + embed.title += f" (line {match_index}):" + embed.description = best_match + await ctx.send(embed=embed) + + @command(aliases=("poll",)) + @with_role(*MODERATION_ROLES) + async def vote(self, ctx: Context, title: clean_content(fix_channel_mentions=True), *options: str) -> None: + """ + Build a quick voting poll with matching reactions with the provided options. + + A maximum of 20 options can be provided, as Discord supports a max of 20 + reactions on a single message. + """ + if len(title) > 256: + raise BadArgument("The title cannot be longer than 256 characters.") + if len(options) < 2: + raise BadArgument("Please provide at least 2 options.") + if len(options) > 20: + raise BadArgument("I can only handle 20 options!") + + codepoint_start = 127462 # represents "regional_indicator_a" unicode value + options = {chr(i): f"{chr(i)} - {v}" for i, v in enumerate(options, start=codepoint_start)} + embed = Embed(title=title, description="\n".join(options.values())) + message = await ctx.send(embed=embed) + for reaction in options: + await message.add_reaction(reaction) + + async def send_pep_zero(self, ctx: Context) -> None: + """Send information about PEP 0.""" + pep_embed = Embed( + title="**PEP 0 - Index of Python Enhancement Proposals (PEPs)**", + description="[Link](https://www.python.org/dev/peps/)" + ) + pep_embed.set_thumbnail(url=ICON_URL) + pep_embed.add_field(name="Status", value="Active") + pep_embed.add_field(name="Created", value="13-Jul-2000") + pep_embed.add_field(name="Type", value="Informational") + + await ctx.send(embed=pep_embed) + + +def setup(bot: Bot) -> None: + """Load the Utils cog.""" + bot.add_cog(Utils(bot)) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py deleted file mode 100644 index ae156cf70..000000000 --- a/bot/cogs/verification.py +++ /dev/null @@ -1,191 +0,0 @@ -import logging -from contextlib import suppress - -from discord import Colour, Forbidden, Message, NotFound, Object -from discord.ext.commands import Cog, Context, command - -from bot import constants -from bot.bot import Bot -from bot.cogs.moderation import ModLog -from bot.decorators import in_whitelist, without_role -from bot.utils.checks import InWhitelistCheckFailure, without_role_check - -log = logging.getLogger(__name__) - -WELCOME_MESSAGE = f""" -Hello! Welcome to the server, and thanks for verifying yourself! - -For your records, these are the documents you accepted: - -`1)` Our rules, here: -`2)` Our privacy policy, here: - you can find information on how to have \ -your information removed here as well. - -Feel free to review them at any point! - -Additionally, if you'd like to receive notifications for the announcements \ -we post in <#{constants.Channels.announcements}> -from time to time, you can send `!subscribe` to <#{constants.Channels.bot_commands}> at any time \ -to assign yourself the **Announcements** role. We'll mention this role every time we make an announcement. - -If you'd like to unsubscribe from the announcement notifications, simply send `!unsubscribe` to \ -<#{constants.Channels.bot_commands}>. -""" - -BOT_MESSAGE_DELETE_DELAY = 10 - - -class Verification(Cog): - """User verification and role self-management.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @property - def mod_log(self) -> ModLog: - """Get currently loaded ModLog cog instance.""" - return self.bot.get_cog("ModLog") - - @Cog.listener() - async def on_message(self, message: Message) -> None: - """Check new message event for messages to the checkpoint channel & process.""" - if message.channel.id != constants.Channels.verification: - return # Only listen for #checkpoint messages - - if message.author.bot: - # They're a bot, delete their message after the delay. - await message.delete(delay=BOT_MESSAGE_DELETE_DELAY) - return - - # if a user mentions a role or guild member - # alert the mods in mod-alerts channel - if message.mentions or message.role_mentions: - log.debug( - f"{message.author} mentioned one or more users " - f"and/or roles in {message.channel.name}" - ) - - embed_text = ( - f"{message.author.mention} sent a message in " - f"{message.channel.mention} that contained user and/or role mentions." - f"\n\n**Original message:**\n>>> {message.content}" - ) - - # Send pretty mod log embed to mod-alerts - await self.mod_log.send_log_message( - icon_url=constants.Icons.filtering, - colour=Colour(constants.Colours.soft_red), - title=f"User/Role mentioned in {message.channel.name}", - text=embed_text, - thumbnail=message.author.avatar_url_as(static_format="png"), - channel_id=constants.Channels.mod_alerts, - ) - - ctx: Context = await self.bot.get_context(message) - if ctx.command is not None and ctx.command.name == "accept": - return - - if any(r.id == constants.Roles.verified for r in ctx.author.roles): - log.info( - f"{ctx.author} posted '{ctx.message.content}' " - "in the verification channel, but is already verified." - ) - return - - log.debug( - f"{ctx.author} posted '{ctx.message.content}' in the verification " - "channel. We are providing instructions how to verify." - ) - await ctx.send( - f"{ctx.author.mention} Please type `!accept` to verify that you accept our rules, " - f"and gain access to the rest of the server.", - delete_after=20 - ) - - log.trace(f"Deleting the message posted by {ctx.author}") - with suppress(NotFound): - await ctx.message.delete() - - @command(name='accept', aliases=('verify', 'verified', 'accepted'), hidden=True) - @without_role(constants.Roles.verified) - @in_whitelist(channels=(constants.Channels.verification,)) - async def accept_command(self, ctx: Context, *_) -> None: # We don't actually care about the args - """Accept our rules and gain access to the rest of the server.""" - log.debug(f"{ctx.author} called !accept. Assigning the 'Developer' role.") - await ctx.author.add_roles(Object(constants.Roles.verified), reason="Accepted the rules") - try: - await ctx.author.send(WELCOME_MESSAGE) - except Forbidden: - log.info(f"Sending welcome message failed for {ctx.author}.") - finally: - log.trace(f"Deleting accept message by {ctx.author}.") - with suppress(NotFound): - self.mod_log.ignore(constants.Event.message_delete, ctx.message.id) - await ctx.message.delete() - - @command(name='subscribe') - @in_whitelist(channels=(constants.Channels.bot_commands,)) - async def subscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args - """Subscribe to announcement notifications by assigning yourself the role.""" - has_role = False - - for role in ctx.author.roles: - if role.id == constants.Roles.announcements: - has_role = True - break - - if has_role: - await ctx.send(f"{ctx.author.mention} You're already subscribed!") - return - - log.debug(f"{ctx.author} called !subscribe. Assigning the 'Announcements' role.") - await ctx.author.add_roles(Object(constants.Roles.announcements), reason="Subscribed to announcements") - - log.trace(f"Deleting the message posted by {ctx.author}.") - - await ctx.send( - f"{ctx.author.mention} Subscribed to <#{constants.Channels.announcements}> notifications.", - ) - - @command(name='unsubscribe') - @in_whitelist(channels=(constants.Channels.bot_commands,)) - async def unsubscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args - """Unsubscribe from announcement notifications by removing the role from yourself.""" - has_role = False - - for role in ctx.author.roles: - if role.id == constants.Roles.announcements: - has_role = True - break - - if not has_role: - await ctx.send(f"{ctx.author.mention} You're already unsubscribed!") - return - - log.debug(f"{ctx.author} called !unsubscribe. Removing the 'Announcements' role.") - await ctx.author.remove_roles(Object(constants.Roles.announcements), reason="Unsubscribed from announcements") - - log.trace(f"Deleting the message posted by {ctx.author}.") - - await ctx.send( - f"{ctx.author.mention} Unsubscribed from <#{constants.Channels.announcements}> notifications." - ) - - # This cannot be static (must have a __func__ attribute). - async def cog_command_error(self, ctx: Context, error: Exception) -> None: - """Check for & ignore any InWhitelistCheckFailure.""" - if isinstance(error, InWhitelistCheckFailure): - error.handled = True - - @staticmethod - def bot_check(ctx: Context) -> bool: - """Block any command within the verification channel that is not !accept.""" - if ctx.channel.id == constants.Channels.verification and without_role_check(ctx, *constants.MODERATION_ROLES): - return ctx.command.name == "accept" - else: - return True - - -def setup(bot: Bot) -> None: - """Load the Verification cog.""" - bot.add_cog(Verification(bot)) diff --git a/bot/cogs/watchchannels/__init__.py b/bot/cogs/watchchannels/__init__.py deleted file mode 100644 index 69d118df6..000000000 --- a/bot/cogs/watchchannels/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -from bot.bot import Bot -from .bigbrother import BigBrother -from .talentpool import TalentPool - - -def setup(bot: Bot) -> None: - """Load the BigBrother and TalentPool cogs.""" - bot.add_cog(BigBrother(bot)) - bot.add_cog(TalentPool(bot)) diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py deleted file mode 100644 index 4d27a6333..000000000 --- a/bot/cogs/watchchannels/bigbrother.py +++ /dev/null @@ -1,165 +0,0 @@ -import logging -import textwrap -from collections import ChainMap - -from discord.ext.commands import Cog, Context, group - -from bot.bot import Bot -from bot.cogs.moderation.utils import post_infraction -from bot.constants import Channels, MODERATION_ROLES, Webhooks -from bot.converters import FetchedMember -from bot.decorators import with_role -from .watchchannel import WatchChannel - -log = logging.getLogger(__name__) - - -class BigBrother(WatchChannel, Cog, name="Big Brother"): - """Monitors users by relaying their messages to a watch channel to assist with moderation.""" - - def __init__(self, bot: Bot) -> None: - super().__init__( - bot, - destination=Channels.big_brother_logs, - webhook_id=Webhooks.big_brother, - api_endpoint='bot/infractions', - api_default_params={'active': 'true', 'type': 'watch', 'ordering': '-inserted_at'}, - logger=log - ) - - @group(name='bigbrother', aliases=('bb',), invoke_without_command=True) - @with_role(*MODERATION_ROLES) - async def bigbrother_group(self, ctx: Context) -> None: - """Monitors users by relaying their messages to the Big Brother watch channel.""" - await ctx.send_help(ctx.command) - - @bigbrother_group.command(name='watched', aliases=('all', 'list')) - @with_role(*MODERATION_ROLES) - async def watched_command( - self, ctx: Context, oldest_first: bool = False, update_cache: bool = True - ) -> None: - """ - Shows the users that are currently being monitored by Big Brother. - - The optional kwarg `oldest_first` can be used to order the list by oldest watched. - - The optional kwarg `update_cache` can be used to update the user - cache using the API before listing the users. - """ - await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache) - - @bigbrother_group.command(name='oldest') - @with_role(*MODERATION_ROLES) - async def oldest_command(self, ctx: Context, update_cache: bool = True) -> None: - """ - Shows Big Brother monitored users ordered by oldest watched. - - The optional kwarg `update_cache` can be used to update the user - cache using the API before listing the users. - """ - await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache) - - @bigbrother_group.command(name='watch', aliases=('w',)) - @with_role(*MODERATION_ROLES) - async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: - """ - Relay messages sent by the given `user` to the `#big-brother` channel. - - A `reason` for adding the user to Big Brother is required and will be displayed - in the header when relaying messages of this user to the watchchannel. - """ - await self.apply_watch(ctx, user, reason) - - @bigbrother_group.command(name='unwatch', aliases=('uw',)) - @with_role(*MODERATION_ROLES) - async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: - """Stop relaying messages by the given `user`.""" - await self.apply_unwatch(ctx, user, reason) - - async def apply_watch(self, ctx: Context, user: FetchedMember, reason: str) -> None: - """ - Add `user` to watched users and apply a watch infraction with `reason`. - - A message indicating the result of the operation is sent to `ctx`. - The message will include `user`'s previous watch infraction history, if it exists. - """ - if user.bot: - await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.") - return - - if not await self.fetch_user_cache(): - await ctx.send(f":x: Updating the user cache failed, can't watch user {user}") - return - - if user.id in self.watched_users: - await ctx.send(f":x: {user} is already being watched.") - return - - response = await post_infraction(ctx, user, 'watch', reason, hidden=True, active=True) - - if response is not None: - self.watched_users[user.id] = response - msg = f":white_check_mark: Messages sent by {user} will now be relayed to Big Brother." - - history = await self.bot.api_client.get( - self.api_endpoint, - params={ - "user__id": str(user.id), - "active": "false", - 'type': 'watch', - 'ordering': '-inserted_at' - } - ) - - if len(history) > 1: - total = f"({len(history) // 2} previous infractions in total)" - end_reason = textwrap.shorten(history[0]["reason"], width=500, placeholder="...") - start_reason = f"Watched: {textwrap.shorten(history[1]['reason'], width=500, placeholder='...')}" - msg += f"\n\nUser's previous watch reasons {total}:```{start_reason}\n\n{end_reason}```" - else: - msg = ":x: Failed to post the infraction: response was empty." - - await ctx.send(msg) - - async def apply_unwatch(self, ctx: Context, user: FetchedMember, reason: str, send_message: bool = True) -> None: - """ - Remove `user` from watched users and mark their infraction as inactive with `reason`. - - If `send_message` is True, a message indicating the result of the operation is sent to - `ctx`. - """ - active_watches = await self.bot.api_client.get( - self.api_endpoint, - params=ChainMap( - self.api_default_params, - {"user__id": str(user.id)} - ) - ) - if active_watches: - log.trace("Active watches for user found. Attempting to remove.") - [infraction] = active_watches - - await self.bot.api_client.patch( - f"{self.api_endpoint}/{infraction['id']}", - json={'active': False} - ) - - await post_infraction(ctx, user, 'watch', f"Unwatched: {reason}", hidden=True, active=False) - - self._remove_user(user.id) - - if not send_message: # Prevents a message being sent to the channel if part of a permanent ban - log.debug(f"Perma-banned user {user} was unwatched.") - return - log.trace("User is not banned. Sending message to channel") - message = f":white_check_mark: Messages sent by {user} will no longer be relayed." - - else: - log.trace("No active watches found for user.") - if not send_message: # Prevents a message being sent to the channel if part of a permanent ban - log.debug(f"{user} was not on the watch list; no removal necessary.") - return - log.trace("User is not perma banned. Send the error message.") - message = ":x: The specified user is currently not being watched." - - await ctx.send(message) diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py deleted file mode 100644 index 89256e92e..000000000 --- a/bot/cogs/watchchannels/talentpool.py +++ /dev/null @@ -1,264 +0,0 @@ -import logging -import textwrap -from collections import ChainMap - -from discord import Color, Embed, Member -from discord.ext.commands import Cog, Context, group - -from bot.api import ResponseCodeError -from bot.bot import Bot -from bot.constants import Channels, Guild, MODERATION_ROLES, STAFF_ROLES, Webhooks -from bot.converters import FetchedMember -from bot.decorators import with_role -from bot.pagination import LinePaginator -from bot.utils import time -from .watchchannel import WatchChannel - -log = logging.getLogger(__name__) - - -class TalentPool(WatchChannel, Cog, name="Talentpool"): - """Relays messages of helper candidates to a watch channel to observe them.""" - - def __init__(self, bot: Bot) -> None: - super().__init__( - bot, - destination=Channels.talent_pool, - webhook_id=Webhooks.talent_pool, - api_endpoint='bot/nominations', - api_default_params={'active': 'true', 'ordering': '-inserted_at'}, - logger=log, - ) - - @group(name='talentpool', aliases=('tp', 'talent', 'nomination', 'n'), invoke_without_command=True) - @with_role(*MODERATION_ROLES) - async def nomination_group(self, ctx: Context) -> None: - """Highlights the activity of helper nominees by relaying their messages to the talent pool channel.""" - await ctx.send_help(ctx.command) - - @nomination_group.command(name='watched', aliases=('all', 'list')) - @with_role(*MODERATION_ROLES) - async def watched_command( - self, ctx: Context, oldest_first: bool = False, update_cache: bool = True - ) -> None: - """ - Shows the users that are currently being monitored in the talent pool. - - The optional kwarg `oldest_first` can be used to order the list by oldest nomination. - - The optional kwarg `update_cache` can be used to update the user - cache using the API before listing the users. - """ - await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache) - - @nomination_group.command(name='oldest') - @with_role(*MODERATION_ROLES) - async def oldest_command(self, ctx: Context, update_cache: bool = True) -> None: - """ - Shows talent pool monitored users ordered by oldest nomination. - - The optional kwarg `update_cache` can be used to update the user - cache using the API before listing the users. - """ - await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache) - - @nomination_group.command(name='watch', aliases=('w', 'add', 'a')) - @with_role(*STAFF_ROLES) - async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: - """ - Relay messages sent by the given `user` to the `#talent-pool` channel. - - A `reason` for adding the user to the talent pool is required and will be displayed - in the header when relaying messages of this user to the channel. - """ - if user.bot: - await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.") - return - - if isinstance(user, Member) and any(role.id in STAFF_ROLES for role in user.roles): - await ctx.send(":x: Nominating staff members, eh? Here's a cookie :cookie:") - return - - if not await self.fetch_user_cache(): - await ctx.send(f":x: Failed to update the user cache; can't add {user}") - return - - if user.id in self.watched_users: - await ctx.send(f":x: {user} is already being watched in the talent pool") - return - - # Manual request with `raise_for_status` as False because we want the actual response - session = self.bot.api_client.session - url = self.bot.api_client._url_for(self.api_endpoint) - kwargs = { - 'json': { - 'actor': ctx.author.id, - 'reason': reason, - 'user': user.id - }, - 'raise_for_status': False, - } - async with session.post(url, **kwargs) as resp: - response_data = await resp.json() - - if resp.status == 400 and response_data.get('user', False): - await ctx.send(":x: The specified user can't be found in the database tables") - return - else: - resp.raise_for_status() - - self.watched_users[user.id] = response_data - msg = f":white_check_mark: Messages sent by {user} will now be relayed to the talent pool channel" - - history = await self.bot.api_client.get( - self.api_endpoint, - params={ - "user__id": str(user.id), - "active": "false", - "ordering": "-inserted_at" - } - ) - - if history: - total = f"({len(history)} previous nominations in total)" - start_reason = f"Watched: {textwrap.shorten(history[0]['reason'], width=500, placeholder='...')}" - end_reason = f"Unwatched: {textwrap.shorten(history[0]['end_reason'], width=500, placeholder='...')}" - msg += f"\n\nUser's previous watch reasons {total}:```{start_reason}\n\n{end_reason}```" - - await ctx.send(msg) - - @nomination_group.command(name='history', aliases=('info', 'search')) - @with_role(*MODERATION_ROLES) - async def history_command(self, ctx: Context, user: FetchedMember) -> None: - """Shows the specified user's nomination history.""" - result = await self.bot.api_client.get( - self.api_endpoint, - params={ - 'user__id': str(user.id), - 'ordering': "-active,-inserted_at" - } - ) - if not result: - await ctx.send(":warning: This user has never been nominated") - return - - embed = Embed( - title=f"Nominations for {user.display_name} `({user.id})`", - color=Color.blue() - ) - lines = [self._nomination_to_string(nomination) for nomination in result] - await LinePaginator.paginate( - lines, - ctx=ctx, - embed=embed, - empty=True, - max_lines=3, - max_size=1000 - ) - - @nomination_group.command(name='unwatch', aliases=('end', )) - @with_role(*MODERATION_ROLES) - async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: - """ - Ends the active nomination of the specified user with the given reason. - - Providing a `reason` is required. - """ - active_nomination = await self.bot.api_client.get( - self.api_endpoint, - params=ChainMap( - self.api_default_params, - {"user__id": str(user.id)} - ) - ) - - if not active_nomination: - await ctx.send(":x: The specified user does not have an active nomination") - return - - [nomination] = active_nomination - await self.bot.api_client.patch( - f"{self.api_endpoint}/{nomination['id']}", - json={'end_reason': reason, 'active': False} - ) - await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed") - self._remove_user(user.id) - - @nomination_group.group(name='edit', aliases=('e',), invoke_without_command=True) - @with_role(*MODERATION_ROLES) - async def nomination_edit_group(self, ctx: Context) -> None: - """Commands to edit nominations.""" - await ctx.send_help(ctx.command) - - @nomination_edit_group.command(name='reason') - @with_role(*MODERATION_ROLES) - async def edit_reason_command(self, ctx: Context, nomination_id: int, *, reason: str) -> None: - """ - Edits the reason/unnominate reason for the nomination with the given `id` depending on the status. - - If the nomination is active, the reason for nominating the user will be edited; - If the nomination is no longer active, the reason for ending the nomination will be edited instead. - """ - try: - nomination = await self.bot.api_client.get(f"{self.api_endpoint}/{nomination_id}") - except ResponseCodeError as e: - if e.response.status == 404: - self.log.trace(f"Nomination API 404: Can't nomination with id {nomination_id}") - await ctx.send(f":x: Can't find a nomination with id `{nomination_id}`") - return - else: - raise - - field = "reason" if nomination["active"] else "end_reason" - - self.log.trace(f"Changing {field} for nomination with id {nomination_id} to {reason}") - - await self.bot.api_client.patch( - f"{self.api_endpoint}/{nomination_id}", - json={field: reason} - ) - - await ctx.send(f":white_check_mark: Updated the {field} of the nomination!") - - def _nomination_to_string(self, nomination_object: dict) -> str: - """Creates a string representation of a nomination.""" - guild = self.bot.get_guild(Guild.id) - - actor_id = nomination_object["actor"] - actor = guild.get_member(actor_id) - - active = nomination_object["active"] - log.debug(active) - log.debug(type(nomination_object["inserted_at"])) - - start_date = time.format_infraction(nomination_object["inserted_at"]) - if active: - lines = textwrap.dedent( - f""" - =============== - Status: **Active** - Date: {start_date} - Actor: {actor.mention if actor else actor_id} - Reason: {nomination_object["reason"]} - Nomination ID: `{nomination_object["id"]}` - =============== - """ - ) - else: - end_date = time.format_infraction(nomination_object["ended_at"]) - lines = textwrap.dedent( - f""" - =============== - Status: Inactive - Date: {start_date} - Actor: {actor.mention if actor else actor_id} - Reason: {nomination_object["reason"]} - - End date: {end_date} - Unwatch reason: {nomination_object["end_reason"]} - Nomination ID: `{nomination_object["id"]}` - =============== - """ - ) - - return lines.strip() diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py deleted file mode 100644 index 044077350..000000000 --- a/bot/cogs/watchchannels/watchchannel.py +++ /dev/null @@ -1,348 +0,0 @@ -import asyncio -import logging -import re -import textwrap -from abc import abstractmethod -from collections import defaultdict, deque -from dataclasses import dataclass -from typing import Optional - -import dateutil.parser -import discord -from discord import Color, DMChannel, Embed, HTTPException, Message, errors -from discord.ext.commands import Cog, Context - -from bot.api import ResponseCodeError -from bot.bot import Bot -from bot.cogs.moderation import ModLog -from bot.constants import BigBrother as BigBrotherConfig, Guild as GuildConfig, Icons -from bot.pagination import LinePaginator -from bot.utils import CogABCMeta, messages -from bot.utils.time import time_since - -log = logging.getLogger(__name__) - -URL_RE = re.compile(r"(https?://[^\s]+)") - - -@dataclass -class MessageHistory: - """Represents a watch channel's message history.""" - - last_author: Optional[int] = None - last_channel: Optional[int] = None - message_count: int = 0 - - -class WatchChannel(metaclass=CogABCMeta): - """ABC with functionality for relaying users' messages to a certain channel.""" - - @abstractmethod - def __init__( - self, - bot: Bot, - destination: int, - webhook_id: int, - api_endpoint: str, - api_default_params: dict, - logger: logging.Logger - ) -> None: - self.bot = bot - - self.destination = destination # E.g., Channels.big_brother_logs - self.webhook_id = webhook_id # E.g., Webhooks.big_brother - self.api_endpoint = api_endpoint # E.g., 'bot/infractions' - self.api_default_params = api_default_params # E.g., {'active': 'true', 'type': 'watch'} - self.log = logger # Logger of the child cog for a correct name in the logs - - self._consume_task = None - self.watched_users = defaultdict(dict) - self.message_queue = defaultdict(lambda: defaultdict(deque)) - self.consumption_queue = {} - self.retries = 5 - self.retry_delay = 10 - self.channel = None - self.webhook = None - self.message_history = MessageHistory() - - self._start = self.bot.loop.create_task(self.start_watchchannel()) - - @property - def modlog(self) -> ModLog: - """Provides access to the ModLog cog for alert purposes.""" - return self.bot.get_cog("ModLog") - - @property - def consuming_messages(self) -> bool: - """Checks if a consumption task is currently running.""" - if self._consume_task is None: - return False - - if self._consume_task.done(): - exc = self._consume_task.exception() - if exc: - self.log.exception( - "The message queue consume task has failed with:", - exc_info=exc - ) - return False - - return True - - async def start_watchchannel(self) -> None: - """Starts the watch channel by getting the channel, webhook, and user cache ready.""" - await self.bot.wait_until_guild_available() - - try: - self.channel = await self.bot.fetch_channel(self.destination) - except HTTPException: - self.log.exception(f"Failed to retrieve the text channel with id `{self.destination}`") - - try: - self.webhook = await self.bot.fetch_webhook(self.webhook_id) - except discord.HTTPException: - self.log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`") - - if self.channel is None or self.webhook is None: - self.log.error("Failed to start the watch channel; unloading the cog.") - - message = textwrap.dedent( - f""" - An error occurred while loading the text channel or webhook. - - TextChannel: {"**Failed to load**" if self.channel is None else "Loaded successfully"} - Webhook: {"**Failed to load**" if self.webhook is None else "Loaded successfully"} - - The Cog has been unloaded. - """ - ) - - await self.modlog.send_log_message( - title=f"Error: Failed to initialize the {self.__class__.__name__} watch channel", - text=message, - ping_everyone=True, - icon_url=Icons.token_removed, - colour=Color.red() - ) - - self.bot.remove_cog(self.__class__.__name__) - return - - if not await self.fetch_user_cache(): - await self.modlog.send_log_message( - title=f"Warning: Failed to retrieve user cache for the {self.__class__.__name__} watch channel", - text="Could not retrieve the list of watched users from the API and messages will not be relayed.", - ping_everyone=True, - icon_url=Icons.token_removed, - colour=Color.red() - ) - - async def fetch_user_cache(self) -> bool: - """ - Fetches watched users from the API and updates the watched user cache accordingly. - - This function returns `True` if the update succeeded. - """ - try: - data = await self.bot.api_client.get(self.api_endpoint, params=self.api_default_params) - except ResponseCodeError as err: - self.log.exception("Failed to fetch the watched users from the API", exc_info=err) - return False - - self.watched_users = defaultdict(dict) - - for entry in data: - user_id = entry.pop('user') - self.watched_users[user_id] = entry - - return True - - @Cog.listener() - async def on_message(self, msg: Message) -> None: - """Queues up messages sent by watched users.""" - if msg.author.id in self.watched_users: - if not self.consuming_messages: - self._consume_task = self.bot.loop.create_task(self.consume_messages()) - - self.log.trace(f"Received message: {msg.content} ({len(msg.attachments)} attachments)") - self.message_queue[msg.author.id][msg.channel.id].append(msg) - - async def consume_messages(self, delay_consumption: bool = True) -> None: - """Consumes the message queues to log watched users' messages.""" - if delay_consumption: - self.log.trace(f"Sleeping {BigBrotherConfig.log_delay} seconds before consuming message queue") - await asyncio.sleep(BigBrotherConfig.log_delay) - - self.log.trace("Started consuming the message queue") - - # If the previous consumption Task failed, first consume the existing comsumption_queue - if not self.consumption_queue: - self.consumption_queue = self.message_queue.copy() - self.message_queue.clear() - - for user_channel_queues in self.consumption_queue.values(): - for channel_queue in user_channel_queues.values(): - while channel_queue: - msg = channel_queue.popleft() - - self.log.trace(f"Consuming message {msg.id} ({len(msg.attachments)} attachments)") - await self.relay_message(msg) - - self.consumption_queue.clear() - - if self.message_queue: - self.log.trace("Channel queue not empty: Continuing consuming queues") - self._consume_task = self.bot.loop.create_task(self.consume_messages(delay_consumption=False)) - else: - self.log.trace("Done consuming messages.") - - async def webhook_send( - self, - content: Optional[str] = None, - username: Optional[str] = None, - avatar_url: Optional[str] = None, - embed: Optional[Embed] = None, - ) -> None: - """Sends a message to the webhook with the specified kwargs.""" - username = messages.sub_clyde(username) - try: - await self.webhook.send(content=content, username=username, avatar_url=avatar_url, embed=embed) - except discord.HTTPException as exc: - self.log.exception( - "Failed to send a message to the webhook", - exc_info=exc - ) - - async def relay_message(self, msg: Message) -> None: - """Relays the message to the relevant watch channel.""" - limit = BigBrotherConfig.header_message_limit - - if ( - msg.author.id != self.message_history.last_author - or msg.channel.id != self.message_history.last_channel - or self.message_history.message_count >= limit - ): - self.message_history = MessageHistory(last_author=msg.author.id, last_channel=msg.channel.id) - - await self.send_header(msg) - - cleaned_content = msg.clean_content - - if cleaned_content: - # Put all non-media URLs in a code block to prevent embeds - media_urls = {embed.url for embed in msg.embeds if embed.type in ("image", "video")} - for url in URL_RE.findall(cleaned_content): - if url not in media_urls: - cleaned_content = cleaned_content.replace(url, f"`{url}`") - await self.webhook_send( - cleaned_content, - username=msg.author.display_name, - avatar_url=msg.author.avatar_url - ) - - if msg.attachments: - try: - await messages.send_attachments(msg, self.webhook) - except (errors.Forbidden, errors.NotFound): - e = Embed( - description=":x: **This message contained an attachment, but it could not be retrieved**", - color=Color.red() - ) - await self.webhook_send( - embed=e, - username=msg.author.display_name, - avatar_url=msg.author.avatar_url - ) - except discord.HTTPException as exc: - self.log.exception( - "Failed to send an attachment to the webhook", - exc_info=exc - ) - - self.message_history.message_count += 1 - - async def send_header(self, msg: Message) -> None: - """Sends a header embed with information about the relayed messages to the watch channel.""" - user_id = msg.author.id - - guild = self.bot.get_guild(GuildConfig.id) - actor = guild.get_member(self.watched_users[user_id]['actor']) - actor = actor.display_name if actor else self.watched_users[user_id]['actor'] - - inserted_at = self.watched_users[user_id]['inserted_at'] - time_delta = self._get_time_delta(inserted_at) - - reason = self.watched_users[user_id]['reason'] - - if isinstance(msg.channel, DMChannel): - # If a watched user DMs the bot there won't be a channel name or jump URL - # This could technically include a GroupChannel but bot's can't be in those - message_jump = "via DM" - else: - message_jump = f"in [#{msg.channel.name}]({msg.jump_url})" - - footer = f"Added {time_delta} by {actor} | Reason: {reason}" - embed = Embed(description=f"{msg.author.mention} {message_jump}") - embed.set_footer(text=textwrap.shorten(footer, width=128, placeholder="...")) - - await self.webhook_send(embed=embed, username=msg.author.display_name, avatar_url=msg.author.avatar_url) - - async def list_watched_users( - self, ctx: Context, oldest_first: bool = False, update_cache: bool = True - ) -> None: - """ - Gives an overview of the watched user list for this channel. - - The optional kwarg `oldest_first` orders the list by oldest entry. - - The optional kwarg `update_cache` specifies whether the cache should - be refreshed by polling the API. - """ - if update_cache: - if not await self.fetch_user_cache(): - await ctx.send(f":x: Failed to update {self.__class__.__name__} user cache, serving from cache") - update_cache = False - - lines = [] - for user_id, user_data in self.watched_users.items(): - inserted_at = user_data['inserted_at'] - time_delta = self._get_time_delta(inserted_at) - lines.append(f"• <@{user_id}> (added {time_delta})") - - if oldest_first: - lines.reverse() - - lines = lines or ("There's nothing here yet.",) - - embed = Embed( - title=f"{self.__class__.__name__} watched users ({'updated' if update_cache else 'cached'})", - color=Color.blue() - ) - await LinePaginator.paginate(lines, ctx, embed, empty=False) - - @staticmethod - def _get_time_delta(time_string: str) -> str: - """Returns the time in human-readable time delta format.""" - date_time = dateutil.parser.isoparse(time_string).replace(tzinfo=None) - time_delta = time_since(date_time, precision="minutes", max_units=1) - - return time_delta - - def _remove_user(self, user_id: int) -> None: - """Removes a user from a watch channel.""" - self.watched_users.pop(user_id, None) - self.message_queue.pop(user_id, None) - self.consumption_queue.pop(user_id, None) - - def cog_unload(self) -> None: - """Takes care of unloading the cog and canceling the consumption task.""" - self.log.trace("Unloading the cog") - if self._consume_task and not self._consume_task.done(): - self._consume_task.cancel() - try: - self._consume_task.result() - except asyncio.CancelledError as e: - self.log.exception( - "The consume task was canceled. Messages may be lost.", - exc_info=e - ) diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py deleted file mode 100644 index 5812da87c..000000000 --- a/bot/cogs/webhook_remover.py +++ /dev/null @@ -1,84 +0,0 @@ -import logging -import re - -from discord import Colour, Message, NotFound -from discord.ext.commands import Cog - -from bot.bot import Bot -from bot.cogs.moderation.modlog import ModLog -from bot.constants import Channels, Colours, Event, Icons - -WEBHOOK_URL_RE = re.compile(r"((?:https?://)?discord(?:app)?\.com/api/webhooks/\d+/)\S+/?", re.IGNORECASE) - -ALERT_MESSAGE_TEMPLATE = ( - "{user}, looks like you posted a Discord webhook URL. Therefore, your " - "message has been removed. Your webhook may have been **compromised** so " - "please re-create the webhook **immediately**. If you believe this was " - "mistake, please let us know." -) - -log = logging.getLogger(__name__) - - -class WebhookRemover(Cog): - """Scan messages to detect Discord webhooks links.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @property - def mod_log(self) -> ModLog: - """Get current instance of `ModLog`.""" - return self.bot.get_cog("ModLog") - - async def delete_and_respond(self, msg: Message, redacted_url: str) -> None: - """Delete `msg` and send a warning that it contained the Discord webhook `redacted_url`.""" - # Don't log this, due internal delete, not by user. Will make different entry. - self.mod_log.ignore(Event.message_delete, msg.id) - - try: - await msg.delete() - except NotFound: - log.debug(f"Failed to remove webhook in message {msg.id}: message already deleted.") - return - - await msg.channel.send(ALERT_MESSAGE_TEMPLATE.format(user=msg.author.mention)) - - message = ( - f"{msg.author} (`{msg.author.id}`) posted a Discord webhook URL " - f"to #{msg.channel}. Webhook URL was `{redacted_url}`" - ) - log.debug(message) - - # Send entry to moderation alerts. - await self.mod_log.send_log_message( - icon_url=Icons.token_removed, - colour=Colour(Colours.soft_red), - title="Discord webhook URL removed!", - text=message, - thumbnail=msg.author.avatar_url_as(static_format="png"), - channel_id=Channels.mod_alerts - ) - - self.bot.stats.incr("tokens.removed_webhooks") - - @Cog.listener() - async def on_message(self, msg: Message) -> None: - """Check if a Discord webhook URL is in `message`.""" - # Ignore DMs; can't delete messages in there anyway. - if not msg.guild or msg.author.bot: - return - - matches = WEBHOOK_URL_RE.search(msg.content) - if matches: - await self.delete_and_respond(msg, matches[1] + "xxx") - - @Cog.listener() - async def on_message_edit(self, before: Message, after: Message) -> None: - """Check if a Discord webhook URL is in the edited message `after`.""" - await self.on_message(after) - - -def setup(bot: Bot) -> None: - """Load `WebhookRemover` cog.""" - bot.add_cog(WebhookRemover(bot)) diff --git a/bot/cogs/wolfram.py b/bot/cogs/wolfram.py deleted file mode 100644 index e6cae3bb8..000000000 --- a/bot/cogs/wolfram.py +++ /dev/null @@ -1,280 +0,0 @@ -import logging -from io import BytesIO -from typing import Callable, List, Optional, Tuple -from urllib import parse - -import discord -from dateutil.relativedelta import relativedelta -from discord import Embed -from discord.ext import commands -from discord.ext.commands import BucketType, Cog, Context, check, group - -from bot.bot import Bot -from bot.constants import Colours, STAFF_ROLES, Wolfram -from bot.pagination import ImagePaginator -from bot.utils.time import humanize_delta - -log = logging.getLogger(__name__) - -APPID = Wolfram.key -DEFAULT_OUTPUT_FORMAT = "JSON" -QUERY = "http://api.wolframalpha.com/v2/{request}?{data}" -WOLF_IMAGE = "https://www.symbols.com/gi.php?type=1&id=2886&i=1" - -MAX_PODS = 20 - -# Allows for 10 wolfram calls pr user pr day -usercd = commands.CooldownMapping.from_cooldown(Wolfram.user_limit_day, 60*60*24, BucketType.user) - -# Allows for max api requests / days in month per day for the entire guild (Temporary) -guildcd = commands.CooldownMapping.from_cooldown(Wolfram.guild_limit_day, 60*60*24, BucketType.guild) - - -async def send_embed( - ctx: Context, - message_txt: str, - colour: int = Colours.soft_red, - footer: str = None, - img_url: str = None, - f: discord.File = None -) -> None: - """Generate & send a response embed with Wolfram as the author.""" - embed = Embed(colour=colour) - embed.description = message_txt - embed.set_author(name="Wolfram Alpha", - icon_url=WOLF_IMAGE, - url="https://www.wolframalpha.com/") - if footer: - embed.set_footer(text=footer) - - if img_url: - embed.set_image(url=img_url) - - await ctx.send(embed=embed, file=f) - - -def custom_cooldown(*ignore: List[int]) -> Callable: - """ - Implement per-user and per-guild cooldowns for requests to the Wolfram API. - - A list of roles may be provided to ignore the per-user cooldown - """ - async def predicate(ctx: Context) -> bool: - if ctx.invoked_with == 'help': - # if the invoked command is help we don't want to increase the ratelimits since it's not actually - # invoking the command/making a request, so instead just check if the user/guild are on cooldown. - guild_cooldown = not guildcd.get_bucket(ctx.message).get_tokens() == 0 # if guild is on cooldown - if not any(r.id in ignore for r in ctx.author.roles): # check user bucket if user is not ignored - return guild_cooldown and not usercd.get_bucket(ctx.message).get_tokens() == 0 - return guild_cooldown - - user_bucket = usercd.get_bucket(ctx.message) - - if all(role.id not in ignore for role in ctx.author.roles): - user_rate = user_bucket.update_rate_limit() - - if user_rate: - # Can't use api; cause: member limit - delta = relativedelta(seconds=int(user_rate)) - cooldown = humanize_delta(delta) - message = ( - "You've used up your limit for Wolfram|Alpha requests.\n" - f"Cooldown: {cooldown}" - ) - await send_embed(ctx, message) - return False - - guild_bucket = guildcd.get_bucket(ctx.message) - guild_rate = guild_bucket.update_rate_limit() - - # Repr has a token attribute to read requests left - log.debug(guild_bucket) - - if guild_rate: - # Can't use api; cause: guild limit - message = ( - "The max limit of requests for the server has been reached for today.\n" - f"Cooldown: {int(guild_rate)}" - ) - await send_embed(ctx, message) - return False - - return True - return check(predicate) - - -async def get_pod_pages(ctx: Context, bot: Bot, query: str) -> Optional[List[Tuple]]: - """Get the Wolfram API pod pages for the provided query.""" - async with ctx.channel.typing(): - url_str = parse.urlencode({ - "input": query, - "appid": APPID, - "output": DEFAULT_OUTPUT_FORMAT, - "format": "image,plaintext" - }) - request_url = QUERY.format(request="query", data=url_str) - - async with bot.http_session.get(request_url) as response: - json = await response.json(content_type='text/plain') - - result = json["queryresult"] - - if result["error"]: - # API key not set up correctly - if result["error"]["msg"] == "Invalid appid": - message = "Wolfram API key is invalid or missing." - log.warning( - "API key seems to be missing, or invalid when " - f"processing a wolfram request: {url_str}, Response: {json}" - ) - await send_embed(ctx, message) - return - - message = "Something went wrong internally with your request, please notify staff!" - log.warning(f"Something went wrong getting a response from wolfram: {url_str}, Response: {json}") - await send_embed(ctx, message) - return - - if not result["success"]: - message = f"I couldn't find anything for {query}." - await send_embed(ctx, message) - return - - if not result["numpods"]: - message = "Could not find any results." - await send_embed(ctx, message) - return - - pods = result["pods"] - pages = [] - for pod in pods[:MAX_PODS]: - subs = pod.get("subpods") - - for sub in subs: - title = sub.get("title") or sub.get("plaintext") or sub.get("id", "") - img = sub["img"]["src"] - pages.append((title, img)) - return pages - - -class Wolfram(Cog): - """Commands for interacting with the Wolfram|Alpha API.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @group(name="wolfram", aliases=("wolf", "wa"), invoke_without_command=True) - @custom_cooldown(*STAFF_ROLES) - async def wolfram_command(self, ctx: Context, *, query: str) -> None: - """Requests all answers on a single image, sends an image of all related pods.""" - url_str = parse.urlencode({ - "i": query, - "appid": APPID, - }) - query = QUERY.format(request="simple", data=url_str) - - # Give feedback that the bot is working. - async with ctx.channel.typing(): - async with self.bot.http_session.get(query) as response: - status = response.status - image_bytes = await response.read() - - f = discord.File(BytesIO(image_bytes), filename="image.png") - image_url = "attachment://image.png" - - if status == 501: - message = "Failed to get response" - footer = "" - color = Colours.soft_red - elif status == 400: - message = "No input found" - footer = "" - color = Colours.soft_red - elif status == 403: - message = "Wolfram API key is invalid or missing." - footer = "" - color = Colours.soft_red - else: - message = "" - footer = "View original for a bigger picture." - color = Colours.soft_orange - - # Sends a "blank" embed if no request is received, unsure how to fix - await send_embed(ctx, message, color, footer=footer, img_url=image_url, f=f) - - @wolfram_command.command(name="page", aliases=("pa", "p")) - @custom_cooldown(*STAFF_ROLES) - async def wolfram_page_command(self, ctx: Context, *, query: str) -> None: - """ - Requests a drawn image of given query. - - Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc. - """ - pages = await get_pod_pages(ctx, self.bot, query) - - if not pages: - return - - embed = Embed() - embed.set_author(name="Wolfram Alpha", - icon_url=WOLF_IMAGE, - url="https://www.wolframalpha.com/") - embed.colour = Colours.soft_orange - - await ImagePaginator.paginate(pages, ctx, embed) - - @wolfram_command.command(name="cut", aliases=("c",)) - @custom_cooldown(*STAFF_ROLES) - async def wolfram_cut_command(self, ctx: Context, *, query: str) -> None: - """ - Requests a drawn image of given query. - - Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc. - """ - pages = await get_pod_pages(ctx, self.bot, query) - - if not pages: - return - - if len(pages) >= 2: - page = pages[1] - else: - page = pages[0] - - await send_embed(ctx, page[0], colour=Colours.soft_orange, img_url=page[1]) - - @wolfram_command.command(name="short", aliases=("sh", "s")) - @custom_cooldown(*STAFF_ROLES) - async def wolfram_short_command(self, ctx: Context, *, query: str) -> None: - """Requests an answer to a simple question.""" - url_str = parse.urlencode({ - "i": query, - "appid": APPID, - }) - query = QUERY.format(request="result", data=url_str) - - # Give feedback that the bot is working. - async with ctx.channel.typing(): - async with self.bot.http_session.get(query) as response: - status = response.status - response_text = await response.text() - - if status == 501: - message = "Failed to get response" - color = Colours.soft_red - elif status == 400: - message = "No input found" - color = Colours.soft_red - elif response_text == "Error 1: Invalid appid": - message = "Wolfram API key is invalid or missing." - color = Colours.soft_red - else: - message = response_text - color = Colours.soft_orange - - await send_embed(ctx, message, color) - - -def setup(bot: Bot) -> None: - """Load the Wolfram cog.""" - bot.add_cog(Wolfram(bot)) diff --git a/tests/bot/cogs/moderation/test_infractions.py b/tests/bot/cogs/moderation/test_infractions.py index da4e92ccc..df38090fb 100644 --- a/tests/bot/cogs/moderation/test_infractions.py +++ b/tests/bot/cogs/moderation/test_infractions.py @@ -2,7 +2,7 @@ import textwrap import unittest from unittest.mock import AsyncMock, Mock, patch -from bot.cogs.moderation.infractions import Infractions +from bot.cogs.moderation.infraction.infractions import Infractions from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index 70aea2bab..84d036405 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -6,7 +6,7 @@ import discord from bot import constants from bot.api import ResponseCodeError -from bot.cogs.sync.syncers import Syncer, _Diff +from bot.cogs.backend.sync import Syncer, _Diff from tests import helpers diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index 120bc991d..ea7d090ba 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -5,8 +5,8 @@ import discord from bot import constants from bot.api import ResponseCodeError -from bot.cogs import sync -from bot.cogs.sync.syncers import Syncer +from bot.cogs.backend import sync +from bot.cogs.backend.sync import Syncer from tests import helpers from tests.base import CommandTestCase diff --git a/tests/bot/cogs/sync/test_roles.py b/tests/bot/cogs/sync/test_roles.py index 79eee98f4..888c49ca8 100644 --- a/tests/bot/cogs/sync/test_roles.py +++ b/tests/bot/cogs/sync/test_roles.py @@ -3,7 +3,7 @@ from unittest import mock import discord -from bot.cogs.sync.syncers import RoleSyncer, _Diff, _Role +from bot.cogs.backend.sync import RoleSyncer, _Diff, _Role from tests import helpers diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py index 002a947ad..71f4b134c 100644 --- a/tests/bot/cogs/sync/test_users.py +++ b/tests/bot/cogs/sync/test_users.py @@ -1,7 +1,7 @@ import unittest from unittest import mock -from bot.cogs.sync.syncers import UserSyncer, _Diff, _User +from bot.cogs.backend.sync import UserSyncer, _Diff, _User from tests import helpers diff --git a/tests/bot/cogs/test_antimalware.py b/tests/bot/cogs/test_antimalware.py index ecb7abf00..b00211f47 100644 --- a/tests/bot/cogs/test_antimalware.py +++ b/tests/bot/cogs/test_antimalware.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, Mock from discord import NotFound -from bot.cogs import antimalware +from bot.cogs.filters import antimalware from bot.constants import Channels, STAFF_ROLES from tests.helpers import MockAttachment, MockBot, MockMessage, MockRole diff --git a/tests/bot/cogs/test_antispam.py b/tests/bot/cogs/test_antispam.py index ce5472c71..8a3d8d02e 100644 --- a/tests/bot/cogs/test_antispam.py +++ b/tests/bot/cogs/test_antispam.py @@ -1,6 +1,6 @@ import unittest -from bot.cogs import antispam +from bot.cogs.filters import antispam class AntispamConfigurationValidationTests(unittest.TestCase): diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index 79c0e0ad3..305a2bad9 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -6,7 +6,7 @@ import unittest.mock import discord from bot import constants -from bot.cogs import information +from bot.cogs.info import information from bot.utils.checks import InWhitelistCheckFailure from tests import helpers diff --git a/tests/bot/cogs/test_security.py b/tests/bot/cogs/test_security.py index 9d1a62f7e..82679f69c 100644 --- a/tests/bot/cogs/test_security.py +++ b/tests/bot/cogs/test_security.py @@ -3,7 +3,7 @@ from unittest.mock import MagicMock from discord.ext.commands import NoPrivateMessage -from bot.cogs import security +from bot.cogs.filters import security from tests.helpers import MockBot, MockContext diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index 343e37db9..c7bac3ab3 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -6,8 +6,8 @@ from unittest.mock import AsyncMock, MagicMock, Mock, call, create_autospec, pat from discord.ext import commands from bot import constants -from bot.cogs import snekbox -from bot.cogs.snekbox import Snekbox +from bot.cogs.utils import snekbox +from bot.cogs.utils.snekbox import Snekbox from tests.helpers import MockBot, MockContext, MockMessage, MockReaction, MockUser diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index 3349caa73..e33f3af38 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -6,9 +6,9 @@ from unittest.mock import MagicMock from discord import Colour, NotFound from bot import constants -from bot.cogs import token_remover +from bot.cogs.filters import token_remover +from bot.cogs.filters.token_remover import Token, TokenRemover from bot.cogs.moderation import ModLog -from bot.cogs.token_remover import Token, TokenRemover from tests.helpers import MockBot, MockMessage, autospec -- cgit v1.2.3