diff options
Diffstat (limited to '')
| -rw-r--r-- | bot/__main__.py | 1 | ||||
| -rw-r--r-- | bot/cogs/bot.py | 10 | ||||
| -rw-r--r-- | bot/cogs/clean.py | 35 | ||||
| -rw-r--r-- | bot/cogs/filtering.py | 2 | ||||
| -rw-r--r-- | bot/cogs/help_channels.py | 47 | ||||
| -rw-r--r-- | bot/cogs/information.py | 7 | ||||
| -rw-r--r-- | bot/cogs/jams.py | 86 | ||||
| -rw-r--r-- | bot/cogs/moderation/__init__.py | 4 | ||||
| -rw-r--r-- | bot/cogs/moderation/incidents.py | 407 | ||||
| -rw-r--r-- | bot/cogs/reminders.py | 172 | ||||
| -rw-r--r-- | bot/cogs/snekbox.py | 2 | ||||
| -rw-r--r-- | bot/cogs/source.py | 133 | ||||
| -rw-r--r-- | bot/cogs/tags.py | 1 | ||||
| -rw-r--r-- | bot/cogs/utils.py | 40 | ||||
| -rw-r--r-- | bot/cogs/webhook_remover.py | 2 | ||||
| -rw-r--r-- | bot/constants.py | 12 | ||||
| -rw-r--r-- | bot/utils/messages.py | 16 | ||||
| -rw-r--r-- | config-default.yml | 30 | ||||
| -rw-r--r-- | tests/bot/cogs/moderation/test_incidents.py | 770 | ||||
| -rw-r--r-- | tests/bot/cogs/test_jams.py | 173 | ||||
| -rw-r--r-- | tests/bot/cogs/test_snekbox.py | 6 | 
21 files changed, 1821 insertions, 135 deletions
| diff --git a/bot/__main__.py b/bot/__main__.py index fcef2239e..f698b5662 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -66,6 +66,7 @@ bot.load_extension("bot.cogs.reddit")  bot.load_extension("bot.cogs.reminders")  bot.load_extension("bot.cogs.site")  bot.load_extension("bot.cogs.snekbox") +bot.load_extension("bot.cogs.source")  bot.load_extension("bot.cogs.stats")  bot.load_extension("bot.cogs.sync")  bot.load_extension("bot.cogs.tags") diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index a79b37d25..79510739c 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -72,10 +72,14 @@ class BotCog(Cog, name="Bot"):      @command(name='embed')      @with_role(*MODERATION_ROLES) -    async def embed_command(self, ctx: Context, *, text: str) -> None: -        """Send the input within an embed to the current channel.""" +    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) -        await ctx.send(embed=embed) + +        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]]:          """ diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py index 368d91c85..f436e531a 100644 --- a/bot/cogs/clean.py +++ b/bot/cogs/clean.py @@ -45,6 +45,7 @@ class Clean(Cog):          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: @@ -129,6 +130,20 @@ class Clean(Cog):                  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) @@ -138,7 +153,14 @@ class Clean(Cog):          # Now let's delete the actual messages with purge.          self.mod_log.ignore(Event.message_delete, *message_ids)          for channel in channels: -            messages += await channel.purge(limit=amount, check=predicate) +            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: @@ -221,6 +243,17 @@ class Clean(Cog):          """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: diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 8670e1c8c..64afd184d 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -325,7 +325,7 @@ class Filtering(Cog):                              text=message,                              thumbnail=msg.author.avatar_url_as(static_format="png"),                              channel_id=Channels.mod_alerts, -                            ping_everyone=Filter.ping_everyone, +                            ping_everyone=Filter.ping_everyone if not is_private else False,                              additional_embeds=additional_embeds,                              additional_embeds_msg=additional_embeds_msg                          ) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 0c8cbb417..1be980472 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -102,6 +102,10 @@ class HelpChannels(commands.Cog):      # RedisCache[discord.TextChannel.id, UtcPosixTimestamp]      claim_times = RedisCache() +    # This cache maps a help channel to original question message in same channel. +    # RedisCache[discord.TextChannel.id, discord.Message.id] +    question_messages = RedisCache() +      def __init__(self, bot: Bot):          self.bot = bot          self.scheduler = Scheduler(self.__class__.__name__) @@ -360,10 +364,18 @@ class HelpChannels(commands.Cog):          channels = list(self.get_category_channels(self.available_category))          missing = constants.HelpChannels.max_available - len(channels) -        log.trace(f"Moving {missing} missing channels to the Available category.") +        # If we've got less than `max_available` channel available, we should add some. +        if missing > 0: +            log.trace(f"Moving {missing} missing channels to the Available category.") +            for _ in range(missing): +                await self.move_to_available() -        for _ in range(missing): -            await self.move_to_available() +        # If for some reason we have more than `max_available` channels available, +        # we should move the superfluous ones over to dormant. +        elif missing < 0: +            log.trace(f"Moving {abs(missing)} superfluous available channels over to the Dormant category.") +            for channel in channels[:abs(missing)]: +                await self.move_to_dormant(channel, "auto")      async def init_categories(self) -> None:          """Get the help category objects. Remove the cog if retrieval fails.""" @@ -428,8 +440,11 @@ class HelpChannels(commands.Cog):          if not message or not message.embeds:              return False -        embed = message.embeds[0] -        return message.author == self.bot.user and embed.description.strip() == description.strip() +        bot_msg_desc = message.embeds[0].description +        if bot_msg_desc is discord.Embed.Empty: +            log.trace("Last message was a bot embed but it was empty.") +            return False +        return message.author == self.bot.user and bot_msg_desc.strip() == description.strip()      @staticmethod      def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: @@ -536,6 +551,18 @@ class HelpChannels(commands.Cog):          A caller argument is provided for metrics.          """ +        msg_id = await self.question_messages.pop(channel.id) + +        try: +            await self.bot.http.unpin_message(channel.id, msg_id) +        except discord.HTTPException as e: +            if e.code == 10008: +                log.trace(f"Message {msg_id} don't exist, can't unpin.") +            else: +                log.warn(f"Got unexpected status {e.code} when unpinning message {msg_id}: {e.text}") +        else: +            log.trace(f"Unpinned message {msg_id}.") +          log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.")          await self.move_to_bottom_position( @@ -677,6 +704,16 @@ class HelpChannels(commands.Cog):              log.info(f"Channel #{channel} was claimed by `{message.author.id}`.")              await self.move_to_in_use(channel)              await self.revoke_send_permissions(message.author) +            # Pin message for better access and store this to cache +            try: +                await message.pin() +            except discord.NotFound: +                log.info(f"Pinning message {message.id} ({channel}) failed because message got deleted.") +            except discord.HTTPException as e: +                log.info(f"Pinning message {message.id} ({channel.id}) failed with code {e.code}", exc_info=e) +            else: +                await self.question_messages.set(channel.id, message.id) +              # Add user with channel for dormant check.              await self.help_channel_claimants.set(channel.id, message.author.id) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index f0bd1afdb..8982196d1 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -116,10 +116,7 @@ class Information(Cog):              parsed_roles.append(role)          if failed_roles: -            await ctx.send( -                ":x: I could not convert the following role names to a role: \n- " -                "\n- ".join(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()) @@ -226,7 +223,7 @@ class Information(Cog):          if user.nick:              name = f"{user.nick} ({name})" -        joined = time_since(user.joined_at, precision="days") +        joined = time_since(user.joined_at, max_units=3)          roles = ", ".join(role.mention for role in user.roles[1:])          description = [ diff --git a/bot/cogs/jams.py b/bot/cogs/jams.py index 1d062b0c2..b3102db2f 100644 --- a/bot/cogs/jams.py +++ b/bot/cogs/jams.py @@ -1,6 +1,7 @@  import logging +import typing as t -from discord import Member, PermissionOverwrite, utils +from discord import CategoryChannel, Guild, Member, PermissionOverwrite, Role  from discord.ext import commands  from more_itertools import unique_everseen @@ -10,6 +11,9 @@ 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.""" @@ -40,22 +44,47 @@ class CodeJams(commands.Cog):              )              return -        code_jam_category = utils.get(ctx.guild.categories, name="Code Jam") +        team_channel = await self.create_channels(ctx.guild, team_name, members) +        await self.add_roles(ctx.guild, members) -        if code_jam_category is None: -            log.info("Code Jam category not found, creating it.") +        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:])}" +        ) -            category_overwrites = { -                ctx.guild.default_role: PermissionOverwrite(read_messages=False), -                ctx.guild.me: PermissionOverwrite(read_messages=True) -            } +    async def get_category(self, guild: Guild) -> CategoryChannel: +        """ +        Return a code jam category. -            code_jam_category = await ctx.guild.create_category_channel( -                "Code Jam", -                overwrites=category_overwrites, -                reason="It's code jam time!" -            ) +        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( @@ -64,8 +93,8 @@ class CodeJams(commands.Cog):                  manage_webhooks=True,                  connect=True              ), -            ctx.guild.default_role: PermissionOverwrite(read_messages=False, connect=False), -            ctx.guild.get_role(Roles.verified): PermissionOverwrite( +            guild.default_role: PermissionOverwrite(read_messages=False, connect=False), +            guild.get_role(Roles.verified): PermissionOverwrite(                  read_messages=False,                  connect=False              ) @@ -78,8 +107,16 @@ class CodeJams(commands.Cog):                  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 ctx.guild.create_text_channel( +        team_channel = await guild.create_text_channel(              team_name,              overwrites=team_channel_overwrites,              category=code_jam_category @@ -88,26 +125,25 @@ class CodeJams(commands.Cog):          # Create a voice channel for the team          team_voice_name = " ".join(team_name.split("-")).title() -        await ctx.guild.create_voice_channel( +        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(ctx.guild.get_role(Roles.team_leaders)) +        await members[0].add_roles(guild.get_role(Roles.team_leaders))          # Assign rest of roles -        jammer_role = ctx.guild.get_role(Roles.jammers) +        jammer_role = guild.get_role(Roles.jammers)          for member in members:              await member.add_roles(jammer_role) -        await ctx.send( -            f":ok_hand: Team created: {team_channel.mention}\n" -            f"**Team Leader:** {members[0].mention}\n" -            f"**Team Members:** {' '.join(member.mention for member in members[1:])}" -        ) -  def setup(bot: Bot) -> None:      """Load the CodeJams cog.""" diff --git a/bot/cogs/moderation/__init__.py b/bot/cogs/moderation/__init__.py index a5c1ef362..995187ef0 100644 --- a/bot/cogs/moderation/__init__.py +++ b/bot/cogs/moderation/__init__.py @@ -1,4 +1,5 @@  from bot.bot import Bot +from .incidents import Incidents  from .infractions import Infractions  from .management import ModManagement  from .modlog import ModLog @@ -8,7 +9,8 @@ from .superstarify import Superstarify  def setup(bot: Bot) -> None: -    """Load the Infractions, ModManagement, ModLog, Silence, Slowmode, and Superstarify cogs.""" +    """Load the Incidents, Infractions, ModManagement, ModLog, Silence, Slowmode and Superstarify cogs.""" +    bot.add_cog(Incidents(bot))      bot.add_cog(Infractions(bot))      bot.add_cog(ModLog(bot))      bot.add_cog(ModManagement(bot)) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py new file mode 100644 index 000000000..3605ab1d2 --- /dev/null +++ b/bot/cogs/moderation/incidents.py @@ -0,0 +1,407 @@ +import asyncio +import logging +import typing as t +from datetime import datetime +from enum import Enum + +import discord +from discord.ext.commands import Cog + +from bot.bot import Bot +from bot.constants import Channels, Colours, Emojis, Guild, Webhooks +from bot.utils.messages import sub_clyde + +log = logging.getLogger(__name__) + +# Amount of messages for `crawl_task` to process at most on start-up - limited to 50 +# as in practice, there should never be this many messages, and if there are, +# something has likely gone very wrong +CRAWL_LIMIT = 50 + +# Seconds for `crawl_task` to sleep after adding reactions to a message +CRAWL_SLEEP = 2 + + +class Signal(Enum): +    """ +    Recognized incident status signals. + +    This binds emoji to actions. The bot will only react to emoji linked here. +    All other signals are seen as invalid. +    """ + +    ACTIONED = Emojis.incident_actioned +    NOT_ACTIONED = Emojis.incident_unactioned +    INVESTIGATING = Emojis.incident_investigating + + +# Reactions from non-mod roles will be removed +ALLOWED_ROLES: t.Set[int] = set(Guild.moderation_roles) + +# Message must have all of these emoji to pass the `has_signals` check +ALL_SIGNALS: t.Set[str] = {signal.value for signal in Signal} + +# An embed coupled with an optional file to be dispatched +# If the file is not None, the embed attempts to show it in its body +FileEmbed = t.Tuple[discord.Embed, t.Optional[discord.File]] + + +async def download_file(attachment: discord.Attachment) -> t.Optional[discord.File]: +    """ +    Download & return `attachment` file. + +    If the download fails, the reason is logged and None will be returned. +    404 and 403 errors are only logged at debug level. +    """ +    log.debug(f"Attempting to download attachment: {attachment.filename}") +    try: +        return await attachment.to_file() +    except (discord.NotFound, discord.Forbidden) as exc: +        log.debug(f"Failed to download attachment: {exc}") +    except Exception: +        log.exception("Failed to download attachment") + + +async def make_embed(incident: discord.Message, outcome: Signal, actioned_by: discord.Member) -> FileEmbed: +    """ +    Create an embed representation of `incident` for the #incidents-archive channel. + +    The name & discriminator of `actioned_by` and `outcome` will be presented in the +    embed footer. Additionally, the embed is coloured based on `outcome`. + +    The author of `incident` is not shown in the embed. It is assumed that this piece +    of information will be relayed in other ways, e.g. webhook username. + +    As mentions in embeds do not ping, we do not need to use `incident.clean_content`. + +    If `incident` contains attachments, the first attachment will be downloaded and +    returned alongside the embed. The embed attempts to display the attachment. +    Should the download fail, we fallback on linking the `proxy_url`, which should +    remain functional for some time after the original message is deleted. +    """ +    log.trace(f"Creating embed for {incident.id=}") + +    if outcome is Signal.ACTIONED: +        colour = Colours.soft_green +        footer = f"Actioned by {actioned_by}" +    else: +        colour = Colours.soft_red +        footer = f"Rejected by {actioned_by}" + +    embed = discord.Embed( +        description=incident.content, +        timestamp=datetime.utcnow(), +        colour=colour, +    ) +    embed.set_footer(text=footer, icon_url=actioned_by.avatar_url) + +    if incident.attachments: +        attachment = incident.attachments[0]  # User-sent messages can only contain one attachment +        file = await download_file(attachment) + +        if file is not None: +            embed.set_image(url=f"attachment://{attachment.filename}")  # Embed displays the attached file +        else: +            embed.set_author(name="[Failed to relay attachment]", url=attachment.proxy_url)  # Embed links the file +    else: +        file = None + +    return embed, file + + +def is_incident(message: discord.Message) -> bool: +    """True if `message` qualifies as an incident, False otherwise.""" +    conditions = ( +        message.channel.id == Channels.incidents,  # Message sent in #incidents +        not message.author.bot,                    # Not by a bot +        not message.content.startswith("#"),       # Doesn't start with a hash +        not message.pinned,                        # And isn't header +    ) +    return all(conditions) + + +def own_reactions(message: discord.Message) -> t.Set[str]: +    """Get the set of reactions placed on `message` by the bot itself.""" +    return {str(reaction.emoji) for reaction in message.reactions if reaction.me} + + +def has_signals(message: discord.Message) -> bool: +    """True if `message` already has all `Signal` reactions, False otherwise.""" +    return ALL_SIGNALS.issubset(own_reactions(message)) + + +async def add_signals(incident: discord.Message) -> None: +    """ +    Add `Signal` member emoji to `incident` as reactions. + +    If the emoji has already been placed on `incident` by the bot, it will be skipped. +    """ +    existing_reacts = own_reactions(incident) + +    for signal_emoji in Signal: +        if signal_emoji.value in existing_reacts:  # This would not raise, but it is a superfluous API call +            log.trace(f"Skipping emoji as it's already been placed: {signal_emoji}") +        else: +            log.trace(f"Adding reaction: {signal_emoji}") +            await incident.add_reaction(signal_emoji.value) + + +class Incidents(Cog): +    """ +    Automation for the #incidents channel. + +    This cog does not provide a command API, it only reacts to the following events. + +    On start-up: +        * Crawl #incidents and add missing `Signal` emoji where appropriate +        * This is to retro-actively add the available options for messages which +          were sent while the bot wasn't listening +        * Pinned messages and message starting with # do not qualify as incidents +        * See: `crawl_incidents` + +    On message: +        * Add `Signal` member emoji if message qualifies as an incident +        * Ignore messages starting with # +            * Use this if verbal communication is necessary +            * Each such message must be deleted manually once appropriate +        * See: `on_message` + +    On reaction: +        * Remove reaction if not permitted +            * User does not have any of the roles in `ALLOWED_ROLES` +            * Used emoji is not a `Signal` member +        * If `Signal.ACTIONED` or `Signal.NOT_ACTIONED` were chosen, attempt to +          relay the incident message to #incidents-archive +        * If relay successful, delete original message +        * See: `on_raw_reaction_add` + +    Please refer to function docstrings for implementation details. +    """ + +    def __init__(self, bot: Bot) -> None: +        """Prepare `event_lock` and schedule `crawl_task` on start-up.""" +        self.bot = bot + +        self.event_lock = asyncio.Lock() +        self.crawl_task = self.bot.loop.create_task(self.crawl_incidents()) + +    async def crawl_incidents(self) -> None: +        """ +        Crawl #incidents and add missing emoji where necessary. + +        This is to catch-up should an incident be reported while the bot wasn't listening. +        After adding each reaction, we take a short break to avoid drowning in ratelimits. + +        Once this task is scheduled, listeners that change messages should await it. +        The crawl assumes that the channel history doesn't change as we go over it. + +        Behaviour is configured by: `CRAWL_LIMIT`, `CRAWL_SLEEP`. +        """ +        await self.bot.wait_until_guild_available() +        incidents: discord.TextChannel = self.bot.get_channel(Channels.incidents) + +        log.debug(f"Crawling messages in #incidents: {CRAWL_LIMIT=}, {CRAWL_SLEEP=}") +        async for message in incidents.history(limit=CRAWL_LIMIT): + +            if not is_incident(message): +                log.trace(f"Skipping message {message.id}: not an incident") +                continue + +            if has_signals(message): +                log.trace(f"Skipping message {message.id}: already has all signals") +                continue + +            await add_signals(message) +            await asyncio.sleep(CRAWL_SLEEP) + +        log.debug("Crawl task finished!") + +    async def archive(self, incident: discord.Message, outcome: Signal, actioned_by: discord.Member) -> bool: +        """ +        Relay an embed representation of `incident` to the #incidents-archive channel. + +        The following pieces of information are relayed: +            * Incident message content (as embed description) +            * Incident attachment (if image, shown in archive embed) +            * Incident author name (as webhook author) +            * Incident author avatar (as webhook avatar) +            * Resolution signal `outcome` (as embed colour & footer) +            * Moderator `actioned_by` (name & discriminator shown in footer) + +        If `incident` contains an attachment, we try to add it to the archive embed. There is +        no handing of extensions / file types - we simply dispatch the attachment file with the +        webhook, and try to display it in the embed. Testing indicates that if the attachment +        cannot be displayed (e.g. a text file), it's invisible in the embed, with no error. + +        Return True if the relay finishes successfully. If anything goes wrong, meaning +        not all information was relayed, return False. This signals that the original +        message is not safe to be deleted, as we will lose some information. +        """ +        log.debug(f"Archiving incident: {incident.id} (outcome: {outcome}, actioned by: {actioned_by})") +        embed, attachment_file = await make_embed(incident, outcome, actioned_by) + +        try: +            webhook = await self.bot.fetch_webhook(Webhooks.incidents_archive) +            await webhook.send( +                embed=embed, +                username=sub_clyde(incident.author.name), +                avatar_url=incident.author.avatar_url, +                file=attachment_file, +            ) +        except Exception: +            log.exception(f"Failed to archive incident {incident.id} to #incidents-archive") +            return False +        else: +            log.trace("Message archived successfully!") +            return True + +    def make_confirmation_task(self, incident: discord.Message, timeout: int = 5) -> asyncio.Task: +        """ +        Create a task to wait `timeout` seconds for `incident` to be deleted. + +        If `timeout` passes, this will raise `asyncio.TimeoutError`, signaling that we haven't +        been able to confirm that the message was deleted. +        """ +        log.trace(f"Confirmation task will wait {timeout=} seconds for {incident.id=} to be deleted") + +        def check(payload: discord.RawReactionActionEvent) -> bool: +            return payload.message_id == incident.id + +        coroutine = self.bot.wait_for(event="raw_message_delete", check=check, timeout=timeout) +        return self.bot.loop.create_task(coroutine) + +    async def process_event(self, reaction: str, incident: discord.Message, member: discord.Member) -> None: +        """ +        Process a `reaction_add` event in #incidents. + +        First, we check that the reaction is a recognized `Signal` member, and that it was sent by +        a permitted user (at least one role in `ALLOWED_ROLES`). If not, the reaction is removed. + +        If the reaction was either `Signal.ACTIONED` or `Signal.NOT_ACTIONED`, we attempt to relay +        the report to #incidents-archive. If successful, the original message is deleted. + +        We do not release `event_lock` until we receive the corresponding `message_delete` event. +        This ensures that if there is a racing event awaiting the lock, it will fail to find the +        message, and will abort. There is a `timeout` to ensure that this doesn't hold the lock +        forever should something go wrong. +        """ +        members_roles: t.Set[int] = {role.id for role in member.roles} +        if not members_roles & ALLOWED_ROLES:  # Intersection is truthy on at least 1 common element +            log.debug(f"Removing invalid reaction: user {member} is not permitted to send signals") +            await incident.remove_reaction(reaction, member) +            return + +        try: +            signal = Signal(reaction) +        except ValueError: +            log.debug(f"Removing invalid reaction: emoji {reaction} is not a valid signal") +            await incident.remove_reaction(reaction, member) +            return + +        log.trace(f"Received signal: {signal}") + +        if signal not in (Signal.ACTIONED, Signal.NOT_ACTIONED): +            log.debug("Reaction was valid, but no action is currently defined for it") +            return + +        relay_successful = await self.archive(incident, signal, actioned_by=member) +        if not relay_successful: +            log.trace("Original message will not be deleted as we failed to relay it to the archive") +            return + +        timeout = 5  # Seconds +        confirmation_task = self.make_confirmation_task(incident, timeout) + +        log.trace("Deleting original message") +        await incident.delete() + +        log.trace(f"Awaiting deletion confirmation: {timeout=} seconds") +        try: +            await confirmation_task +        except asyncio.TimeoutError: +            log.warning(f"Did not receive incident deletion confirmation within {timeout} seconds!") +        else: +            log.trace("Deletion was confirmed") + +    async def resolve_message(self, message_id: int) -> t.Optional[discord.Message]: +        """ +        Get `discord.Message` for `message_id` from cache, or API. + +        We first look into the local cache to see if the message is present. + +        If not, we try to fetch the message from the API. This is necessary for messages +        which were sent before the bot's current session. + +        In an edge-case, it is also possible that the message was already deleted, and +        the API will respond with a 404. In such a case, None will be returned. +        This signals that the event for `message_id` should be ignored. +        """ +        await self.bot.wait_until_guild_available()  # First make sure that the cache is ready +        log.trace(f"Resolving message for: {message_id=}") +        message: t.Optional[discord.Message] = self.bot._connection._get_message(message_id) + +        if message is not None: +            log.trace("Message was found in cache") +            return message + +        log.trace("Message not found, attempting to fetch") +        try: +            message = await self.bot.get_channel(Channels.incidents).fetch_message(message_id) +        except discord.NotFound: +            log.trace("Message doesn't exist, it was likely already relayed") +        except Exception: +            log.exception(f"Failed to fetch message {message_id}!") +        else: +            log.trace("Message fetched successfully!") +            return message + +    @Cog.listener() +    async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent) -> None: +        """ +        Pre-process `payload` and pass it to `process_event` if appropriate. + +        We abort instantly if `payload` doesn't relate to a message sent in #incidents, +        or if it was sent by a bot. + +        If `payload` relates to a message in #incidents, we first ensure that `crawl_task` has +        finished, to make sure we don't mutate channel state as we're crawling it. + +        Next, we acquire `event_lock` - to prevent racing, events are processed one at a time. + +        Once we have the lock, the `discord.Message` object for this event must be resolved. +        If the lock was previously held by an event which successfully relayed the incident, +        this will fail and we abort the current event. + +        Finally, with both the lock and the `discord.Message` instance in our hands, we delegate +        to `process_event` to handle the event. + +        The justification for using a raw listener is the need to receive events for messages +        which were not cached in the current session. As a result, a certain amount of +        complexity is introduced, but at the moment this doesn't appear to be avoidable. +        """ +        if payload.channel_id != Channels.incidents or payload.member.bot: +            return + +        log.trace(f"Received reaction add event in #incidents, waiting for crawler: {self.crawl_task.done()=}") +        await self.crawl_task + +        log.trace(f"Acquiring event lock: {self.event_lock.locked()=}") +        async with self.event_lock: +            message = await self.resolve_message(payload.message_id) + +            if message is None: +                log.debug("Listener will abort as related message does not exist!") +                return + +            if not is_incident(message): +                log.debug("Ignoring event for a non-incident message") +                return + +            await self.process_event(str(payload.emoji), message, payload.member) +            log.trace("Releasing event lock") + +    @Cog.listener() +    async def on_message(self, message: discord.Message) -> None: +        """Pass `message` to `add_signals` if and only if it satisfies `is_incident`.""" +        if is_incident(message): +            await add_signals(message) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 0d20bdb2b..b5998cc0e 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -9,13 +9,14 @@ from operator import itemgetter  import discord  from dateutil.parser import isoparse  from dateutil.relativedelta import relativedelta -from discord.ext.commands import Cog, Context, group +from discord.ext.commands import Cog, Context, Greedy, group  from bot.bot import Bot -from bot.constants import Guild, Icons, NEGATIVE_REPLIES, POSITIVE_REPLIES, STAFF_ROLES +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 @@ -24,6 +25,8 @@ 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.""" @@ -99,6 +102,46 @@ class Reminders(Cog):          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"] @@ -120,6 +163,19 @@ class Reminders(Cog):              # 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']}") @@ -153,36 +209,39 @@ class Reminders(Cog):                  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=user.mention, +            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, expiration: Duration, *, content: str) -> None: +    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, expiration=expiration, content=content) +        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, expiration: Duration, *, content: str) -> t.Optional[discord.Message]: +    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/          """ -        embed = discord.Embed() -          # 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: -                embed.colour = discord.Colour.red() -                embed.title = random.choice(NEGATIVE_REPLIES) -                embed.description = "Sorry, you can't do that here!" - -                return await ctx.send(embed=embed) +                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( @@ -195,11 +254,18 @@ class Reminders(Cog):              # 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: -                embed.colour = discord.Colour.red() -                embed.title = random.choice(NEGATIVE_REPLIES) -                embed.description = "You have too many active reminders!" +                await send_denial(ctx, "You have too many active reminders!") +                return -                return await ctx.send(embed=embed) +        # 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( @@ -209,17 +275,22 @@ class Reminders(Cog):                  'channel_id': ctx.message.channel.id,                  'jump_url': ctx.message.jump_url,                  'content': content, -                'expiration': expiration.isoformat() +                '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=f"Your reminder will arrive in {humanized_delta}!", +            on_success=mention_string,              reminder_id=reminder["id"],              delivery_dt=expiration,          ) @@ -227,7 +298,7 @@ class Reminders(Cog):          self.schedule_reminder(reminder)      @remind_group.command(name="list") -    async def list_reminders(self, ctx: Context) -> t.Optional[discord.Message]: +    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( @@ -240,7 +311,7 @@ class Reminders(Cog):          # Make a list of tuples so it can be sorted by time.          reminders = sorted(              ( -                (rem['content'], rem['expiration'], rem['id']) +                (rem['content'], rem['expiration'], rem['id'], rem['mentions'])                  for rem in data              ),              key=itemgetter(1) @@ -248,13 +319,19 @@ class Reminders(Cog):          lines = [] -        for content, remind_at, id_ in reminders: +        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_}) +            **Reminder #{id_}:** *expires in {time}* (ID: {id_}){mention_string}              {content}              """).strip() @@ -267,7 +344,8 @@ class Reminders(Cog):          # Remind the user that they have no reminders :^)          if not lines:              embed.description = "No active reminders could be found." -            return await ctx.send(embed=embed) +            await ctx.send(embed=embed) +            return          # Construct the embed and paginate it.          embed.colour = discord.Colour.blurple() @@ -287,37 +365,37 @@ class Reminders(Cog):      @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. +        Edit one of your reminder's expiration.          Expiration is parsed per: http://strftime.org/          """ -        # Send the request to update the reminder in the database -        reminder = await self.bot.api_client.patch( -            'bot/reminders/' + str(id_), -            json={'expiration': expiration.isoformat()} -        ) - -        # 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) +        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.""" -        # Send the request to update the reminder in the database -        reminder = await self.bot.api_client.patch( -            'bot/reminders/' + str(id_), -            json={'content': 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 for the confirmation message -        expiration = isoparse(reminder['expiration']).replace(tzinfo=None) +        # 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( diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 662f90869..52c8b6f88 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -202,7 +202,7 @@ class Snekbox(Cog):                  output, paste_link = await self.format_output(results["stdout"])              icon = self.get_status_emoji(results) -            msg = f"{ctx.author.mention} {icon} {msg}.\n\n```py\n{output}\n```" +            msg = f"{ctx.author.mention} {icon} {msg}.\n\n```\n{output}\n```"              if paste_link:                  msg = f"{msg}\nFull output: {paste_link}" diff --git a/bot/cogs/source.py b/bot/cogs/source.py new file mode 100644 index 000000000..f1db745cd --- /dev/null +++ b/bot/cogs/source.py @@ -0,0 +1,133 @@ +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.""" +        if isinstance(source_item, commands.HelpCommand): +            src = type(source_item) +            filename = inspect.getsourcefile(src) +        elif 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) +            filename = inspect.getsourcefile(src) + +        if not isinstance(source_item, str): +            lines, first_line_no = inspect.getsourcelines(src) +            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/tags.py b/bot/cogs/tags.py index 6f03a3475..3d76c5c08 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -47,6 +47,7 @@ class Tags(Cog):                          "description": file.read_text(encoding="utf8"),                      },                      "restricted_to": "developers", +                    "location": f"/bot/{file}"                  }                  # Convert to a list to allow negative indexing. diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 697bf60ce..91c6cb36e 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -7,11 +7,13 @@ from io import StringIO  from typing import Tuple, Union  from discord import Colour, Embed, utils -from discord.ext.commands import BadArgument, Cog, Context, command +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__) @@ -117,25 +119,18 @@ class Utils(Cog):      @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 25 unicode characters.""" +        """Shows you information on up to 50 unicode characters."""          match = re.match(r"<(a?):(\w+):(\d+)>", characters)          if match: -            embed = Embed( -                title="Non-Character Detected", -                description=( -                    "Only unicode characters can be processed, but a custom Discord emoji " -                    "was found. Please remove it and try again." -                ) +            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."              ) -            embed.colour = Colour.red() -            await ctx.send(embed=embed) -            return -        if len(characters) > 25: -            embed = Embed(title=f"Too many characters ({len(characters)}/25)") -            embed.colour = Colour.red() -            await ctx.send(embed=embed) -            return +        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}" @@ -148,15 +143,14 @@ class Utils(Cog):              info = f"`{u_code.ljust(10)}`: {name} - {utils.escape_markdown(char)}"              return info, u_code -        charlist, rawlist = zip(*(get_info(c) for c in characters)) - -        embed = Embed(description="\n".join(charlist)) -        embed.set_author(name="Character Info") +        char_list, raw_list = zip(*(get_info(c) for c in characters)) +        embed = Embed().set_author(name="Character Info")          if len(characters) > 1: -            embed.add_field(name='Raw', value=f"`{''.join(rawlist)}`", inline=False) +            # 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 ctx.send(embed=embed) +        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: @@ -231,7 +225,7 @@ class Utils(Cog):      @command(aliases=("poll",))      @with_role(*MODERATION_ROLES) -    async def vote(self, ctx: Context, title: str, *options: str) -> None: +    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. diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py index 543869215..5812da87c 100644 --- a/bot/cogs/webhook_remover.py +++ b/bot/cogs/webhook_remover.py @@ -8,7 +8,7 @@ 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?://)?discordapp\.com/api/webhooks/\d+/)\S+/?", re.I) +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 " diff --git a/bot/constants.py b/bot/constants.py index 857e6c4f0..9d00eac36 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -268,6 +268,10 @@ class Emojis(metaclass=YAMLGetter):      status_idle: str      status_dnd: str +    incident_actioned: str +    incident_unactioned: str +    incident_investigating: str +      failmail: str      trashcan: str @@ -396,6 +400,7 @@ class Channels(metaclass=YAMLGetter):      helpers: int      how_to_get_help: int      incidents: int +    incidents_archive: int      message_log: int      meta: int      mod_alerts: int @@ -419,12 +424,13 @@ class Webhooks(metaclass=YAMLGetter):      section = "guild"      subsection = "webhooks" -    talent_pool: int      big_brother: int -    reddit: int -    duck_pond: int      dev_log: int      dm_log: int +    duck_pond: int +    incidents_archive: int +    reddit: int +    talent_pool: int  class Roles(metaclass=YAMLGetter): diff --git a/bot/utils/messages.py b/bot/utils/messages.py index a40a12e98..670289941 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -1,15 +1,17 @@  import asyncio  import contextlib  import logging +import random  import re  from io import BytesIO  from typing import List, Optional, Sequence, Union -from discord import Client, Embed, File, Member, Message, Reaction, TextChannel, Webhook +from discord import Client, Colour, Embed, File, Member, Message, Reaction, TextChannel, Webhook  from discord.abc import Snowflake  from discord.errors import HTTPException +from discord.ext.commands import Context -from bot.constants import Emojis +from bot.constants import Emojis, NEGATIVE_REPLIES  log = logging.getLogger(__name__) @@ -132,3 +134,13 @@ def sub_clyde(username: Optional[str]) -> Optional[str]:          return re.sub(r"(clyd)(e)", replace_e, username, flags=re.I)      else:          return username  # Empty string or None + + +async def send_denial(ctx: Context, reason: str) -> None: +    """Send an embed denying the user with the given reason.""" +    embed = Embed() +    embed.colour = Colour.red() +    embed.title = random.choice(NEGATIVE_REPLIES) +    embed.description = reason + +    await ctx.send(embed=embed) diff --git a/config-default.yml b/config-default.yml index 503cc2b52..14a073611 100644 --- a/config-default.yml +++ b/config-default.yml @@ -38,6 +38,10 @@ style:          status_dnd:     "<:status_dnd:470326272082313216>"          status_offline: "<:status_offline:470326266537705472>" +        incident_actioned:      "<:incident_actioned:719645530128646266>" +        incident_unactioned:    "<:incident_unactioned:719645583245180960>" +        incident_investigating: "<:incident_investigating:719645658671480924>" +          failmail: "<:failmail:633660039931887616>"          trashcan: "<:trashcan:637136429717389331>" @@ -168,12 +172,13 @@ guild:          admin_spam:         &ADMIN_SPAM     563594791770914816          defcon:             &DEFCON         464469101889454091          helpers:            &HELPERS        385474242440986624 +        incidents:                          714214212200562749 +        incidents_archive:                  720668923636351037          mods:               &MODS           305126844661760000          mod_alerts:         &MOD_ALERTS     473092532147060736          mod_spam:           &MOD_SPAM       620607373828030464          organisation:       &ORGANISATION   551789653284356126          staff_lounge:       &STAFF_LOUNGE   464905259261755392 -        incidents:                          714214212200562749          # Voice          admins_voice:       &ADMINS_VOICE   500734494840717332 @@ -231,8 +236,8 @@ guild:          owners:             &OWNERS_ROLE    267627879762755584          # Code Jam -        jammers:        591786436651646989 -        team_leaders:   501324292341104650 +        jammers:        737249140966162473 +        team_leaders:   737250302834638889      moderation_roles:          - *OWNERS_ROLE @@ -246,13 +251,14 @@ guild:          - *HELPERS_ROLE      webhooks: -        talent_pool:                    569145364800602132 -        big_brother:                    569133704568373283 -        reddit:                         635408384794951680 -        duck_pond:                      637821475327311927 -        dev_log:                        680501655111729222 -        python_news:    &PYNEWS_WEBHOOK 704381182279942324 -        dm_log:                         654567640664244225 +        big_brother:                        569133704568373283 +        dev_log:                            680501655111729222 +        dm_log:                             654567640664244225 +        duck_pond:                          637821475327311927 +        incidents_archive:                  720671599790915702 +        python_news:        &PYNEWS_WEBHOOK 704381182279942324 +        reddit:                             635408384794951680 +        talent_pool:                        569145364800602132  filter:      # What do we filter? @@ -352,10 +358,6 @@ anti_spam:              interval: 10              max: 7 -        burst_shared: -            interval: 10 -            max: 20 -          chars:              interval: 5              max: 3_000 diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py new file mode 100644 index 000000000..435a1cd51 --- /dev/null +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -0,0 +1,770 @@ +import asyncio +import enum +import logging +import typing as t +import unittest +from unittest.mock import AsyncMock, MagicMock, call, patch + +import aiohttp +import discord + +from bot.cogs.moderation import Incidents, incidents +from bot.constants import Colours +from tests.helpers import ( +    MockAsyncWebhook, +    MockAttachment, +    MockBot, +    MockMember, +    MockMessage, +    MockReaction, +    MockRole, +    MockTextChannel, +    MockUser, +) + + +class MockAsyncIterable: +    """ +    Helper for mocking asynchronous for loops. + +    It does not appear that the `unittest` library currently provides anything that would +    allow us to simply mock an async iterator, such as `discord.TextChannel.history`. + +    We therefore write our own helper to wrap a regular synchronous iterable, and feed +    its values via `__anext__` rather than `__next__`. + +    This class was written for the purposes of testing the `Incidents` cog - it may not +    be generic enough to be placed in the `tests.helpers` module. +    """ + +    def __init__(self, messages: t.Iterable): +        """Take a sync iterable to be wrapped.""" +        self.iter_messages = iter(messages) + +    def __aiter__(self): +        """Return `self` as we provide the `__anext__` method.""" +        return self + +    async def __anext__(self): +        """ +        Feed the next item, or raise `StopAsyncIteration`. + +        Since we're wrapping a sync iterator, it will communicate that it has been depleted +        by raising a `StopIteration`. The `async for` construct does not expect it, and we +        therefore need to substitute it for the appropriate exception type. +        """ +        try: +            return next(self.iter_messages) +        except StopIteration: +            raise StopAsyncIteration + + +class MockSignal(enum.Enum): +    A = "A" +    B = "B" + + +mock_404 = discord.NotFound( +    response=MagicMock(aiohttp.ClientResponse),  # Mock the erroneous response +    message="Not found", +) + + +class TestDownloadFile(unittest.IsolatedAsyncioTestCase): +    """Collection of tests for the `download_file` helper function.""" + +    async def test_download_file_success(self): +        """If `to_file` succeeds, function returns the acquired `discord.File`.""" +        file = MagicMock(discord.File, filename="bigbadlemon.jpg") +        attachment = MockAttachment(to_file=AsyncMock(return_value=file)) + +        acquired_file = await incidents.download_file(attachment) +        self.assertIs(file, acquired_file) + +    async def test_download_file_404(self): +        """If `to_file` encounters a 404, function handles the exception & returns None.""" +        attachment = MockAttachment(to_file=AsyncMock(side_effect=mock_404)) + +        acquired_file = await incidents.download_file(attachment) +        self.assertIsNone(acquired_file) + +    async def test_download_file_fail(self): +        """If `to_file` fails on a non-404 error, function logs the exception & returns None.""" +        arbitrary_error = discord.HTTPException(MagicMock(aiohttp.ClientResponse), "Arbitrary API error") +        attachment = MockAttachment(to_file=AsyncMock(side_effect=arbitrary_error)) + +        with self.assertLogs(logger=incidents.log, level=logging.ERROR): +            acquired_file = await incidents.download_file(attachment) + +        self.assertIsNone(acquired_file) + + +class TestMakeEmbed(unittest.IsolatedAsyncioTestCase): +    """Collection of tests for the `make_embed` helper function.""" + +    async def test_make_embed_actioned(self): +        """Embed is coloured green and footer contains 'Actioned' when `outcome=Signal.ACTIONED`.""" +        embed, file = await incidents.make_embed(MockMessage(), incidents.Signal.ACTIONED, MockMember()) + +        self.assertEqual(embed.colour.value, Colours.soft_green) +        self.assertIn("Actioned", embed.footer.text) + +    async def test_make_embed_not_actioned(self): +        """Embed is coloured red and footer contains 'Rejected' when `outcome=Signal.NOT_ACTIONED`.""" +        embed, file = await incidents.make_embed(MockMessage(), incidents.Signal.NOT_ACTIONED, MockMember()) + +        self.assertEqual(embed.colour.value, Colours.soft_red) +        self.assertIn("Rejected", embed.footer.text) + +    async def test_make_embed_content(self): +        """Incident content appears as embed description.""" +        incident = MockMessage(content="this is an incident") +        embed, file = await incidents.make_embed(incident, incidents.Signal.ACTIONED, MockMember()) + +        self.assertEqual(incident.content, embed.description) + +    async def test_make_embed_with_attachment_succeeds(self): +        """Incident's attachment is downloaded and displayed in the embed's image field.""" +        file = MagicMock(discord.File, filename="bigbadjoe.jpg") +        attachment = MockAttachment(filename="bigbadjoe.jpg") +        incident = MockMessage(content="this is an incident", attachments=[attachment]) + +        # Patch `download_file` to return our `file` +        with patch("bot.cogs.moderation.incidents.download_file", AsyncMock(return_value=file)): +            embed, returned_file = await incidents.make_embed(incident, incidents.Signal.ACTIONED, MockMember()) + +        self.assertIs(file, returned_file) +        self.assertEqual("attachment://bigbadjoe.jpg", embed.image.url) + +    async def test_make_embed_with_attachment_fails(self): +        """Incident's attachment fails to download, proxy url is linked instead.""" +        attachment = MockAttachment(proxy_url="discord.com/bigbadjoe.jpg") +        incident = MockMessage(content="this is an incident", attachments=[attachment]) + +        # Patch `download_file` to return None as if the download failed +        with patch("bot.cogs.moderation.incidents.download_file", AsyncMock(return_value=None)): +            embed, returned_file = await incidents.make_embed(incident, incidents.Signal.ACTIONED, MockMember()) + +        self.assertIsNone(returned_file) + +        # The author name field is simply expected to have something in it, we do not assert the message +        self.assertGreater(len(embed.author.name), 0) +        self.assertEqual(embed.author.url, "discord.com/bigbadjoe.jpg")  # However, it should link the exact url + + +@patch("bot.constants.Channels.incidents", 123) +class TestIsIncident(unittest.TestCase): +    """ +    Collection of tests for the `is_incident` helper function. + +    In `setUp`, we will create a mock message which should qualify as an incident. Each +    test case will then mutate this instance to make it **not** qualify, in various ways. + +    Notice that we patch the #incidents channel id globally for this class. +    """ + +    def setUp(self) -> None: +        """Prepare a mock message which should qualify as an incident.""" +        self.incident = MockMessage( +            channel=MockTextChannel(id=123), +            content="this is an incident", +            author=MockUser(bot=False), +            pinned=False, +        ) + +    def test_is_incident_true(self): +        """Message qualifies as an incident if unchanged.""" +        self.assertTrue(incidents.is_incident(self.incident)) + +    def check_false(self): +        """Assert that `self.incident` does **not** qualify as an incident.""" +        self.assertFalse(incidents.is_incident(self.incident)) + +    def test_is_incident_false_channel(self): +        """Message doesn't qualify if sent outside of #incidents.""" +        self.incident.channel = MockTextChannel(id=456) +        self.check_false() + +    def test_is_incident_false_content(self): +        """Message doesn't qualify if content begins with hash symbol.""" +        self.incident.content = "# this is a comment message" +        self.check_false() + +    def test_is_incident_false_author(self): +        """Message doesn't qualify if author is a bot.""" +        self.incident.author = MockUser(bot=True) +        self.check_false() + +    def test_is_incident_false_pinned(self): +        """Message doesn't qualify if it is pinned.""" +        self.incident.pinned = True +        self.check_false() + + +class TestOwnReactions(unittest.TestCase): +    """Assertions for the `own_reactions` function.""" + +    def test_own_reactions(self): +        """Only bot's own emoji are extracted from the input incident.""" +        reactions = ( +            MockReaction(emoji="A", me=True), +            MockReaction(emoji="B", me=True), +            MockReaction(emoji="C", me=False), +        ) +        message = MockMessage(reactions=reactions) +        self.assertSetEqual(incidents.own_reactions(message), {"A", "B"}) + + +@patch("bot.cogs.moderation.incidents.ALL_SIGNALS", {"A", "B"}) +class TestHasSignals(unittest.TestCase): +    """ +    Assertions for the `has_signals` function. + +    We patch `ALL_SIGNALS` globally. Each test function then patches `own_reactions` +    as appropriate. +    """ + +    def test_has_signals_true(self): +        """True when `own_reactions` returns all emoji in `ALL_SIGNALS`.""" +        message = MockMessage() +        own_reactions = MagicMock(return_value={"A", "B"}) + +        with patch("bot.cogs.moderation.incidents.own_reactions", own_reactions): +            self.assertTrue(incidents.has_signals(message)) + +    def test_has_signals_false(self): +        """False when `own_reactions` does not return all emoji in `ALL_SIGNALS`.""" +        message = MockMessage() +        own_reactions = MagicMock(return_value={"A", "C"}) + +        with patch("bot.cogs.moderation.incidents.own_reactions", own_reactions): +            self.assertFalse(incidents.has_signals(message)) + + +@patch("bot.cogs.moderation.incidents.Signal", MockSignal) +class TestAddSignals(unittest.IsolatedAsyncioTestCase): +    """ +    Assertions for the `add_signals` coroutine. + +    These are all fairly similar and could go into a single test function, but I found the +    patching & sub-testing fairly awkward in that case and decided to split them up +    to avoid unnecessary syntax noise. +    """ + +    def setUp(self): +        """Prepare a mock incident message for tests to use.""" +        self.incident = MockMessage() + +    @patch("bot.cogs.moderation.incidents.own_reactions", MagicMock(return_value=set())) +    async def test_add_signals_missing(self): +        """All emoji are added when none are present.""" +        await incidents.add_signals(self.incident) +        self.incident.add_reaction.assert_has_calls([call("A"), call("B")]) + +    @patch("bot.cogs.moderation.incidents.own_reactions", MagicMock(return_value={"A"})) +    async def test_add_signals_partial(self): +        """Only missing emoji are added when some are present.""" +        await incidents.add_signals(self.incident) +        self.incident.add_reaction.assert_has_calls([call("B")]) + +    @patch("bot.cogs.moderation.incidents.own_reactions", MagicMock(return_value={"A", "B"})) +    async def test_add_signals_present(self): +        """No emoji are added when all are present.""" +        await incidents.add_signals(self.incident) +        self.incident.add_reaction.assert_not_called() + + +class TestIncidents(unittest.IsolatedAsyncioTestCase): +    """ +    Tests for bound methods of the `Incidents` cog. + +    Use this as a base class for `Incidents` tests - it will prepare a fresh instance +    for each test function, but not make any assertions on its own. Tests can mutate +    the instance as they wish. +    """ + +    def setUp(self): +        """ +        Prepare a fresh `Incidents` instance for each test. + +        Note that this will not schedule `crawl_incidents` in the background, as everything +        is being mocked. The `crawl_task` attribute will end up being None. +        """ +        self.cog_instance = Incidents(MockBot()) + + +@patch("asyncio.sleep", AsyncMock())  # Prevent the coro from sleeping to speed up the test +class TestCrawlIncidents(TestIncidents): +    """ +    Tests for the `Incidents.crawl_incidents` coroutine. + +    Apart from `test_crawl_incidents_waits_until_cache_ready`, all tests in this class +    will patch the return values of `is_incident` and `has_signal` and then observe +    whether the `AsyncMock` for `add_signals` was awaited or not. + +    The `add_signals` mock is added by each test separately to ensure it is clean (has not +    been awaited by another test yet). The mock can be reset, but this appears to be the +    cleaner way. + +    For each test, we inject a mock channel with a history of 1 message only (see: `setUp`). +    """ + +    def setUp(self): +        """For each test, ensure `bot.get_channel` returns a channel with 1 arbitrary message.""" +        super().setUp()  # First ensure we get `cog_instance` from parent + +        incidents_history = MagicMock(return_value=MockAsyncIterable([MockMessage()])) +        self.cog_instance.bot.get_channel = MagicMock(return_value=MockTextChannel(history=incidents_history)) + +    async def test_crawl_incidents_waits_until_cache_ready(self): +        """ +        The coroutine will await the `wait_until_guild_available` event. + +        Since this task is schedule in the `__init__`, it is critical that it waits for the +        cache to be ready, so that it can safely get the #incidents channel. +        """ +        await self.cog_instance.crawl_incidents() +        self.cog_instance.bot.wait_until_guild_available.assert_awaited() + +    @patch("bot.cogs.moderation.incidents.add_signals", AsyncMock()) +    @patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=False))  # Message doesn't qualify +    @patch("bot.cogs.moderation.incidents.has_signals", MagicMock(return_value=False)) +    async def test_crawl_incidents_noop_if_is_not_incident(self): +        """Signals are not added for a non-incident message.""" +        await self.cog_instance.crawl_incidents() +        incidents.add_signals.assert_not_awaited() + +    @patch("bot.cogs.moderation.incidents.add_signals", AsyncMock()) +    @patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=True))  # Message qualifies +    @patch("bot.cogs.moderation.incidents.has_signals", MagicMock(return_value=True))  # But already has signals +    async def test_crawl_incidents_noop_if_message_already_has_signals(self): +        """Signals are not added for messages which already have them.""" +        await self.cog_instance.crawl_incidents() +        incidents.add_signals.assert_not_awaited() + +    @patch("bot.cogs.moderation.incidents.add_signals", AsyncMock()) +    @patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=True))  # Message qualifies +    @patch("bot.cogs.moderation.incidents.has_signals", MagicMock(return_value=False))  # And doesn't have signals +    async def test_crawl_incidents_add_signals_called(self): +        """Message has signals added as it does not have them yet and qualifies as an incident.""" +        await self.cog_instance.crawl_incidents() +        incidents.add_signals.assert_awaited_once() + + +class TestArchive(TestIncidents): +    """Tests for the `Incidents.archive` coroutine.""" + +    async def test_archive_webhook_not_found(self): +        """ +        Method recovers and returns False when the webhook is not found. + +        Implicitly, this also tests that the error is handled internally and doesn't +        propagate out of the method, which is just as important. +        """ +        self.cog_instance.bot.fetch_webhook = AsyncMock(side_effect=mock_404) +        self.assertFalse( +            await self.cog_instance.archive(incident=MockMessage(), outcome=MagicMock(), actioned_by=MockMember()) +        ) + +    async def test_archive_relays_incident(self): +        """ +        If webhook is found, method relays `incident` properly. + +        This test will assert that the fetched webhook's `send` method is fed the correct arguments, +        and that the `archive` method returns True. +        """ +        webhook = MockAsyncWebhook() +        self.cog_instance.bot.fetch_webhook = AsyncMock(return_value=webhook)  # Patch in our webhook + +        # Define our own `incident` to be archived +        incident = MockMessage( +            content="this is an incident", +            author=MockUser(name="author_name", avatar_url="author_avatar"), +            id=123, +        ) +        built_embed = MagicMock(discord.Embed, id=123)  # We patch `make_embed` to return this + +        with patch("bot.cogs.moderation.incidents.make_embed", AsyncMock(return_value=(built_embed, None))): +            archive_return = await self.cog_instance.archive(incident, MagicMock(value="A"), MockMember()) + +        # Now we check that the webhook was given the correct args, and that `archive` returned True +        webhook.send.assert_called_once_with( +            embed=built_embed, +            username="author_name", +            avatar_url="author_avatar", +            file=None, +        ) +        self.assertTrue(archive_return) + +    async def test_archive_clyde_username(self): +        """ +        The archive webhook username is cleansed using `sub_clyde`. + +        Discord will reject any webhook with "clyde" in the username field, as it impersonates +        the official Clyde bot. Since we do not control what the username will be (the incident +        author name is used), we must ensure the name is cleansed, otherwise the relay may fail. + +        This test assumes the username is passed as a kwarg. If this test fails, please review +        whether the passed argument is being retrieved correctly. +        """ +        webhook = MockAsyncWebhook() +        self.cog_instance.bot.fetch_webhook = AsyncMock(return_value=webhook) + +        message_from_clyde = MockMessage(author=MockUser(name="clyde the great")) +        await self.cog_instance.archive(message_from_clyde, MagicMock(incidents.Signal), MockMember()) + +        self.assertNotIn("clyde", webhook.send.call_args.kwargs["username"]) + + +class TestMakeConfirmationTask(TestIncidents): +    """ +    Tests for the `Incidents.make_confirmation_task` method. + +    Writing tests for this method is difficult, as it mostly just delegates the provided +    information elsewhere. There is very little internal logic. Whether our approach +    works conceptually is difficult to prove using unit tests. +    """ + +    def test_make_confirmation_task_check(self): +        """ +        The internal check will recognize the passed incident. + +        This is a little tricky - we first pass a message with a specific `id` in, and then +        retrieve the built check from the `call_args` of the `wait_for` method. This relies +        on the check being passed as a kwarg. + +        Once the check is retrieved, we assert that it gives True for our incident's `id`, +        and False for any other. + +        If this function begins to fail, first check that `created_check` is being retrieved +        correctly. It should be the function that is built locally in the tested method. +        """ +        self.cog_instance.make_confirmation_task(MockMessage(id=123)) + +        self.cog_instance.bot.wait_for.assert_called_once() +        created_check = self.cog_instance.bot.wait_for.call_args.kwargs["check"] + +        # The `message_id` matches the `id` of our incident +        self.assertTrue(created_check(payload=MagicMock(message_id=123))) + +        # This `message_id` does not match +        self.assertFalse(created_check(payload=MagicMock(message_id=0))) + + +@patch("bot.cogs.moderation.incidents.ALLOWED_ROLES", {1, 2}) +@patch("bot.cogs.moderation.incidents.Incidents.make_confirmation_task", AsyncMock())  # Generic awaitable +class TestProcessEvent(TestIncidents): +    """Tests for the `Incidents.process_event` coroutine.""" + +    async def test_process_event_bad_role(self): +        """The reaction is removed when the author lacks all allowed roles.""" +        incident = MockMessage() +        member = MockMember(roles=[MockRole(id=0)])  # Must have role 1 or 2 + +        await self.cog_instance.process_event("reaction", incident, member) +        incident.remove_reaction.assert_called_once_with("reaction", member) + +    async def test_process_event_bad_emoji(self): +        """ +        The reaction is removed when an invalid emoji is used. + +        This requires that we pass in a `member` with valid roles, as we need the role check +        to succeed. +        """ +        incident = MockMessage() +        member = MockMember(roles=[MockRole(id=1)])  # Member has allowed role + +        await self.cog_instance.process_event("invalid_signal", incident, member) +        incident.remove_reaction.assert_called_once_with("invalid_signal", member) + +    async def test_process_event_no_archive_on_investigating(self): +        """Message is not archived on `Signal.INVESTIGATING`.""" +        with patch("bot.cogs.moderation.incidents.Incidents.archive", AsyncMock()) as mocked_archive: +            await self.cog_instance.process_event( +                reaction=incidents.Signal.INVESTIGATING.value, +                incident=MockMessage(), +                member=MockMember(roles=[MockRole(id=1)]), +            ) + +        mocked_archive.assert_not_called() + +    async def test_process_event_no_delete_if_archive_fails(self): +        """ +        Original message is not deleted when `Incidents.archive` returns False. + +        This is the way of signaling that the relay failed, and we should not remove the original, +        as that would result in losing the incident record. +        """ +        incident = MockMessage() + +        with patch("bot.cogs.moderation.incidents.Incidents.archive", AsyncMock(return_value=False)): +            await self.cog_instance.process_event( +                reaction=incidents.Signal.ACTIONED.value, +                incident=incident, +                member=MockMember(roles=[MockRole(id=1)]) +            ) + +        incident.delete.assert_not_called() + +    async def test_process_event_confirmation_task_is_awaited(self): +        """Task given by `Incidents.make_confirmation_task` is awaited before method exits.""" +        mock_task = AsyncMock() + +        with patch("bot.cogs.moderation.incidents.Incidents.make_confirmation_task", mock_task): +            await self.cog_instance.process_event( +                reaction=incidents.Signal.ACTIONED.value, +                incident=MockMessage(), +                member=MockMember(roles=[MockRole(id=1)]) +            ) + +        mock_task.assert_awaited() + +    async def test_process_event_confirmation_task_timeout_is_handled(self): +        """ +        Confirmation task `asyncio.TimeoutError` is handled gracefully. + +        We have `make_confirmation_task` return a mock with a side effect, and then catch the +        exception should it propagate out of `process_event`. This is so that we can then manually +        fail the test with a more informative message than just the plain traceback. +        """ +        mock_task = AsyncMock(side_effect=asyncio.TimeoutError()) + +        try: +            with patch("bot.cogs.moderation.incidents.Incidents.make_confirmation_task", mock_task): +                await self.cog_instance.process_event( +                    reaction=incidents.Signal.ACTIONED.value, +                    incident=MockMessage(), +                    member=MockMember(roles=[MockRole(id=1)]) +                ) +        except asyncio.TimeoutError: +            self.fail("TimeoutError was not handled gracefully, and propagated out of `process_event`!") + + +class TestResolveMessage(TestIncidents): +    """Tests for the `Incidents.resolve_message` coroutine.""" + +    async def test_resolve_message_pass_message_id(self): +        """Method will call `_get_message` with the passed `message_id`.""" +        await self.cog_instance.resolve_message(123) +        self.cog_instance.bot._connection._get_message.assert_called_once_with(123) + +    async def test_resolve_message_in_cache(self): +        """ +        No API call is made if the queried message exists in the cache. + +        We mock the `_get_message` return value regardless of input. Whether it finds the message +        internally is considered d.py's responsibility, not ours. +        """ +        cached_message = MockMessage(id=123) +        self.cog_instance.bot._connection._get_message = MagicMock(return_value=cached_message) + +        return_value = await self.cog_instance.resolve_message(123) + +        self.assertIs(return_value, cached_message) +        self.cog_instance.bot.get_channel.assert_not_called()  # The `fetch_message` line was never hit + +    async def test_resolve_message_not_in_cache(self): +        """ +        The message is retrieved from the API if it isn't cached. + +        This is desired behaviour for messages which exist, but were sent before the bot's +        current session. +        """ +        self.cog_instance.bot._connection._get_message = MagicMock(return_value=None)  # Cache returns None + +        # API returns our message +        uncached_message = MockMessage() +        fetch_message = AsyncMock(return_value=uncached_message) +        self.cog_instance.bot.get_channel = MagicMock(return_value=MockTextChannel(fetch_message=fetch_message)) + +        retrieved_message = await self.cog_instance.resolve_message(123) +        self.assertIs(retrieved_message, uncached_message) + +    async def test_resolve_message_doesnt_exist(self): +        """ +        If the API returns a 404, the function handles it gracefully and returns None. + +        This is an edge-case happening with racing events - event A will relay the message +        to the archive and delete the original. Once event B acquires the `event_lock`, +        it will not find the message in the cache, and will ask the API. +        """ +        self.cog_instance.bot._connection._get_message = MagicMock(return_value=None)  # Cache returns None + +        fetch_message = AsyncMock(side_effect=mock_404) +        self.cog_instance.bot.get_channel = MagicMock(return_value=MockTextChannel(fetch_message=fetch_message)) + +        self.assertIsNone(await self.cog_instance.resolve_message(123)) + +    async def test_resolve_message_fetch_fails(self): +        """ +        Non-404 errors are handled, logged & None is returned. + +        In contrast with a 404, this should make an error-level log. We assert that at least +        one such log was made - we do not make any assertions about the log's message. +        """ +        self.cog_instance.bot._connection._get_message = MagicMock(return_value=None)  # Cache returns None + +        arbitrary_error = discord.HTTPException( +            response=MagicMock(aiohttp.ClientResponse), +            message="Arbitrary error", +        ) +        fetch_message = AsyncMock(side_effect=arbitrary_error) +        self.cog_instance.bot.get_channel = MagicMock(return_value=MockTextChannel(fetch_message=fetch_message)) + +        with self.assertLogs(logger=incidents.log, level=logging.ERROR): +            self.assertIsNone(await self.cog_instance.resolve_message(123)) + + +@patch("bot.constants.Channels.incidents", 123) +class TestOnRawReactionAdd(TestIncidents): +    """ +    Tests for the `Incidents.on_raw_reaction_add` listener. + +    Writing tests for this listener comes with additional complexity due to the listener +    awaiting the `crawl_task` task. See `asyncSetUp` for further details, which attempts +    to make unit testing this function possible. +    """ + +    def setUp(self): +        """ +        Prepare & assign `payload` attribute. + +        This attribute represents an *ideal* payload which will not be rejected by the +        listener. As each test will receive a fresh instance, it can be mutated to +        observe how the listener's behaviour changes with different attributes on +        the passed payload. +        """ +        super().setUp()  # Ensure `cog_instance` is assigned + +        self.payload = MagicMock( +            discord.RawReactionActionEvent, +            channel_id=123,  # Patched at class level +            message_id=456, +            member=MockMember(bot=False), +            emoji="reaction", +        ) + +    async def asyncSetUp(self):  # noqa: N802 +        """ +        Prepare an empty task and assign it as `crawl_task`. + +        It appears that the `unittest` framework does not provide anything for mocking +        asyncio tasks. An `AsyncMock` instance can be called and then awaited, however, +        it does not provide the `done` method or any other parts of the `asyncio.Task` +        interface. + +        Although we do not need to make any assertions about the task itself while +        testing the listener, the code will still await it and call the `done` method, +        and so we must inject something that will not fail on either action. + +        Note that this is done in an `asyncSetUp`, which runs after `setUp`. +        The justification is that creating an actual task requires the event +        loop to be ready, which is not the case in the `setUp`. +        """ +        mock_task = asyncio.create_task(AsyncMock()())  # Mock async func, then a coro +        self.cog_instance.crawl_task = mock_task + +    async def test_on_raw_reaction_add_wrong_channel(self): +        """ +        Events outside of #incidents will be ignored. + +        We check this by asserting that `resolve_message` was never queried. +        """ +        self.payload.channel_id = 0 +        self.cog_instance.resolve_message = AsyncMock() + +        await self.cog_instance.on_raw_reaction_add(self.payload) +        self.cog_instance.resolve_message.assert_not_called() + +    async def test_on_raw_reaction_add_user_is_bot(self): +        """ +        Events dispatched by bot accounts will be ignored. + +        We check this by asserting that `resolve_message` was never queried. +        """ +        self.payload.member = MockMember(bot=True) +        self.cog_instance.resolve_message = AsyncMock() + +        await self.cog_instance.on_raw_reaction_add(self.payload) +        self.cog_instance.resolve_message.assert_not_called() + +    async def test_on_raw_reaction_add_message_doesnt_exist(self): +        """ +        Listener gracefully handles the case where `resolve_message` gives None. + +        We check this by asserting that `process_event` was never called. +        """ +        self.cog_instance.process_event = AsyncMock() +        self.cog_instance.resolve_message = AsyncMock(return_value=None) + +        await self.cog_instance.on_raw_reaction_add(self.payload) +        self.cog_instance.process_event.assert_not_called() + +    async def test_on_raw_reaction_add_message_is_not_an_incident(self): +        """ +        The event won't be processed if the related message is not an incident. + +        This is an edge-case that can happen if someone manually leaves a reaction +        on a pinned message, or a comment. + +        We check this by asserting that `process_event` was never called. +        """ +        self.cog_instance.process_event = AsyncMock() +        self.cog_instance.resolve_message = AsyncMock(return_value=MockMessage()) + +        with patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=False)): +            await self.cog_instance.on_raw_reaction_add(self.payload) + +        self.cog_instance.process_event.assert_not_called() + +    async def test_on_raw_reaction_add_valid_event_is_processed(self): +        """ +        If the reaction event is valid, it is passed to `process_event`. + +        This is the case when everything goes right: +            * The reaction was placed in #incidents, and not by a bot +            * The message was found successfully +            * The message qualifies as an incident + +        Additionally, we check that all arguments were passed as expected. +        """ +        incident = MockMessage(id=1) + +        self.cog_instance.process_event = AsyncMock() +        self.cog_instance.resolve_message = AsyncMock(return_value=incident) + +        with patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=True)): +            await self.cog_instance.on_raw_reaction_add(self.payload) + +        self.cog_instance.process_event.assert_called_with( +            "reaction",  # Defined in `self.payload` +            incident, +            self.payload.member, +        ) + + +class TestOnMessage(TestIncidents): +    """ +    Tests for the `Incidents.on_message` listener. + +    Notice the decorators mocking the `is_incident` return value. The `is_incidents` +    function is tested in `TestIsIncident` - here we do not worry about it. +    """ + +    @patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=True)) +    async def test_on_message_incident(self): +        """Messages qualifying as incidents are passed to `add_signals`.""" +        incident = MockMessage() + +        with patch("bot.cogs.moderation.incidents.add_signals", AsyncMock()) as mock_add_signals: +            await self.cog_instance.on_message(incident) + +        mock_add_signals.assert_called_once_with(incident) + +    @patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=False)) +    async def test_on_message_non_incident(self): +        """Messages not qualifying as incidents are ignored.""" +        with patch("bot.cogs.moderation.incidents.add_signals", AsyncMock()) as mock_add_signals: +            await self.cog_instance.on_message(MockMessage()) + +        mock_add_signals.assert_not_called() diff --git a/tests/bot/cogs/test_jams.py b/tests/bot/cogs/test_jams.py new file mode 100644 index 000000000..b4ad8535f --- /dev/null +++ b/tests/bot/cogs/test_jams.py @@ -0,0 +1,173 @@ +import unittest +from unittest.mock import AsyncMock, MagicMock, create_autospec + +from discord import CategoryChannel + +from bot.cogs import jams +from bot.constants import Roles +from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole, MockTextChannel + + +def get_mock_category(channel_count: int, name: str) -> CategoryChannel: +    """Return a mocked code jam category.""" +    category = create_autospec(CategoryChannel, spec_set=True, instance=True) +    category.name = name +    category.channels = [MockTextChannel() for _ in range(channel_count)] + +    return category + + +class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): +    """Tests for `createteam` command.""" + +    def setUp(self): +        self.bot = MockBot() +        self.admin_role = MockRole(name="Admins", id=Roles.admins) +        self.command_user = MockMember([self.admin_role]) +        self.guild = MockGuild([self.admin_role]) +        self.ctx = MockContext(bot=self.bot, author=self.command_user, guild=self.guild) +        self.cog = jams.CodeJams(self.bot) + +    async def test_too_small_amount_of_team_members_passed(self): +        """Should `ctx.send` and exit early when too small amount of members.""" +        for case in (1, 2): +            with self.subTest(amount_of_members=case): +                self.cog.create_channels = AsyncMock() +                self.cog.add_roles = AsyncMock() + +                self.ctx.reset_mock() +                members = (MockMember() for _ in range(case)) +                await self.cog.createteam(self.cog, self.ctx, "foo", members) + +                self.ctx.send.assert_awaited_once() +                self.cog.create_channels.assert_not_awaited() +                self.cog.add_roles.assert_not_awaited() + +    async def test_duplicate_members_provided(self): +        """Should `ctx.send` and exit early because duplicate members provided and total there is only 1 member.""" +        self.cog.create_channels = AsyncMock() +        self.cog.add_roles = AsyncMock() + +        member = MockMember() +        await self.cog.createteam(self.cog, self.ctx, "foo", (member for _ in range(5))) + +        self.ctx.send.assert_awaited_once() +        self.cog.create_channels.assert_not_awaited() +        self.cog.add_roles.assert_not_awaited() + +    async def test_result_sending(self): +        """Should call `ctx.send` when everything goes right.""" +        self.cog.create_channels = AsyncMock() +        self.cog.add_roles = AsyncMock() + +        members = [MockMember() for _ in range(5)] +        await self.cog.createteam(self.cog, self.ctx, "foo", members) + +        self.cog.create_channels.assert_awaited_once() +        self.cog.add_roles.assert_awaited_once() +        self.ctx.send.assert_awaited_once() + +    async def test_category_doesnt_exist(self): +        """Should create a new code jam category.""" +        subtests = ( +            [], +            [get_mock_category(jams.MAX_CHANNELS - 1, jams.CATEGORY_NAME)], +            [get_mock_category(jams.MAX_CHANNELS - 2, "other")], +        ) + +        for categories in subtests: +            self.guild.reset_mock() +            self.guild.categories = categories + +            with self.subTest(categories=categories): +                actual_category = await self.cog.get_category(self.guild) + +                self.guild.create_category_channel.assert_awaited_once() +                category_overwrites = self.guild.create_category_channel.call_args[1]["overwrites"] + +                self.assertFalse(category_overwrites[self.guild.default_role].read_messages) +                self.assertTrue(category_overwrites[self.guild.me].read_messages) +                self.assertEqual(self.guild.create_category_channel.return_value, actual_category) + +    async def test_category_channel_exist(self): +        """Should not try to create category channel.""" +        expected_category = get_mock_category(jams.MAX_CHANNELS - 2, jams.CATEGORY_NAME) +        self.guild.categories = [ +            get_mock_category(jams.MAX_CHANNELS - 2, "other"), +            expected_category, +            get_mock_category(0, jams.CATEGORY_NAME), +        ] + +        actual_category = await self.cog.get_category(self.guild) +        self.assertEqual(expected_category, actual_category) + +    async def test_channel_overwrites(self): +        """Should have correct permission overwrites for users and roles.""" +        leader = MockMember() +        members = [leader] + [MockMember() for _ in range(4)] +        overwrites = self.cog.get_overwrites(members, self.guild) + +        # Leader permission overwrites +        self.assertTrue(overwrites[leader].manage_messages) +        self.assertTrue(overwrites[leader].read_messages) +        self.assertTrue(overwrites[leader].manage_webhooks) +        self.assertTrue(overwrites[leader].connect) + +        # Other members permission overwrites +        for member in members[1:]: +            self.assertTrue(overwrites[member].read_messages) +            self.assertTrue(overwrites[member].connect) + +        # Everyone and verified role overwrite +        self.assertFalse(overwrites[self.guild.default_role].read_messages) +        self.assertFalse(overwrites[self.guild.default_role].connect) +        self.assertFalse(overwrites[self.guild.get_role(Roles.verified)].read_messages) +        self.assertFalse(overwrites[self.guild.get_role(Roles.verified)].connect) + +    async def test_team_channels_creation(self): +        """Should create new voice and text channel for team.""" +        members = [MockMember() for _ in range(5)] + +        self.cog.get_overwrites = MagicMock() +        self.cog.get_category = AsyncMock() +        self.ctx.guild.create_text_channel.return_value = MockTextChannel(mention="foobar-channel") +        actual = await self.cog.create_channels(self.guild, "my-team", members) + +        self.assertEqual("foobar-channel", actual) +        self.cog.get_overwrites.assert_called_once_with(members, self.guild) +        self.cog.get_category.assert_awaited_once_with(self.guild) + +        self.guild.create_text_channel.assert_awaited_once_with( +            "my-team", +            overwrites=self.cog.get_overwrites.return_value, +            category=self.cog.get_category.return_value +        ) +        self.guild.create_voice_channel.assert_awaited_once_with( +            "My Team", +            overwrites=self.cog.get_overwrites.return_value, +            category=self.cog.get_category.return_value +        ) + +    async def test_jam_roles_adding(self): +        """Should add team leader role to leader and jam role to every team member.""" +        leader_role = MockRole(name="Team Leader") +        jam_role = MockRole(name="Jammer") +        self.guild.get_role.side_effect = [leader_role, jam_role] + +        leader = MockMember() +        members = [leader] + [MockMember() for _ in range(4)] +        await self.cog.add_roles(self.guild, members) + +        leader.add_roles.assert_any_await(leader_role) +        for member in members: +            member.add_roles.assert_any_await(jam_role) + + +class CodeJamSetup(unittest.TestCase): +    """Test for `setup` function of `CodeJam` cog.""" + +    def test_setup(self): +        """Should call `bot.add_cog`.""" +        bot = MockBot() +        jams.setup(bot) +        bot.add_cog.assert_called_once() diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index 98dee7a1b..343e37db9 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -239,7 +239,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):          await self.cog.send_eval(ctx, 'MyAwesomeCode')          ctx.send.assert_called_once_with( -            '@LemonLemonishBeard#0042 :yay!: Return code 0.\n\n```py\n[No output]\n```' +            '@LemonLemonishBeard#0042 :yay!: Return code 0.\n\n```\n[No output]\n```'          )          self.cog.post_eval.assert_called_once_with('MyAwesomeCode')          self.cog.get_status_emoji.assert_called_once_with({'stdout': '', 'returncode': 0}) @@ -265,7 +265,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):          await self.cog.send_eval(ctx, 'MyAwesomeCode')          ctx.send.assert_called_once_with(              '@LemonLemonishBeard#0042 :yay!: Return code 0.' -            '\n\n```py\nWay too long beard\n```\nFull output: lookatmybeard.com' +            '\n\n```\nWay too long beard\n```\nFull output: lookatmybeard.com'          )          self.cog.post_eval.assert_called_once_with('MyAwesomeCode')          self.cog.get_status_emoji.assert_called_once_with({'stdout': 'Way too long beard', 'returncode': 0}) @@ -289,7 +289,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):          await self.cog.send_eval(ctx, 'MyAwesomeCode')          ctx.send.assert_called_once_with( -            '@LemonLemonishBeard#0042 :nope!: Return code 127.\n\n```py\nBeard got stuck in the eval\n```' +            '@LemonLemonishBeard#0042 :nope!: Return code 127.\n\n```\nBeard got stuck in the eval\n```'          )          self.cog.post_eval.assert_called_once_with('MyAwesomeCode')          self.cog.get_status_emoji.assert_called_once_with({'stdout': 'ERROR', 'returncode': 127}) | 
