diff options
33 files changed, 552 insertions, 239 deletions
| diff --git a/bot/__main__.py b/bot/__main__.py index aa1d1aee8..4e0d4a111 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -3,7 +3,9 @@ import logging  import discord  import sentry_sdk  from discord.ext.commands import when_mentioned_or +from sentry_sdk.integrations.aiohttp import AioHttpIntegration  from sentry_sdk.integrations.logging import LoggingIntegration +from sentry_sdk.integrations.redis import RedisIntegration  from bot import constants, patches  from bot.bot import Bot @@ -15,7 +17,11 @@ sentry_logging = LoggingIntegration(  sentry_sdk.init(      dsn=constants.Bot.sentry_dsn, -    integrations=[sentry_logging] +    integrations=[ +        sentry_logging, +        AioHttpIntegration(), +        RedisIntegration(), +    ]  )  bot = Bot( diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py index 66b5073e8..ea257442e 100644 --- a/bot/cogs/antimalware.py +++ b/bot/cogs/antimalware.py @@ -1,4 +1,5 @@  import logging +import typing as t  from os.path import splitext  from discord import Embed, Message, NotFound @@ -9,6 +10,27 @@ from bot.constants import AntiMalware as AntiMalwareConfig, Channels, STAFF_ROLE  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}). " +    f"We currently allow the following file types: **{', '.join(AntiMalwareConfig.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.""" @@ -29,34 +51,20 @@ class AntiMalware(Cog):              return          embed = Embed() -        file_extensions = {splitext(attachment.filename.lower())[1] for attachment in message.attachments} -        extensions_blocked = file_extensions - set(AntiMalwareConfig.whitelist) +        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 = ( -                "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}" -            ) +            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 = ( -                "**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 " -                f"{cmd_channel.mention} for more information) or use a pasting service like: " -                f"\n\n{URLs.site_schema}{URLs.site_paste}" -            ) +            embed.description = TXT_EMBED_DESCRIPTION.format(cmd_channel_mention=cmd_channel.mention)          elif extensions_blocked: -            whitelisted_types = ', '.join(AntiMalwareConfig.whitelist)              meta_channel = self.bot.get_channel(Channels.meta) -            embed.description = ( -                f"It looks like you tried to attach file type(s) that we do not allow ({blocked_extensions_str}). " -                f"We currently allow the following file types: **{whitelisted_types}**.\n\n" -                f"Feel free to ask in {meta_channel.mention} if you think this is a mistake." +            embed.description = DISALLOWED_EMBED_DESCRIPTION.format( +                blocked_extensions_str=blocked_extensions_str, +                meta_channel_mention=meta_channel.mention,              )          if embed.description: @@ -73,6 +81,13 @@ class AntiMalware(Cog):              except NotFound:                  log.info(f"Tried to delete message `{message.id}`, but message could not be found.") +    @classmethod +    def get_disallowed_extensions(cls, 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(AntiMalwareConfig.whitelist) +        return extensions_blocked +  def setup(bot: Bot) -> None:      """Load the AntiMalware cog.""" diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py index b5d9132cb..368d91c85 100644 --- a/bot/cogs/clean.py +++ b/bot/cogs/clean.py @@ -1,16 +1,16 @@  import logging  import random  import re -from typing import Optional +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 +    Channels, CleanMessages, Colours, Event, Icons, MODERATION_ROLES, NEGATIVE_REPLIES  )  from bot.decorators import with_role @@ -41,10 +41,10 @@ class Clean(Cog):          self,          amount: int,          ctx: Context, +        channels: Iterable[TextChannel],          bots_only: bool = False,          user: User = None,          regex: Optional[str] = None, -        channel: Optional[TextChannel] = None      ) -> None:          """A helper function that does the actual message cleaning."""          def predicate_bots_only(message: Message) -> bool: @@ -110,48 +110,39 @@ class Clean(Cog):              predicate = None                     # Delete all messages          # Default to using the invoking context's channel -        if not channel: -            channel = ctx.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() -        # Look through the history and retrieve message data          messages = []          message_ids = []          self.cleaning = True -        invocation_deleted = False - -        # To account for the invocation message, we index `amount + 1` messages. -        async for message in channel.history(limit=amount + 1): -            # If at any point the cancel command is invoked, we should stop. -            if not self.cleaning: -                return +        # 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): -            # Always start by deleting the invocation -            if not invocation_deleted: -                self.mod_log.ignore(Event.message_delete, message.id) -                await message.delete() -                invocation_deleted = True -                continue +                # If at any point the cancel command is invoked, we should stop. +                if not self.cleaning: +                    return -            # If the message passes predicate, let's save it. -            if predicate is None or predicate(message): -                message_ids.append(message.id) -                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 -        # We should ignore the ID's we stored, so we don't get mod-log spam. +        # Now let's delete the actual messages with purge.          self.mod_log.ignore(Event.message_delete, *message_ids) - -        # Use bulk delete to actually do the cleaning. It's far faster. -        await channel.purge( -            limit=amount, -            check=predicate -        ) +        for channel in channels: +            messages += await channel.purge(limit=amount, check=predicate)          # Reverse the list to restore chronological order          if messages: -            messages = list(reversed(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! @@ -163,8 +154,10 @@ class Clean(Cog):              return          # Build the embed and send it +        target_channels = ", ".join(channel.mention for channel in channels) +          message = ( -            f"**{len(message_ids)}** messages deleted in <#{channel.id}> by **{ctx.author.name}**\n\n" +            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})."          ) @@ -189,10 +182,10 @@ class Clean(Cog):          ctx: Context,          user: User,          amount: Optional[int] = 10, -        channel: TextChannel = None +        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, channel=channel) +        await self._clean_messages(amount, ctx, user=user, channels=channels)      @clean_group.command(name="all", aliases=["everything"])      @with_role(*MODERATION_ROLES) @@ -200,10 +193,10 @@ class Clean(Cog):          self,          ctx: Context,          amount: Optional[int] = 10, -        channel: TextChannel = None +        channels: commands.Greedy[TextChannel] = None      ) -> None:          """Delete all messages, regardless of poster, stop cleaning after traversing `amount` messages.""" -        await self._clean_messages(amount, ctx, channel=channel) +        await self._clean_messages(amount, ctx, channels=channels)      @clean_group.command(name="bots", aliases=["bot"])      @with_role(*MODERATION_ROLES) @@ -211,10 +204,10 @@ class Clean(Cog):          self,          ctx: Context,          amount: Optional[int] = 10, -        channel: TextChannel = None +        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, channel=channel) +        await self._clean_messages(amount, ctx, bots_only=True, channels=channels)      @clean_group.command(name="regex", aliases=["word", "expression"])      @with_role(*MODERATION_ROLES) @@ -223,10 +216,10 @@ class Clean(Cog):          ctx: Context,          regex: str,          amount: Optional[int] = 10, -        channel: TextChannel = None +        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, channel=channel) +        await self._clean_messages(amount, ctx, regex=regex, channels=channels)      @clean_group.command(name="stop", aliases=["cancel", "abort"])      @with_role(*MODERATION_ROLES) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index d2a55fba6..70cef339a 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -24,18 +24,8 @@ ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/"  MAX_CHANNELS_PER_CATEGORY = 50  EXCLUDED_CHANNELS = (constants.Channels.how_to_get_help,) -AVAILABLE_TOPIC = """ -This channel is available. Feel free to ask a question in order to claim this channel! -""" - -IN_USE_TOPIC = """ -This channel is currently in use. If you'd like to discuss a different problem, please claim a new \ -channel from the Help: Available category. -""" - -DORMANT_TOPIC = """ -This channel is temporarily archived. If you'd like to ask a question, please use one of the \ -channels in the Help: Available category. +HELP_CHANNEL_TOPIC = """ +This is a Python help channel. You can claim your own help channel in the Python Help: Available category.  """  AVAILABLE_MSG = f""" @@ -64,11 +54,6 @@ question to maximize your chance of getting a good answer. If you're not sure ho  through our guide for [asking a good question]({ASKING_GUIDE_URL}).  """ -AVAILABLE_EMOJI = "✅" -IN_USE_ANSWERED_EMOJI = "⌛" -IN_USE_UNANSWERED_EMOJI = "⏳" -NAME_SEPARATOR = "|" -  CoroutineFunc = t.Callable[..., t.Coroutine] @@ -196,7 +181,7 @@ class HelpChannels(Scheduler, commands.Cog):              return None          log.debug(f"Creating a new dormant channel named {name}.") -        return await self.dormant_category.create_text_channel(name) +        return await self.dormant_category.create_text_channel(name, topic=HELP_CHANNEL_TOPIC)      def create_name_queue(self) -> deque:          """Return a queue of element names to use for creating new channels.""" @@ -542,8 +527,6 @@ class HelpChannels(Scheduler, commands.Cog):          await self.move_to_bottom_position(              channel=channel,              category_id=constants.Categories.help_available, -            name=f"{AVAILABLE_EMOJI}{NAME_SEPARATOR}{self.get_clean_channel_name(channel)}", -            topic=AVAILABLE_TOPIC,          )          self.report_stats() @@ -559,8 +542,6 @@ class HelpChannels(Scheduler, commands.Cog):          await self.move_to_bottom_position(              channel=channel,              category_id=constants.Categories.help_dormant, -            name=self.get_clean_channel_name(channel), -            topic=DORMANT_TOPIC,          )          self.bot.stats.incr(f"help.dormant_calls.{caller}") @@ -593,8 +574,6 @@ class HelpChannels(Scheduler, commands.Cog):          await self.move_to_bottom_position(              channel=channel,              category_id=constants.Categories.help_in_use, -            name=f"{IN_USE_UNANSWERED_EMOJI}{NAME_SEPARATOR}{self.get_clean_channel_name(channel)}", -            topic=IN_USE_TOPIC,          )          timeout = constants.HelpChannels.idle_minutes * 60 @@ -660,18 +639,16 @@ class HelpChannels(Scheduler, commands.Cog):              # Check if there is an entry in unanswered (does not persist across restarts)              if channel.id in self.unanswered: -                claimant_id = self.help_channel_claimants[channel].id +                claimant = self.help_channel_claimants.get(channel) +                if not claimant: +                    # The mapping for this channel was lost, we can't do anything. +                    return                  # Check the message did not come from the claimant -                if claimant_id != message.author.id: +                if claimant.id != message.author.id:                      # Mark the channel as answered                      self.unanswered[channel.id] = False -                    # Change the emoji in the channel name to signify activity -                    log.trace(f"#{channel} ({channel.id}) has been answered; changing its emoji") -                    name = self.get_clean_channel_name(channel) -                    await channel.edit(name=f"{IN_USE_ANSWERED_EMOJI}{NAME_SEPARATOR}{name}") -      @commands.Cog.listener()      async def on_message(self, message: discord.Message) -> None:          """Move an available channel to the In Use category and replace it with a dormant one.""" diff --git a/bot/cogs/information.py b/bot/cogs/information.py index f0eb3a1ea..f0bd1afdb 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -6,7 +6,8 @@ from collections import Counter, defaultdict  from string import Template  from typing import Any, Mapping, Optional, Union -from discord import Colour, Embed, Member, Message, Role, Status, utils +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 @@ -26,6 +27,49 @@ class Information(Cog):      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: @@ -102,15 +146,16 @@ class Information(Cog):          roles = len(ctx.guild.roles)          member_count = ctx.guild.member_count - -        # How many of each type of channel? -        channels = Counter(c.type for c in ctx.guild.channels) -        channel_counts = "".join(sorted(f"{str(ch).title()} channels: {channels[ch]}\n" for ch in channels)).strip() +        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 @@ -122,12 +167,16 @@ class Information(Cog):                  Voice region: {region}                  Features: {features} -                **Counts** +                **Channel counts** +                $channel_counts +                Staff channels: {staff_channel_count} + +                **Member counts**                  Members: {member_count:,} +                Staff members: {staff_member_count}                  Roles: {roles} -                $channel_counts -                **Members** +                **Member statuses**                  {constants.Emojis.status_online} {statuses[Status.online]:,}                  {constants.Emojis.status_idle} {statuses[Status.idle]:,}                  {constants.Emojis.status_dnd} {statuses[Status.dnd]:,} diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index e62a36c43..5bfaad796 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -1,4 +1,5 @@  import logging +import textwrap  import typing as t  import discord @@ -225,7 +226,7 @@ class Infractions(InfractionScheduler, commands.Cog):          self.mod_log.ignore(Event.member_remove, user.id) -        action = user.kick(reason=reason) +        action = user.kick(reason=textwrap.shorten(reason, width=512, placeholder="..."))          await self.apply_infraction(ctx, infraction, user, action)      @respect_role_hierarchy() @@ -258,7 +259,9 @@ class Infractions(InfractionScheduler, commands.Cog):          self.mod_log.ignore(Event.member_remove, user.id) -        action = ctx.guild.ban(user, reason=reason, delete_message_days=0) +        truncated_reason = textwrap.shorten(reason, width=512, placeholder="...") + +        action = ctx.guild.ban(user, reason=truncated_reason, delete_message_days=0)          await self.apply_infraction(ctx, infraction, user, action)          if infraction.get('expires_at') is not None: diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index beef7a8ef..41472c64c 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -98,7 +98,10 @@ class ModLog(Cog, name="ModLog"):          footer: t.Optional[str] = None,      ) -> Context:          """Generate log embed and send to logging channel.""" -        embed = discord.Embed(description=text) +        # Truncate string directly here to avoid removing newlines +        embed = discord.Embed( +            description=text[:2045] + "..." if len(text) > 2048 else text +        )          if title and icon_url:              embed.set_author(name=title, icon_url=icon_url) @@ -552,6 +555,10 @@ class ModLog(Cog, name="ModLog"):          channel = message.channel          author = message.author +        # Ignore DMs. +        if not message.guild: +            return +          if message.guild.id != GuildConstant.id or channel.id in GuildConstant.modlog_blacklist:              return diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index 012432e60..b03d89537 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -101,11 +101,16 @@ class InfractionScheduler(Scheduler):          dm_result = ""          dm_log_text = "" -        expiry_log_text = f"Expires: {expiry}" if expiry else "" +        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**" @@ -127,7 +132,7 @@ class InfractionScheduler(Scheduler):                  f"Infraction #{id_} actor is bot; including the reason in the confirmation message."              ) -            end_msg = f" (reason: {infraction['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." @@ -164,12 +169,23 @@ class InfractionScheduler(Scheduler):                      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_type}** to {user.mention}{expiry_msg}{end_msg}." -        ) +        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_}.") @@ -180,9 +196,8 @@ class InfractionScheduler(Scheduler):              thumbnail=user.avatar_url_as(static_format="png"),              text=textwrap.dedent(f"""                  Member: {user.mention} (`{user.id}`) -                Actor: {ctx.message.author}{dm_log_text} +                Actor: {ctx.message.author}{dm_log_text}{expiry_log_text}                  Reason: {reason} -                {expiry_log_text}              """),              content=log_content,              footer=f"ID {infraction['id']}" @@ -294,6 +309,9 @@ class InfractionScheduler(Scheduler):                  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], @@ -407,6 +425,9 @@ class InfractionScheduler(Scheduler):              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], @@ -416,7 +437,6 @@ class InfractionScheduler(Scheduler):                  text="\n".join(f"{k}: {v}" for k, v in log_text.items()),                  footer=f"ID: {id_}",                  content=log_content, -              )          return log_text diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index 29855c325..45a010f00 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -183,10 +183,10 @@ class Superstarify(InfractionScheduler, Cog):              text=textwrap.dedent(f"""                  Member: {member.mention} (`{member.id}`)                  Actor: {ctx.message.author} -                Reason: {reason}                  Expires: {expiry_str}                  Old nickname: `{old_nick}`                  New nickname: `{forced_nick}` +                Reason: {reason}              """),              footer=f"ID {id_}"          ) diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index e4e0f1ec2..fb55287b6 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -41,7 +41,6 @@ async def post_user(ctx: Context, user: UserSnowflake) -> t.Optional[dict]:          log.debug("The user being added to the DB is not a Member or User object.")      payload = { -        'avatar_hash': getattr(user, 'avatar', 0),          'discriminator': int(getattr(user, 'discriminator', 0)),          'id': user.id,          'in_guild': False, @@ -143,12 +142,14 @@ async def notify_infraction(      """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.dedent(f""" -            **Type:** {infr_type.capitalize()} -            **Expires:** {expires_at or "N/A"} -            **Reason:** {reason or "No reason provided."} -            """), +        description=textwrap.shorten(text, width=2048, placeholder="..."),          colour=Colours.soft_red      ) diff --git a/bot/cogs/python_news.py b/bot/cogs/python_news.py index d28af4a0b..d15d0371e 100644 --- a/bot/cogs/python_news.py +++ b/bot/cogs/python_news.py @@ -153,6 +153,7 @@ class PythonNews(Cog):                  if (                          thread_information["thread_id"] in existing_news["data"][maillist] +                        or 'Re: ' in thread_information["subject"]                          or new_date.date() < date.today()                  ):                      continue diff --git a/bot/cogs/site.py b/bot/cogs/site.py index 7fc2a9c34..e61cd5003 100644 --- a/bot/cogs/site.py +++ b/bot/cogs/site.py @@ -133,6 +133,9 @@ class Site(Cog):              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) diff --git a/bot/cogs/stats.py b/bot/cogs/stats.py index 4ebb6423c..d42f55466 100644 --- a/bot/cogs/stats.py +++ b/bot/cogs/stats.py @@ -36,7 +36,8 @@ class Stats(Cog):          if message.guild.id != Guild.id:              return -        if message.channel.category.id == Categories.modmail: +        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. diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index 5708be3f4..7cc3726b2 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -94,7 +94,6 @@ class Sync(Cog):          the database, the user is added.          """          packed = { -            'avatar_hash': member.avatar,              'discriminator': int(member.discriminator),              'id': member.id,              'in_guild': True, @@ -135,12 +134,11 @@ class Sync(Cog):      @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", "avatar") +        attrs = ("name", "discriminator")          if any(getattr(before, attr) != getattr(after, attr) for attr in attrs):              updated_information = {                  "name": after.name,                  "discriminator": int(after.discriminator), -                "avatar_hash": after.avatar,              }              await self.patch_user(after.id, updated_information=updated_information) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index e55bf27fd..536455668 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -17,7 +17,7 @@ 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', 'avatar_hash', 'roles', 'in_guild')) +_User = namedtuple('User', ('id', 'name', 'discriminator', 'roles', 'in_guild'))  _Diff = namedtuple('Diff', ('created', 'updated', 'deleted')) @@ -298,7 +298,6 @@ class UserSyncer(Syncer):                  id=member.id,                  name=member.name,                  discriminator=int(member.discriminator), -                avatar_hash=member.avatar,                  roles=tuple(sorted(role.id for role in member.roles)),                  in_guild=True              ) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index bc7f53f68..6f03a3475 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -44,7 +44,7 @@ class Tags(Cog):                  tag = {                      "title": tag_title,                      "embed": { -                        "description": file.read_text(), +                        "description": file.read_text(encoding="utf8"),                      },                      "restricted_to": "developers",                  } diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 99be3cdaa..ae156cf70 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -1,9 +1,7 @@  import logging  from contextlib import suppress -from datetime import datetime  from discord import Colour, Forbidden, Message, NotFound, Object -from discord.ext import tasks  from discord.ext.commands import Cog, Context, command  from bot import constants @@ -34,14 +32,6 @@ If you'd like to unsubscribe from the announcement notifications, simply send `!  <#{constants.Channels.bot_commands}>.  """ -if constants.DEBUG_MODE: -    PERIODIC_PING = "Periodic checkpoint message successfully sent." -else: -    PERIODIC_PING = ( -        f"@everyone To verify that you have read our rules, please type `{constants.Bot.prefix}accept`." -        " If you encounter any problems during the verification process, " -        f"send a direct message to a staff member." -    )  BOT_MESSAGE_DELETE_DELAY = 10 @@ -50,7 +40,6 @@ class Verification(Cog):      def __init__(self, bot: Bot):          self.bot = bot -        self.periodic_ping.start()      @property      def mod_log(self) -> ModLog: @@ -65,9 +54,7 @@ class Verification(Cog):          if message.author.bot:              # They're a bot, delete their message after the delay. -            # But not the periodic ping; we like that one. -            if message.content != PERIODIC_PING: -                await message.delete(delay=BOT_MESSAGE_DELETE_DELAY) +            await message.delete(delay=BOT_MESSAGE_DELETE_DELAY)              return          # if a user mentions a role or guild member @@ -198,34 +185,6 @@ class Verification(Cog):          else:              return True -    @tasks.loop(hours=12) -    async def periodic_ping(self) -> None: -        """Every week, mention @everyone to remind them to verify.""" -        messages = self.bot.get_channel(constants.Channels.verification).history(limit=10) -        need_to_post = True  # True if a new message needs to be sent. - -        async for message in messages: -            if message.author == self.bot.user and message.content == PERIODIC_PING: -                delta = datetime.utcnow() - message.created_at  # Time since last message. -                if delta.days >= 7:  # Message is older than a week. -                    await message.delete() -                else: -                    need_to_post = False - -                break - -        if need_to_post: -            await self.bot.get_channel(constants.Channels.verification).send(PERIODIC_PING) - -    @periodic_ping.before_loop -    async def before_ping(self) -> None: -        """Only start the loop when the bot is ready.""" -        await self.bot.wait_until_guild_available() - -    def cog_unload(self) -> None: -        """Cancel the periodic ping task when the cog is unloaded.""" -        self.periodic_ping.cancel() -  def setup(bot: Bot) -> None:      """Load the Verification cog.""" diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index e4fb173e0..702d371f4 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -1,4 +1,5 @@  import logging +import textwrap  from collections import ChainMap  from discord.ext.commands import Cog, Context, group @@ -97,8 +98,8 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):              if len(history) > 1:                  total = f"({len(history) // 2} previous infractions in total)" -                end_reason = history[0]["reason"] -                start_reason = f"Watched: {history[1]['reason']}" +                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." diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index cd9c7e555..14547105f 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -106,8 +106,8 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):          if history:              total = f"({len(history)} previous nominations in total)" -            start_reason = f"Watched: {history[0]['reason']}" -            end_reason = f"Unwatched: {history[0]['end_reason']}" +            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) @@ -224,7 +224,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):                  Status: **Active**                  Date: {start_date}                  Actor: {actor.mention if actor else actor_id} -                Reason: {nomination_object["reason"]} +                Reason: {textwrap.shorten(nomination_object["reason"], width=200, placeholder="...")}                  Nomination ID: `{nomination_object["id"]}`                  ===============                  """ @@ -237,10 +237,10 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):                  Status: Inactive                  Date: {start_date}                  Actor: {actor.mention if actor else actor_id} -                Reason: {nomination_object["reason"]} +                Reason: {textwrap.shorten(nomination_object["reason"], width=200, placeholder="...")}                  End date: {end_date} -                Unwatch reason: {nomination_object["end_reason"]} +                Unwatch reason: {textwrap.shorten(nomination_object["end_reason"], width=200, placeholder="...")}                  Nomination ID: `{nomination_object["id"]}`                  ===============                  """ diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index 643cd46e4..436778c46 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -280,8 +280,9 @@ class WatchChannel(metaclass=CogABCMeta):          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=f"Added {time_delta} by {actor} | Reason: {reason}") +        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) diff --git a/bot/converters.py b/bot/converters.py index 72c46fdf0..4deb59f87 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -217,7 +217,10 @@ class Duration(Converter):          delta = relativedelta(**duration_dict)          now = datetime.utcnow() -        return now + delta +        try: +            return now + delta +        except ValueError: +            raise BadArgument(f"`{duration}` results in a datetime outside the supported range.")  class ISODateTime(Converter): diff --git a/bot/resources/tags/free.md b/bot/resources/tags/free.md index 582cca9da..1493076c7 100644 --- a/bot/resources/tags/free.md +++ b/bot/resources/tags/free.md @@ -1,5 +1,5 @@  **We have a new help channel system!** -We recently moved to a new help channel system. You can now use any channel in the **<#691405807388196926>** category to ask your question. +Please see <#704250143020417084> for further information. -For more information, check out [our website](https://pythondiscord.com/pages/resources/guides/help-channels/). +A more detailed guide can be found on [our website](https://pythondiscord.com/pages/resources/guides/help-channels/). diff --git a/bot/resources/tags/modmail.md b/bot/resources/tags/modmail.md new file mode 100644 index 000000000..7545419ee --- /dev/null +++ b/bot/resources/tags/modmail.md @@ -0,0 +1,9 @@ +**Contacting the moderation team via ModMail** + +<@!683001325440860340> is a bot that will relay your messages to our moderation team, so that you can start a conversation with the moderation team. Your messages will be relayed to the entire moderator team, who will be able to respond to you via the bot. + +It supports attachments, codeblocks, and reactions. As communication happens over direct messages, the conversation will stay between you and the mod team. + +**To use it, simply send a direct message to the bot.** + +Should there be an urgent and immediate need for a moderator or admin to look at a channel, feel free to ping the <@&267629731250176001> or <@&267628507062992896> role instead. diff --git a/config-default.yml b/config-default.yml index 2c85f5ef3..3a1bdae54 100644 --- a/config-default.yml +++ b/config-default.yml @@ -297,6 +297,7 @@ filter:          - 613425648685547541  # Discord Developers          - 185590609631903755  # Blender Hub          - 420324994703163402  # /r/FlutterDev +        - 488751051629920277  # Python Atlanta      domain_blacklist:          - pornhub.com diff --git a/docker-compose.yml b/docker-compose.yml index 9884e35f0..cff7d33d6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,14 @@ services:      ports:        - "127.0.0.1:6379:6379" +  snekbox: +    image: pythondiscord/snekbox:latest +    init: true +    ipc: none +    ports: +     - "127.0.0.1:8060:8060" +    privileged: true +    web:      image: pythondiscord/site:latest      command: ["run", "--debug"] @@ -47,6 +55,7 @@ services:      depends_on:        - web        - redis +      - snekbox      environment:        BOT_TOKEN: ${BOT_TOKEN}        BOT_API_KEY: badbot13m0n8f570f942013fc818f234916ca531 diff --git a/tests/bot/cogs/moderation/test_infractions.py b/tests/bot/cogs/moderation/test_infractions.py new file mode 100644 index 000000000..da4e92ccc --- /dev/null +++ b/tests/bot/cogs/moderation/test_infractions.py @@ -0,0 +1,55 @@ +import textwrap +import unittest +from unittest.mock import AsyncMock, Mock, patch + +from bot.cogs.moderation.infractions import Infractions +from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole + + +class TruncationTests(unittest.IsolatedAsyncioTestCase): +    """Tests for ban and kick command reason truncation.""" + +    def setUp(self): +        self.bot = MockBot() +        self.cog = Infractions(self.bot) +        self.user = MockMember(id=1234, top_role=MockRole(id=3577, position=10)) +        self.target = MockMember(id=1265, top_role=MockRole(id=9876, position=0)) +        self.guild = MockGuild(id=4567) +        self.ctx = MockContext(bot=self.bot, author=self.user, guild=self.guild) + +    @patch("bot.cogs.moderation.utils.get_active_infraction") +    @patch("bot.cogs.moderation.utils.post_infraction") +    async def test_apply_ban_reason_truncation(self, post_infraction_mock, get_active_mock): +        """Should truncate reason for `ctx.guild.ban`.""" +        get_active_mock.return_value = None +        post_infraction_mock.return_value = {"foo": "bar"} + +        self.cog.apply_infraction = AsyncMock() +        self.bot.get_cog.return_value = AsyncMock() +        self.cog.mod_log.ignore = Mock() +        self.ctx.guild.ban = Mock() + +        await self.cog.apply_ban(self.ctx, self.target, "foo bar" * 3000) +        self.ctx.guild.ban.assert_called_once_with( +            self.target, +            reason=textwrap.shorten("foo bar" * 3000, 512, placeholder="..."), +            delete_message_days=0 +        ) +        self.cog.apply_infraction.assert_awaited_once_with( +            self.ctx, {"foo": "bar"}, self.target, self.ctx.guild.ban.return_value +        ) + +    @patch("bot.cogs.moderation.utils.post_infraction") +    async def test_apply_kick_reason_truncation(self, post_infraction_mock): +        """Should truncate reason for `Member.kick`.""" +        post_infraction_mock.return_value = {"foo": "bar"} + +        self.cog.apply_infraction = AsyncMock() +        self.cog.mod_log.ignore = Mock() +        self.target.kick = Mock() + +        await self.cog.apply_kick(self.ctx, self.target, "foo bar" * 3000) +        self.target.kick.assert_called_once_with(reason=textwrap.shorten("foo bar" * 3000, 512, placeholder="...")) +        self.cog.apply_infraction.assert_awaited_once_with( +            self.ctx, {"foo": "bar"}, self.target, self.target.kick.return_value +        ) diff --git a/tests/bot/cogs/moderation/test_modlog.py b/tests/bot/cogs/moderation/test_modlog.py new file mode 100644 index 000000000..f2809f40a --- /dev/null +++ b/tests/bot/cogs/moderation/test_modlog.py @@ -0,0 +1,29 @@ +import unittest + +import discord + +from bot.cogs.moderation.modlog import ModLog +from tests.helpers import MockBot, MockTextChannel + + +class ModLogTests(unittest.IsolatedAsyncioTestCase): +    """Tests for moderation logs.""" + +    def setUp(self): +        self.bot = MockBot() +        self.cog = ModLog(self.bot) +        self.channel = MockTextChannel() + +    async def test_log_entry_description_truncation(self): +        """Test that embed description for ModLog entry is truncated.""" +        self.bot.get_channel.return_value = self.channel +        await self.cog.send_log_message( +            icon_url="foo", +            colour=discord.Colour.blue(), +            title="bar", +            text="foo bar" * 3000 +        ) +        embed = self.channel.send.call_args[1]["embed"] +        self.assertEqual( +            embed.description, ("foo bar" * 3000)[:2045] + "..." +        ) diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index 81398c61f..14fd909c4 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -247,14 +247,12 @@ class SyncCogListenerTests(SyncCogTestCase):          before_data = {              "name": "old name",              "discriminator": "1234", -            "avatar": "old avatar",              "bot": False,          }          subtests = (              (True, "name", "name", "new name", "new name"),              (True, "discriminator", "discriminator", "8765", 8765), -            (True, "avatar", "avatar_hash", "9j2e9", "9j2e9"),              (False, "bot", "bot", True, True),          ) @@ -295,7 +293,6 @@ class SyncCogListenerTests(SyncCogTestCase):          )          data = { -            "avatar_hash": member.avatar,              "discriminator": int(member.discriminator),              "id": member.id,              "in_guild": True, diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py index 818883012..002a947ad 100644 --- a/tests/bot/cogs/sync/test_users.py +++ b/tests/bot/cogs/sync/test_users.py @@ -10,7 +10,6 @@ def fake_user(**kwargs):      kwargs.setdefault("id", 43)      kwargs.setdefault("name", "bob the test man")      kwargs.setdefault("discriminator", 1337) -    kwargs.setdefault("avatar_hash", None)      kwargs.setdefault("roles", (666,))      kwargs.setdefault("in_guild", True) @@ -32,7 +31,6 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase):          for member in members:              member = member.copy() -            member["avatar"] = member.pop("avatar_hash")              del member["in_guild"]              mock_member = helpers.MockMember(**member) diff --git a/tests/bot/cogs/test_antimalware.py b/tests/bot/cogs/test_antimalware.py new file mode 100644 index 000000000..f219fc1ba --- /dev/null +++ b/tests/bot/cogs/test_antimalware.py @@ -0,0 +1,159 @@ +import unittest +from unittest.mock import AsyncMock, Mock, patch + +from discord import NotFound + +from bot.cogs import antimalware +from bot.constants import AntiMalware as AntiMalwareConfig, Channels, STAFF_ROLES +from tests.helpers import MockAttachment, MockBot, MockMessage, MockRole + +MODULE = "bot.cogs.antimalware" + + +@patch(f"{MODULE}.AntiMalwareConfig.whitelist", new=[".first", ".second", ".third"]) +class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase): +    """Test the AntiMalware cog.""" + +    def setUp(self): +        """Sets up fresh objects for each test.""" +        self.bot = MockBot() +        self.cog = antimalware.AntiMalware(self.bot) +        self.message = MockMessage() + +    async def test_message_with_allowed_attachment(self): +        """Messages with allowed extensions should not be deleted""" +        attachment = MockAttachment(filename=f"python{AntiMalwareConfig.whitelist[0]}") +        self.message.attachments = [attachment] + +        await self.cog.on_message(self.message) +        self.message.delete.assert_not_called() + +    async def test_message_without_attachment(self): +        """Messages without attachments should result in no action.""" +        await self.cog.on_message(self.message) +        self.message.delete.assert_not_called() + +    async def test_direct_message_with_attachment(self): +        """Direct messages should have no action taken.""" +        attachment = MockAttachment(filename="python.disallowed") +        self.message.attachments = [attachment] +        self.message.guild = None + +        await self.cog.on_message(self.message) + +        self.message.delete.assert_not_called() + +    async def test_message_with_illegal_extension_gets_deleted(self): +        """A message containing an illegal extension should send an embed.""" +        attachment = MockAttachment(filename="python.disallowed") +        self.message.attachments = [attachment] + +        await self.cog.on_message(self.message) + +        self.message.delete.assert_called_once() + +    async def test_message_send_by_staff(self): +        """A message send by a member of staff should be ignored.""" +        staff_role = MockRole(id=STAFF_ROLES[0]) +        self.message.author.roles.append(staff_role) +        attachment = MockAttachment(filename="python.disallowed") +        self.message.attachments = [attachment] + +        await self.cog.on_message(self.message) + +        self.message.delete.assert_not_called() + +    async def test_python_file_redirect_embed_description(self): +        """A message containing a .py file should result in an embed redirecting the user to our paste site""" +        attachment = MockAttachment(filename="python.py") +        self.message.attachments = [attachment] +        self.message.channel.send = AsyncMock() + +        await self.cog.on_message(self.message) +        self.message.channel.send.assert_called_once() +        args, kwargs = self.message.channel.send.call_args +        embed = kwargs.pop("embed") + +        self.assertEqual(embed.description, antimalware.PY_EMBED_DESCRIPTION) + +    async def test_txt_file_redirect_embed_description(self): +        """A message containing a .txt file should result in the correct embed.""" +        attachment = MockAttachment(filename="python.txt") +        self.message.attachments = [attachment] +        self.message.channel.send = AsyncMock() +        antimalware.TXT_EMBED_DESCRIPTION = Mock() +        antimalware.TXT_EMBED_DESCRIPTION.format.return_value = "test" + +        await self.cog.on_message(self.message) +        self.message.channel.send.assert_called_once() +        args, kwargs = self.message.channel.send.call_args +        embed = kwargs.pop("embed") +        cmd_channel = self.bot.get_channel(Channels.bot_commands) + +        self.assertEqual(embed.description, antimalware.TXT_EMBED_DESCRIPTION.format.return_value) +        antimalware.TXT_EMBED_DESCRIPTION.format.assert_called_with(cmd_channel_mention=cmd_channel.mention) + +    async def test_other_disallowed_extention_embed_description(self): +        """Test the description for a non .py/.txt disallowed extension.""" +        attachment = MockAttachment(filename="python.disallowed") +        self.message.attachments = [attachment] +        self.message.channel.send = AsyncMock() +        antimalware.DISALLOWED_EMBED_DESCRIPTION = Mock() +        antimalware.DISALLOWED_EMBED_DESCRIPTION.format.return_value = "test" + +        await self.cog.on_message(self.message) +        self.message.channel.send.assert_called_once() +        args, kwargs = self.message.channel.send.call_args +        embed = kwargs.pop("embed") +        meta_channel = self.bot.get_channel(Channels.meta) + +        self.assertEqual(embed.description, antimalware.DISALLOWED_EMBED_DESCRIPTION.format.return_value) +        antimalware.DISALLOWED_EMBED_DESCRIPTION.format.assert_called_with( +            blocked_extensions_str=".disallowed", +            meta_channel_mention=meta_channel.mention +        ) + +    async def test_removing_deleted_message_logs(self): +        """Removing an already deleted message logs the correct message""" +        attachment = MockAttachment(filename="python.disallowed") +        self.message.attachments = [attachment] +        self.message.delete = AsyncMock(side_effect=NotFound(response=Mock(status=""), message="")) + +        with self.assertLogs(logger=antimalware.log, level="INFO"): +            await self.cog.on_message(self.message) +        self.message.delete.assert_called_once() + +    async def test_message_with_illegal_attachment_logs(self): +        """Deleting a message with an illegal attachment should result in a log.""" +        attachment = MockAttachment(filename="python.disallowed") +        self.message.attachments = [attachment] + +        with self.assertLogs(logger=antimalware.log, level="INFO"): +            await self.cog.on_message(self.message) + +    async def test_get_disallowed_extensions(self): +        """The return value should include all non-whitelisted extensions.""" +        test_values = ( +            ([], []), +            (AntiMalwareConfig.whitelist, []), +            ([".first"], []), +            ([".first", ".disallowed"], [".disallowed"]), +            ([".disallowed"], [".disallowed"]), +            ([".disallowed", ".illegal"], [".disallowed", ".illegal"]), +        ) + +        for extensions, expected_disallowed_extensions in test_values: +            with self.subTest(extensions=extensions, expected_disallowed_extensions=expected_disallowed_extensions): +                self.message.attachments = [MockAttachment(filename=f"filename{extension}") for extension in extensions] +                disallowed_extensions = self.cog.get_disallowed_extensions(self.message) +                self.assertCountEqual(disallowed_extensions, expected_disallowed_extensions) + + +class AntiMalwareSetupTests(unittest.TestCase): +    """Tests setup of the `AntiMalware` cog.""" + +    def test_setup(self): +        """Setup of the extension should call add_cog.""" +        bot = MockBot() +        antimalware.setup(bot) +        bot.add_cog.assert_called_once() diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index aca6b594f..79c0e0ad3 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -148,14 +148,18 @@ class InformationCogTests(unittest.TestCase):                  Voice region: {self.ctx.guild.region}                  Features: {', '.join(self.ctx.guild.features)} -                **Counts** -                Members: {self.ctx.guild.member_count:,} -                Roles: {len(self.ctx.guild.roles)} +                **Channel counts**                  Category channels: 1                  Text channels: 1                  Voice channels: 1 +                Staff channels: 0 + +                **Member counts** +                Members: {self.ctx.guild.member_count:,} +                Staff members: 0 +                Roles: {len(self.ctx.guild.roles)} -                **Members** +                **Member statuses**                  {constants.Emojis.status_online} 2                  {constants.Emojis.status_idle} 1                  {constants.Emojis.status_dnd} 4 diff --git a/tests/bot/test_converters.py b/tests/bot/test_converters.py index ca8cb6825..c42111f3f 100644 --- a/tests/bot/test_converters.py +++ b/tests/bot/test_converters.py @@ -1,5 +1,5 @@ -import asyncio  import datetime +import re  import unittest  from unittest.mock import MagicMock, patch @@ -16,7 +16,7 @@ from bot.converters import (  ) -class ConverterTests(unittest.TestCase): +class ConverterTests(unittest.IsolatedAsyncioTestCase):      """Tests our custom argument converters."""      @classmethod @@ -26,7 +26,7 @@ class ConverterTests(unittest.TestCase):          cls.fixed_utc_now = datetime.datetime.fromisoformat('2019-01-01T00:00:00') -    def test_tag_content_converter_for_valid(self): +    async def test_tag_content_converter_for_valid(self):          """TagContentConverter should return correct values for valid input."""          test_values = (              ('hello', 'hello'), @@ -35,10 +35,10 @@ class ConverterTests(unittest.TestCase):          for content, expected_conversion in test_values:              with self.subTest(content=content, expected_conversion=expected_conversion): -                conversion = asyncio.run(TagContentConverter.convert(self.context, content)) +                conversion = await TagContentConverter.convert(self.context, content)                  self.assertEqual(conversion, expected_conversion) -    def test_tag_content_converter_for_invalid(self): +    async def test_tag_content_converter_for_invalid(self):          """TagContentConverter should raise the proper exception for invalid input."""          test_values = (              ('', "Tag contents should not be empty, or filled with whitespace."), @@ -47,10 +47,10 @@ class ConverterTests(unittest.TestCase):          for value, exception_message in test_values:              with self.subTest(tag_content=value, exception_message=exception_message): -                with self.assertRaises(BadArgument, msg=exception_message): -                    asyncio.run(TagContentConverter.convert(self.context, value)) +                with self.assertRaisesRegex(BadArgument, re.escape(exception_message)): +                    await TagContentConverter.convert(self.context, value) -    def test_tag_name_converter_for_valid(self): +    async def test_tag_name_converter_for_valid(self):          """TagNameConverter should return the correct values for valid tag names."""          test_values = (              ('tracebacks', 'tracebacks'), @@ -60,10 +60,10 @@ class ConverterTests(unittest.TestCase):          for name, expected_conversion in test_values:              with self.subTest(name=name, expected_conversion=expected_conversion): -                conversion = asyncio.run(TagNameConverter.convert(self.context, name)) +                conversion = await TagNameConverter.convert(self.context, name)                  self.assertEqual(conversion, expected_conversion) -    def test_tag_name_converter_for_invalid(self): +    async def test_tag_name_converter_for_invalid(self):          """TagNameConverter should raise the correct exception for invalid tag names."""          test_values = (              ('👋', "Don't be ridiculous, you can't use that character!"), @@ -75,29 +75,29 @@ class ConverterTests(unittest.TestCase):          for invalid_name, exception_message in test_values:              with self.subTest(invalid_name=invalid_name, exception_message=exception_message): -                with self.assertRaises(BadArgument, msg=exception_message): -                    asyncio.run(TagNameConverter.convert(self.context, invalid_name)) +                with self.assertRaisesRegex(BadArgument, re.escape(exception_message)): +                    await TagNameConverter.convert(self.context, invalid_name) -    def test_valid_python_identifier_for_valid(self): +    async def test_valid_python_identifier_for_valid(self):          """ValidPythonIdentifier returns valid identifiers unchanged."""          test_values = ('foo', 'lemon')          for name in test_values:              with self.subTest(identifier=name): -                conversion = asyncio.run(ValidPythonIdentifier.convert(self.context, name)) +                conversion = await ValidPythonIdentifier.convert(self.context, name)                  self.assertEqual(name, conversion) -    def test_valid_python_identifier_for_invalid(self): +    async def test_valid_python_identifier_for_invalid(self):          """ValidPythonIdentifier raises the proper exception for invalid identifiers."""          test_values = ('nested.stuff', '#####')          for name in test_values:              with self.subTest(identifier=name):                  exception_message = f'`{name}` is not a valid Python identifier' -                with self.assertRaises(BadArgument, msg=exception_message): -                    asyncio.run(ValidPythonIdentifier.convert(self.context, name)) +                with self.assertRaisesRegex(BadArgument, re.escape(exception_message)): +                    await ValidPythonIdentifier.convert(self.context, name) -    def test_duration_converter_for_valid(self): +    async def test_duration_converter_for_valid(self):          """Duration returns the correct `datetime` for valid duration strings."""          test_values = (              # Simple duration strings @@ -159,35 +159,35 @@ class ConverterTests(unittest.TestCase):                  mock_datetime.utcnow.return_value = self.fixed_utc_now                  with self.subTest(duration=duration, duration_dict=duration_dict): -                    converted_datetime = asyncio.run(converter.convert(self.context, duration)) +                    converted_datetime = await converter.convert(self.context, duration)                      self.assertEqual(converted_datetime, expected_datetime) -    def test_duration_converter_for_invalid(self): +    async def test_duration_converter_for_invalid(self):          """Duration raises the right exception for invalid duration strings."""          test_values = (              # Units in wrong order -            ('1d1w'), -            ('1s1y'), +            '1d1w', +            '1s1y',              # Duplicated units -            ('1 year 2 years'), -            ('1 M 10 minutes'), +            '1 year 2 years', +            '1 M 10 minutes',              # Unknown substrings -            ('1MVes'), -            ('1y3breads'), +            '1MVes', +            '1y3breads',              # Missing amount -            ('ym'), +            'ym',              # Incorrect whitespace -            (" 1y"), -            ("1S "), -            ("1y  1m"), +            " 1y", +            "1S ", +            "1y  1m",              # Garbage -            ('Guido van Rossum'), -            ('lemon lemon lemon lemon lemon lemon lemon'), +            'Guido van Rossum', +            'lemon lemon lemon lemon lemon lemon lemon',          )          converter = Duration() @@ -195,10 +195,21 @@ class ConverterTests(unittest.TestCase):          for invalid_duration in test_values:              with self.subTest(invalid_duration=invalid_duration):                  exception_message = f'`{invalid_duration}` is not a valid duration string.' -                with self.assertRaises(BadArgument, msg=exception_message): -                    asyncio.run(converter.convert(self.context, invalid_duration)) +                with self.assertRaisesRegex(BadArgument, re.escape(exception_message)): +                    await converter.convert(self.context, invalid_duration) -    def test_isodatetime_converter_for_valid(self): +    @patch("bot.converters.datetime") +    async def test_duration_converter_out_of_range(self, mock_datetime): +        """Duration converter should raise BadArgument if datetime raises a ValueError.""" +        mock_datetime.__add__.side_effect = ValueError +        mock_datetime.utcnow.return_value = mock_datetime + +        duration = f"{datetime.MAXYEAR}y" +        exception_message = f"`{duration}` results in a datetime outside the supported range." +        with self.assertRaisesRegex(BadArgument, re.escape(exception_message)): +            await Duration().convert(self.context, duration) + +    async def test_isodatetime_converter_for_valid(self):          """ISODateTime converter returns correct datetime for valid datetime string."""          test_values = (              # `YYYY-mm-ddTHH:MM:SSZ` | `YYYY-mm-dd HH:MM:SSZ` @@ -243,37 +254,37 @@ class ConverterTests(unittest.TestCase):          for datetime_string, expected_dt in test_values:              with self.subTest(datetime_string=datetime_string, expected_dt=expected_dt): -                converted_dt = asyncio.run(converter.convert(self.context, datetime_string)) +                converted_dt = await converter.convert(self.context, datetime_string)                  self.assertIsNone(converted_dt.tzinfo)                  self.assertEqual(converted_dt, expected_dt) -    def test_isodatetime_converter_for_invalid(self): +    async def test_isodatetime_converter_for_invalid(self):          """ISODateTime converter raises the correct exception for invalid datetime strings."""          test_values = (              # Make sure it doesn't interfere with the Duration converter -            ('1Y'), -            ('1d'), -            ('1H'), +            '1Y', +            '1d', +            '1H',              # Check if it fails when only providing the optional time part -            ('10:10:10'), -            ('10:00'), +            '10:10:10', +            '10:00',              # Invalid date format -            ('19-01-01'), +            '19-01-01',              # Other non-valid strings -            ('fisk the tag master'), +            'fisk the tag master',          )          converter = ISODateTime()          for datetime_string in test_values:              with self.subTest(datetime_string=datetime_string):                  exception_message = f"`{datetime_string}` is not a valid ISO-8601 datetime string" -                with self.assertRaises(BadArgument, msg=exception_message): -                    asyncio.run(converter.convert(self.context, datetime_string)) +                with self.assertRaisesRegex(BadArgument, re.escape(exception_message)): +                    await converter.convert(self.context, datetime_string) -    def test_hush_duration_converter_for_valid(self): +    async def test_hush_duration_converter_for_valid(self):          """HushDurationConverter returns correct value for minutes duration or `"forever"` strings."""          test_values = (              ("0", 0), @@ -286,10 +297,10 @@ class ConverterTests(unittest.TestCase):          converter = HushDurationConverter()          for minutes_string, expected_minutes in test_values:              with self.subTest(minutes_string=minutes_string, expected_minutes=expected_minutes): -                converted = asyncio.run(converter.convert(self.context, minutes_string)) +                converted = await converter.convert(self.context, minutes_string)                  self.assertEqual(expected_minutes, converted) -    def test_hush_duration_converter_for_invalid(self): +    async def test_hush_duration_converter_for_invalid(self):          """HushDurationConverter raises correct exception for invalid minutes duration strings."""          test_values = (              ("16", "Duration must be at most 15 minutes."), @@ -299,5 +310,5 @@ class ConverterTests(unittest.TestCase):          converter = HushDurationConverter()          for invalid_minutes_string, exception_message in test_values:              with self.subTest(invalid_minutes_string=invalid_minutes_string, exception_message=exception_message): -                with self.assertRaisesRegex(BadArgument, exception_message): -                    asyncio.run(converter.convert(self.context, invalid_minutes_string)) +                with self.assertRaisesRegex(BadArgument, re.escape(exception_message)): +                    await converter.convert(self.context, invalid_minutes_string) diff --git a/tests/helpers.py b/tests/helpers.py index 13283339b..faa839370 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -208,6 +208,10 @@ class MockRole(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin):          """Simplified position-based comparisons similar to those of `discord.Role`."""          return self.position < other.position +    def __ge__(self, other): +        """Simplified position-based comparisons similar to those of `discord.Role`.""" +        return self.position >= other.position +  # Create a Member instance to get a realistic Mock of `discord.Member`  member_data = {'user': 'lemon', 'roles': [1]} | 
