diff options
Diffstat (limited to '')
| -rw-r--r-- | bot/__main__.py | 1 | ||||
| -rw-r--r-- | bot/cogs/dm_relay.py | 124 | ||||
| -rw-r--r-- | bot/cogs/duck_pond.py | 34 | ||||
| -rw-r--r-- | bot/cogs/error_handler.py | 2 | ||||
| -rw-r--r-- | bot/cogs/filtering.py | 171 | ||||
| -rw-r--r-- | bot/cogs/help.py | 13 | ||||
| -rw-r--r-- | bot/cogs/help_channels.py | 3 | ||||
| -rw-r--r-- | bot/cogs/moderation/infractions.py | 4 | ||||
| -rw-r--r-- | bot/cogs/python_news.py | 72 | ||||
| -rw-r--r-- | bot/cogs/snekbox.py | 9 | ||||
| -rw-r--r-- | bot/cogs/watchchannels/bigbrother.py | 19 | ||||
| -rw-r--r-- | bot/cogs/watchchannels/talentpool.py | 19 | ||||
| -rw-r--r-- | bot/cogs/watchchannels/watchchannel.py | 10 | ||||
| -rw-r--r-- | bot/constants.py | 3 | ||||
| -rw-r--r-- | bot/converters.py | 19 | ||||
| -rw-r--r-- | bot/utils/webhooks.py | 34 | ||||
| -rw-r--r-- | config-default.yml | 4 | ||||
| -rw-r--r-- | tests/bot/cogs/test_duck_pond.py | 51 | ||||
| -rw-r--r-- | tests/bot/cogs/test_snekbox.py | 12 | 
19 files changed, 427 insertions, 177 deletions
| diff --git a/bot/__main__.py b/bot/__main__.py index 37e62c2f1..49388455a 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -54,6 +54,7 @@ bot.load_extension("bot.cogs.verification")  # Feature cogs  bot.load_extension("bot.cogs.alias")  bot.load_extension("bot.cogs.defcon") +bot.load_extension("bot.cogs.dm_relay")  bot.load_extension("bot.cogs.duck_pond")  bot.load_extension("bot.cogs.eval")  bot.load_extension("bot.cogs.information") diff --git a/bot/cogs/dm_relay.py b/bot/cogs/dm_relay.py new file mode 100644 index 000000000..0dc15d4b1 --- /dev/null +++ b/bot/cogs/dm_relay.py @@ -0,0 +1,124 @@ +import logging +from typing import Optional + +import discord +from discord import Color +from discord.ext import commands +from discord.ext.commands import Cog + +from bot import constants +from bot.bot import Bot +from bot.converters import UserMentionOrID +from bot.utils import RedisCache +from bot.utils.checks import in_whitelist_check, with_role_check +from bot.utils.messages import send_attachments +from bot.utils.webhooks import send_webhook + +log = logging.getLogger(__name__) + + +class DMRelay(Cog): +    """Relay direct messages to and from the bot.""" + +    # RedisCache[str, t.Union[discord.User.id, discord.Member.id]] +    dm_cache = RedisCache() + +    def __init__(self, bot: Bot): +        self.bot = bot +        self.webhook_id = constants.Webhooks.dm_log +        self.webhook = None +        self.bot.loop.create_task(self.fetch_webhook()) + +    @commands.command(aliases=("reply",)) +    async def send_dm(self, ctx: commands.Context, member: Optional[UserMentionOrID], *, message: str) -> None: +        """ +        Allows you to send a DM to a user from the bot. + +        If `member` is not provided, it will send to the last user who DM'd the bot. + +        This feature should be used extremely sparingly. Use ModMail if you need to have a serious +        conversation with a user. This is just for responding to extraordinary DMs, having a little +        fun with users, and telling people they are DMing the wrong bot. + +        NOTE: This feature will be removed if it is overused. +        """ +        if not member: +            user_id = await self.dm_cache.get("last_user") +            member = ctx.guild.get_member(user_id) if user_id else None + +        # If we still don't have a Member at this point, give up +        if not member: +            log.debug("This bot has never gotten a DM, or the RedisCache has been cleared.") +            await ctx.message.add_reaction("❌") +            return + +        try: +            await member.send(message) +        except discord.errors.Forbidden: +            log.debug("User has disabled DMs.") +            await ctx.message.add_reaction("❌") +        else: +            await ctx.message.add_reaction("✅") +            self.bot.stats.incr("dm_relay.dm_sent") + +    async def fetch_webhook(self) -> None: +        """Fetches the webhook object, so we can post to it.""" +        await self.bot.wait_until_guild_available() + +        try: +            self.webhook = await self.bot.fetch_webhook(self.webhook_id) +        except discord.HTTPException: +            log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`") + +    @Cog.listener() +    async def on_message(self, message: discord.Message) -> None: +        """Relays the message's content and attachments to the dm_log channel.""" +        # Only relay DMs from humans +        if message.author.bot or message.guild or self.webhook is None: +            return + +        if message.clean_content: +            await send_webhook( +                webhook=self.webhook, +                content=message.clean_content, +                username=f"{message.author.display_name} ({message.author.id})", +                avatar_url=message.author.avatar_url +            ) +            await self.dm_cache.set("last_user", message.author.id) +            self.bot.stats.incr("dm_relay.dm_received") + +        # Handle any attachments +        if message.attachments: +            try: +                await send_attachments(message, self.webhook) +            except (discord.errors.Forbidden, discord.errors.NotFound): +                e = discord.Embed( +                    description=":x: **This message contained an attachment, but it could not be retrieved**", +                    color=Color.red() +                ) +                await send_webhook( +                    webhook=self.webhook, +                    embed=e, +                    username=message.author.display_name, +                    avatar_url=message.author.avatar_url +                ) +            except discord.HTTPException: +                log.exception("Failed to send an attachment to the webhook") + +    def cog_check(self, ctx: commands.Context) -> bool: +        """Only allow moderators to invoke the commands in this cog.""" +        checks = [ +            with_role_check(ctx, *constants.MODERATION_ROLES), +            in_whitelist_check( +                ctx, +                channels=[constants.Channels.dm_log], +                redirect=None, +                fail_silently=True, +            ) +        ] +        return all(checks) + + +def setup(bot: Bot) -> None: +    """Load the DMRelay  cog.""" +    bot.add_cog(DMRelay(bot)) diff --git a/bot/cogs/duck_pond.py b/bot/cogs/duck_pond.py index 5b6a7fd62..7021069fa 100644 --- a/bot/cogs/duck_pond.py +++ b/bot/cogs/duck_pond.py @@ -1,5 +1,5 @@  import logging -from typing import Optional, Union +from typing import Union  import discord  from discord import Color, Embed, Member, Message, RawReactionActionEvent, User, errors @@ -7,7 +7,8 @@ from discord.ext.commands import Cog  from bot import constants  from bot.bot import Bot -from bot.utils.messages import send_attachments, sub_clyde +from bot.utils.messages import send_attachments +from bot.utils.webhooks import send_webhook  log = logging.getLogger(__name__) @@ -18,6 +19,7 @@ class DuckPond(Cog):      def __init__(self, bot: Bot):          self.bot = bot          self.webhook_id = constants.Webhooks.duck_pond +        self.webhook = None          self.bot.loop.create_task(self.fetch_webhook())      async def fetch_webhook(self) -> None: @@ -47,24 +49,6 @@ class DuckPond(Cog):                          return True          return False -    async def send_webhook( -        self, -        content: Optional[str] = None, -        username: Optional[str] = None, -        avatar_url: Optional[str] = None, -        embed: Optional[Embed] = None, -    ) -> None: -        """Send a webhook to the duck_pond channel.""" -        try: -            await self.webhook.send( -                content=content, -                username=sub_clyde(username), -                avatar_url=avatar_url, -                embed=embed -            ) -        except discord.HTTPException: -            log.exception("Failed to send a message to the Duck Pool webhook") -      async def count_ducks(self, message: Message) -> int:          """          Count the number of ducks in the reactions of a specific message. @@ -94,10 +78,9 @@ class DuckPond(Cog):      async def relay_message(self, message: Message) -> None:          """Relays the message's content and attachments to the duck pond channel.""" -        clean_content = message.clean_content - -        if clean_content: -            await self.send_webhook( +        if message.clean_content: +            await send_webhook( +                webhook=self.webhook,                  content=message.clean_content,                  username=message.author.display_name,                  avatar_url=message.author.avatar_url @@ -111,7 +94,8 @@ class DuckPond(Cog):                      description=":x: **This message contained an attachment, but it could not be retrieved**",                      color=Color.red()                  ) -                await self.send_webhook( +                await send_webhook( +                    webhook=self.webhook,                      embed=e,                      username=message.author.display_name,                      avatar_url=message.author.avatar_url diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 5de961116..233851e41 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -170,7 +170,7 @@ class ErrorHandler(Cog):              await prepared_help_command              self.bot.stats.incr("errors.too_many_arguments")          elif isinstance(e, errors.BadArgument): -            await ctx.send(f"Bad argument: {e}\n") +            await ctx.send("Bad argument: Please double-check your input arguments and try again.\n")              await prepared_help_command              self.bot.stats.incr("errors.bad_argument")          elif isinstance(e, errors.BadUnionArgument): diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 099606b82..bd665f424 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -2,7 +2,7 @@ import asyncio  import logging  import re  from datetime import datetime, timedelta -from typing import List, Mapping, Optional, Union +from typing import List, Mapping, Optional, Tuple, Union  import dateutil  import discord.errors @@ -198,24 +198,67 @@ class Filtering(Cog):              # Update time when alert sent              await self.name_alerts.set(member.id, datetime.utcnow().timestamp()) -    async def _filter_message(self, msg: Message, delta: Optional[int] = None) -> None: -        """Filter the input message to see if it violates any of our rules, and then respond accordingly.""" +    async def filter_eval(self, result: str, msg: Message) -> bool: +        """ +        Filter the result of an !eval to see if it violates any of our rules, and then respond accordingly. + +        Also requires the original message, to check whether to filter and for mod logs. +        Returns whether a filter was triggered or not. +        """ +        filter_triggered = False          # Should we filter this message? -        role_whitelisted = False +        if self._check_filter(msg): +            for filter_name, _filter in self.filters.items(): +                # Is this specific filter enabled in the config? +                # We also do not need to worry about filters that take the full message, +                # since all we have is an arbitrary string. +                if _filter["enabled"] and _filter["content_only"]: +                    match = await _filter["function"](result) -        if type(msg.author) is Member:  # Only Member has roles, not User. -            for role in msg.author.roles: -                if role.id in Filter.role_whitelist: -                    role_whitelisted = True +                    if match: +                        # If this is a filter (not a watchlist), we set the variable so we know +                        # that it has been triggered +                        if _filter["type"] == "filter": +                            filter_triggered = True -        filter_message = ( -            msg.channel.id not in Filter.channel_whitelist  # Channel not in whitelist -            and not role_whitelisted                        # Role not in whitelist -            and not msg.author.bot                          # Author not a bot -        ) +                        # We do not have to check against DM channels since !eval cannot be used there. +                        channel_str = f"in {msg.channel.mention}" + +                        message_content, additional_embeds, additional_embeds_msg = self._add_stats( +                            filter_name, match, result +                        ) + +                        message = ( +                            f"The {filter_name} {_filter['type']} was triggered " +                            f"by **{msg.author}** " +                            f"(`{msg.author.id}`) {channel_str} using !eval with " +                            f"[the following message]({msg.jump_url}):\n\n" +                            f"{message_content}" +                        ) + +                        log.debug(message) -        # If none of the above, we can start filtering. -        if filter_message: +                        # Send pretty mod log embed to mod-alerts +                        await self.mod_log.send_log_message( +                            icon_url=Icons.filtering, +                            colour=Colour(Colours.soft_red), +                            title=f"{_filter['type'].title()} triggered!", +                            text=message, +                            thumbnail=msg.author.avatar_url_as(static_format="png"), +                            channel_id=Channels.mod_alerts, +                            ping_everyone=Filter.ping_everyone, +                            additional_embeds=additional_embeds, +                            additional_embeds_msg=additional_embeds_msg +                        ) + +                        break  # We don't want multiple filters to trigger + +        return filter_triggered + +    async def _filter_message(self, msg: Message, delta: Optional[int] = None) -> None: +        """Filter the input message to see if it violates any of our rules, and then respond accordingly.""" +        # Should we filter this message? +        if self._check_filter(msg):              for filter_name, _filter in self.filters.items():                  # Is this specific filter enabled in the config?                  if _filter["enabled"]: @@ -274,16 +317,9 @@ class Filtering(Cog):                          else:                              channel_str = f"in {msg.channel.mention}" -                        # Word and match stats for watch_regex -                        if filter_name == "watch_regex": -                            surroundings = match.string[max(match.start() - 10, 0): match.end() + 10] -                            message_content = ( -                                f"**Match:** '{match[0]}'\n" -                                f"**Location:** '...{escape_markdown(surroundings)}...'\n" -                                f"\n**Original Message:**\n{escape_markdown(msg.content)}" -                            ) -                        else:  # Use content of discord Message -                            message_content = msg.content +                        message_content, additional_embeds, additional_embeds_msg = self._add_stats( +                            filter_name, match, msg.content +                        )                          message = (                              f"The {filter_name} {_filter['type']} was triggered " @@ -295,30 +331,6 @@ class Filtering(Cog):                          log.debug(message) -                        self.bot.stats.incr(f"filters.{filter_name}") - -                        additional_embeds = None -                        additional_embeds_msg = None - -                        # The function returns True for invalid invites. -                        # They have no data so additional embeds can't be created for them. -                        if filter_name == "filter_invites" and match is not True: -                            additional_embeds = [] -                            for invite, data in match.items(): -                                embed = discord.Embed(description=( -                                    f"**Members:**\n{data['members']}\n" -                                    f"**Active:**\n{data['active']}" -                                )) -                                embed.set_author(name=data["name"]) -                                embed.set_thumbnail(url=data["icon"]) -                                embed.set_footer(text=f"Guild Invite Code: {invite}") -                                additional_embeds.append(embed) -                            additional_embeds_msg = "For the following guild(s):" - -                        elif filter_name == "watch_rich_embeds": -                            additional_embeds = msg.embeds -                            additional_embeds_msg = "With the following embed(s):" -                          # Send pretty mod log embed to mod-alerts                          await self.mod_log.send_log_message(                              icon_url=Icons.filtering, @@ -334,6 +346,63 @@ class Filtering(Cog):                          break  # We don't want multiple filters to trigger +    def _add_stats(self, name: str, match: Union[re.Match, dict, bool, List[discord.Embed]], content: str) -> Tuple[ +        str, Optional[List[discord.Embed]], Optional[str] +    ]: +        """Adds relevant statistical information to the relevant filter and increments the bot's stats.""" +        # Word and match stats for watch_regex +        if name == "watch_regex": +            surroundings = match.string[max(match.start() - 10, 0): match.end() + 10] +            message_content = ( +                f"**Match:** '{match[0]}'\n" +                f"**Location:** '...{escape_markdown(surroundings)}...'\n" +                f"\n**Original Message:**\n{escape_markdown(content)}" +            ) +        else:  # Use original content +            message_content = content + +        additional_embeds = None +        additional_embeds_msg = None + +        self.bot.stats.incr(f"filters.{name}") + +        # The function returns True for invalid invites. +        # They have no data so additional embeds can't be created for them. +        if name == "filter_invites" and match is not True: +            additional_embeds = [] +            for invite, data in match.items(): +                embed = discord.Embed(description=( +                    f"**Members:**\n{data['members']}\n" +                    f"**Active:**\n{data['active']}" +                )) +                embed.set_author(name=data["name"]) +                embed.set_thumbnail(url=data["icon"]) +                embed.set_footer(text=f"Guild Invite Code: {invite}") +                additional_embeds.append(embed) +            additional_embeds_msg = "For the following guild(s):" + +        elif name == "watch_rich_embeds": +            additional_embeds = match +            additional_embeds_msg = "With the following embed(s):" + +        return message_content, additional_embeds, additional_embeds_msg + +    @staticmethod +    def _check_filter(msg: Message) -> bool: +        """Check whitelists to see if we should filter this message.""" +        role_whitelisted = False + +        if type(msg.author) is Member:  # Only Member has roles, not User. +            for role in msg.author.roles: +                if role.id in Filter.role_whitelist: +                    role_whitelisted = True + +        return ( +            msg.channel.id not in Filter.channel_whitelist  # Channel not in whitelist +            and not role_whitelisted                        # Role not in whitelist +            and not msg.author.bot                          # Author not a bot +        ) +      @staticmethod      async def _has_watch_regex_match(text: str) -> Union[bool, re.Match]:          """ @@ -426,7 +495,7 @@ class Filtering(Cog):          return invite_data if invite_data else False      @staticmethod -    async def _has_rich_embed(msg: Message) -> bool: +    async def _has_rich_embed(msg: Message) -> Union[bool, List[discord.Embed]]:          """Determines if `msg` contains any rich embeds not auto-generated from a URL."""          if msg.embeds:              for embed in msg.embeds: @@ -435,7 +504,7 @@ class Filtering(Cog):                      if not embed.url or embed.url not in urls:                          # If `embed.url` does not exist or if `embed.url` is not part of the content                          # of the message, it's unlikely to be an auto-generated embed by Discord. -                        return True +                        return msg.embeds                      else:                          log.trace(                              "Found a rich embed sent by a regular user account, " diff --git a/bot/cogs/help.py b/bot/cogs/help.py index 5f3fc4750..3d1d6fd10 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -37,13 +37,12 @@ async def help_cleanup(bot: Bot, author: Member, message: Message) -> None:      await message.add_reaction(DELETE_EMOJI) -    try: -        await bot.wait_for("reaction_add", check=check, timeout=300) -        await message.delete() -    except TimeoutError: -        await message.remove_reaction(DELETE_EMOJI, bot.user) -    except NotFound: -        pass +    with suppress(NotFound): +        try: +            await bot.wait_for("reaction_add", check=check, timeout=300) +            await message.delete() +        except TimeoutError: +            await message.remove_reaction(DELETE_EMOJI, bot.user)  class HelpQueryNotFound(ValueError): diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 4d0c534b0..0c8cbb417 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -34,9 +34,6 @@ and will be yours until it has been inactive for {constants.HelpChannels.idle_mi  is closed manually with `!close`. When that happens, it will be set to **dormant** and moved into \  the **Help: Dormant** category. -You may claim a new channel once every {constants.HelpChannels.claim_minutes} minutes. If you \ -currently cannot send a message in this channel, it means you are on cooldown and need to wait. -  Try to write the best question you can by providing a detailed description and telling us what \  you've tried already. For more information on asking a good question, \  check out our guide on [asking good questions]({ASKING_GUIDE_URL}). diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 3b28526b2..8df642428 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -64,7 +64,7 @@ class Infractions(InfractionScheduler, commands.Cog):      @command()      async def kick(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None:          """Kick a user for the given reason.""" -        await self.apply_kick(ctx, user, reason, active=False) +        await self.apply_kick(ctx, user, reason)      @command()      async def ban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: @@ -134,7 +134,7 @@ class Infractions(InfractionScheduler, commands.Cog):      @command(hidden=True, aliases=['shadowkick', 'skick'])      async def shadow_kick(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None:          """Kick a user for the given reason without notifying the user.""" -        await self.apply_kick(ctx, user, reason, hidden=True, active=False) +        await self.apply_kick(ctx, user, reason, hidden=True)      @command(hidden=True, aliases=['shadowban', 'sban'])      async def shadow_ban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: diff --git a/bot/cogs/python_news.py b/bot/cogs/python_news.py index adefd5c7c..0ab5738a4 100644 --- a/bot/cogs/python_news.py +++ b/bot/cogs/python_news.py @@ -10,7 +10,7 @@ from discord.ext.tasks import loop  from bot import constants  from bot.bot import Bot -from bot.utils.messages import sub_clyde +from bot.utils.webhooks import send_webhook  PEPS_RSS_URL = "https://www.python.org/dev/peps/peps.rss/" @@ -100,13 +100,21 @@ class PythonNews(Cog):              ):                  continue -            msg = await self.send_webhook( +            # Build an embed and send a webhook +            embed = discord.Embed(                  title=new["title"],                  description=new["summary"],                  timestamp=new_datetime,                  url=new["link"], -                webhook_profile_name=data["feed"]["title"], -                footer=data["feed"]["title"] +                colour=constants.Colours.soft_green +            ) +            embed.set_footer(text=data["feed"]["title"], icon_url=AVATAR_URL) +            msg = await send_webhook( +                webhook=self.webhook, +                username=data["feed"]["title"], +                embed=embed, +                avatar_url=AVATAR_URL, +                wait=True,              )              payload["data"]["pep"].append(pep_nr) @@ -161,15 +169,29 @@ class PythonNews(Cog):                  content = email_information["content"]                  link = THREAD_URL.format(id=thread["href"].split("/")[-2], list=maillist) -                msg = await self.send_webhook( + +                # Build an embed and send a message to the webhook +                embed = discord.Embed(                      title=thread_information["subject"],                      description=content[:500] + f"... [continue reading]({link})" if len(content) > 500 else content,                      timestamp=new_date,                      url=link, -                    author=f"{email_information['sender_name']} ({email_information['sender']['address']})", -                    author_url=MAILMAN_PROFILE_URL.format(id=email_information["sender"]["mailman_id"]), -                    webhook_profile_name=self.webhook_names[maillist], -                    footer=f"Posted to {self.webhook_names[maillist]}" +                    colour=constants.Colours.soft_green +                ) +                embed.set_author( +                    name=f"{email_information['sender_name']} ({email_information['sender']['address']})", +                    url=MAILMAN_PROFILE_URL.format(id=email_information["sender"]["mailman_id"]), +                ) +                embed.set_footer( +                    text=f"Posted to {self.webhook_names[maillist]}", +                    icon_url=AVATAR_URL, +                ) +                msg = await send_webhook( +                    webhook=self.webhook, +                    username=self.webhook_names[maillist], +                    embed=embed, +                    avatar_url=AVATAR_URL, +                    wait=True,                  )                  payload["data"][maillist].append(thread_information["thread_id"]) @@ -182,38 +204,6 @@ class PythonNews(Cog):          await self.bot.api_client.put("bot/bot-settings/news", json=payload) -    async def send_webhook(self, -                           title: str, -                           description: str, -                           timestamp: datetime, -                           url: str, -                           webhook_profile_name: str, -                           footer: str, -                           author: t.Optional[str] = None, -                           author_url: t.Optional[str] = None, -                           ) -> discord.Message: -        """Send webhook entry and return sent message.""" -        embed = discord.Embed( -            title=title, -            description=description, -            timestamp=timestamp, -            url=url, -            colour=constants.Colours.soft_green -        ) -        if author and author_url: -            embed.set_author( -                name=author, -                url=author_url -            ) -        embed.set_footer(text=footer, icon_url=AVATAR_URL) - -        return await self.webhook.send( -            embed=embed, -            username=sub_clyde(webhook_profile_name), -            avatar_url=AVATAR_URL, -            wait=True -        ) -      async def get_thread_and_first_mail(self, maillist: str, thread_identifier: str) -> t.Tuple[t.Any, t.Any]:          """Get mail thread and first mail from mail.python.org based on `maillist` and `thread_identifier`."""          async with self.bot.http_session.get( diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index a2a7574d4..662f90869 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -212,7 +212,14 @@ class Snekbox(Cog):              else:                  self.bot.stats.incr("snekbox.python.success") -            response = await ctx.send(msg) +            filter_cog = self.bot.get_cog("Filtering") +            filter_triggered = False +            if filter_cog: +                filter_triggered = await filter_cog.filter_eval(msg, ctx.message) +            if filter_triggered: +                response = await ctx.send("Attempt to circumvent filter detected. Moderator team has been alerted.") +            else: +                response = await ctx.send(msg)              self.bot.loop.create_task(                  wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot)              ) diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index 702d371f4..4d27a6333 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -35,14 +35,29 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):      @bigbrother_group.command(name='watched', aliases=('all', 'list'))      @with_role(*MODERATION_ROLES) -    async def watched_command(self, ctx: Context, update_cache: bool = True) -> None: +    async def watched_command( +        self, ctx: Context, oldest_first: bool = False, update_cache: bool = True +    ) -> None:          """          Shows the users that are currently being monitored by Big Brother. +        The optional kwarg `oldest_first` can be used to order the list by oldest watched. + +        The optional kwarg `update_cache` can be used to update the user +        cache using the API before listing the users. +        """ +        await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache) + +    @bigbrother_group.command(name='oldest') +    @with_role(*MODERATION_ROLES) +    async def oldest_command(self, ctx: Context, update_cache: bool = True) -> None: +        """ +        Shows Big Brother monitored users ordered by oldest watched. +          The optional kwarg `update_cache` can be used to update the user          cache using the API before listing the users.          """ -        await self.list_watched_users(ctx, update_cache) +        await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache)      @bigbrother_group.command(name='watch', aliases=('w',))      @with_role(*MODERATION_ROLES) diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index 33550f68e..89256e92e 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -38,14 +38,29 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):      @nomination_group.command(name='watched', aliases=('all', 'list'))      @with_role(*MODERATION_ROLES) -    async def watched_command(self, ctx: Context, update_cache: bool = True) -> None: +    async def watched_command( +        self, ctx: Context, oldest_first: bool = False, update_cache: bool = True +    ) -> None:          """          Shows the users that are currently being monitored in the talent pool. +        The optional kwarg `oldest_first` can be used to order the list by oldest nomination. + +        The optional kwarg `update_cache` can be used to update the user +        cache using the API before listing the users. +        """ +        await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache) + +    @nomination_group.command(name='oldest') +    @with_role(*MODERATION_ROLES) +    async def oldest_command(self, ctx: Context, update_cache: bool = True) -> None: +        """ +        Shows talent pool monitored users ordered by oldest nomination. +          The optional kwarg `update_cache` can be used to update the user          cache using the API before listing the users.          """ -        await self.list_watched_users(ctx, update_cache) +        await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache)      @nomination_group.command(name='watch', aliases=('w', 'add', 'a'))      @with_role(*STAFF_ROLES) diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index 7c58a0fb5..044077350 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -287,10 +287,14 @@ class WatchChannel(metaclass=CogABCMeta):          await self.webhook_send(embed=embed, username=msg.author.display_name, avatar_url=msg.author.avatar_url) -    async def list_watched_users(self, ctx: Context, update_cache: bool = True) -> None: +    async def list_watched_users( +        self, ctx: Context, oldest_first: bool = False, update_cache: bool = True +    ) -> None:          """          Gives an overview of the watched user list for this channel. +        The optional kwarg `oldest_first` orders the list by oldest entry. +          The optional kwarg `update_cache` specifies whether the cache should          be refreshed by polling the API.          """ @@ -305,7 +309,11 @@ class WatchChannel(metaclass=CogABCMeta):              time_delta = self._get_time_delta(inserted_at)              lines.append(f"• <@{user_id}> (added {time_delta})") +        if oldest_first: +            lines.reverse() +          lines = lines or ("There's nothing here yet.",) +          embed = Embed(              title=f"{self.__class__.__name__} watched users ({'updated' if update_cache else 'cached'})",              color=Color.blue() diff --git a/bot/constants.py b/bot/constants.py index a1b392c82..778bc093c 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -395,6 +395,7 @@ class Channels(metaclass=YAMLGetter):      dev_contrib: int      dev_core: int      dev_log: int +    dm_log: int      esoteric: int      helpers: int      how_to_get_help: int @@ -427,6 +428,7 @@ class Webhooks(metaclass=YAMLGetter):      reddit: int      duck_pond: int      dev_log: int +    dm_log: int  class Roles(metaclass=YAMLGetter): @@ -460,6 +462,7 @@ class Guild(metaclass=YAMLGetter):      staff_channels: List[int]      staff_roles: List[int] +  class Keys(metaclass=YAMLGetter):      section = "keys" diff --git a/bot/converters.py b/bot/converters.py index 898822165..4a0633951 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -330,6 +330,25 @@ def proxy_user(user_id: str) -> discord.Object:      return user +class UserMentionOrID(UserConverter): +    """ +    Converts to a `discord.User`, but only if a mention or userID is provided. + +    Unlike the default `UserConverter`, it does allow conversion from name, or name#descrim. + +    This is useful in cases where that lookup strategy would lead to ambiguity. +    """ + +    async def convert(self, ctx: Context, argument: str) -> discord.User: +        """Convert the `arg` to a `discord.User`.""" +        match = self._get_id_match(argument) or re.match(r'<@!?([0-9]+)>$', argument) + +        if match is not None: +            return await super().convert(ctx, argument) +        else: +            raise BadArgument(f"`{argument}` is not a User mention or a User ID.") + +  class FetchedUser(UserConverter):      """      Converts to a `discord.User` or, if it fails, a `discord.Object`. diff --git a/bot/utils/webhooks.py b/bot/utils/webhooks.py new file mode 100644 index 000000000..66f82ec66 --- /dev/null +++ b/bot/utils/webhooks.py @@ -0,0 +1,34 @@ +import logging +from typing import Optional + +import discord +from discord import Embed + +from bot.utils.messages import sub_clyde + +log = logging.getLogger(__name__) + + +async def send_webhook( +        webhook: discord.Webhook, +        content: Optional[str] = None, +        username: Optional[str] = None, +        avatar_url: Optional[str] = None, +        embed: Optional[Embed] = None, +        wait: Optional[bool] = False +) -> discord.Message: +    """ +    Send a message using the provided webhook. + +    This uses sub_clyde() and tries for an HTTPException to ensure it doesn't crash. +    """ +    try: +        return await webhook.send( +            content=content, +            username=sub_clyde(username), +            avatar_url=avatar_url, +            embed=embed, +            wait=wait, +        ) +    except discord.HTTPException: +        log.exception("Failed to send a message to the webhook!") diff --git a/config-default.yml b/config-default.yml index 19d79fa76..f2eb17b89 100644 --- a/config-default.yml +++ b/config-default.yml @@ -150,6 +150,7 @@ guild:          mod_log:            &MOD_LOG        282638479504965634          user_log:                           528976905546760203          voice_log:                          640292421988646961 +        dm_log:                             653713721625018428          # Off-topic          off_topic_0:    291284109232308226 @@ -251,10 +252,9 @@ guild:          duck_pond:                      637821475327311927          dev_log:                        680501655111729222          python_news:    &PYNEWS_WEBHOOK 704381182279942324 - +        dm_log:                         654567640664244225  filter: -      # What do we filter?      filter_zalgo:       false      filter_invites:     true diff --git a/tests/bot/cogs/test_duck_pond.py b/tests/bot/cogs/test_duck_pond.py index a8c0107c6..cfe10aebf 100644 --- a/tests/bot/cogs/test_duck_pond.py +++ b/tests/bot/cogs/test_duck_pond.py @@ -129,38 +129,6 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.IsolatedAsyncioTestCase):              ):                  self.assertEqual(expected_return, actual_return) -    def test_send_webhook_correctly_passes_on_arguments(self): -        """The `send_webhook` method should pass the arguments to the webhook correctly.""" -        self.cog.webhook = helpers.MockAsyncWebhook() - -        content = "fake content" -        username = "fake username" -        avatar_url = "fake avatar_url" -        embed = "fake embed" - -        asyncio.run(self.cog.send_webhook(content, username, avatar_url, embed)) - -        self.cog.webhook.send.assert_called_once_with( -            content=content, -            username=username, -            avatar_url=avatar_url, -            embed=embed -        ) - -    def test_send_webhook_logs_when_sending_message_fails(self): -        """The `send_webhook` method should catch a `discord.HTTPException` and log accordingly.""" -        self.cog.webhook = helpers.MockAsyncWebhook() -        self.cog.webhook.send.side_effect = discord.HTTPException(response=MagicMock(), message="Something failed.") - -        log = logging.getLogger('bot.cogs.duck_pond') -        with self.assertLogs(logger=log, level=logging.ERROR) as log_watcher: -            asyncio.run(self.cog.send_webhook()) - -        self.assertEqual(len(log_watcher.records), 1) - -        record = log_watcher.records[0] -        self.assertEqual(record.levelno, logging.ERROR) -      def _get_reaction(          self,          emoji: typing.Union[str, helpers.MockEmoji], @@ -280,16 +248,20 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.IsolatedAsyncioTestCase):      async def test_relay_message_correctly_relays_content_and_attachments(self):          """The `relay_message` method should correctly relay message content and attachments.""" -        send_webhook_path = f"{MODULE_PATH}.DuckPond.send_webhook" +        send_webhook_path = f"{MODULE_PATH}.send_webhook"          send_attachments_path = f"{MODULE_PATH}.send_attachments" +        author = MagicMock( +            display_name="x", +            avatar_url="https://" +        )          self.cog.webhook = helpers.MockAsyncWebhook()          test_values = ( -            (helpers.MockMessage(clean_content="", attachments=[]), False, False), -            (helpers.MockMessage(clean_content="message", attachments=[]), True, False), -            (helpers.MockMessage(clean_content="", attachments=["attachment"]), False, True), -            (helpers.MockMessage(clean_content="message", attachments=["attachment"]), True, True), +            (helpers.MockMessage(author=author, clean_content="", attachments=[]), False, False), +            (helpers.MockMessage(author=author, clean_content="message", attachments=[]), True, False), +            (helpers.MockMessage(author=author, clean_content="", attachments=["attachment"]), False, True), +            (helpers.MockMessage(author=author, clean_content="message", attachments=["attachment"]), True, True),          )          for message, expect_webhook_call, expect_attachment_call in test_values: @@ -314,14 +286,14 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.IsolatedAsyncioTestCase):          for side_effect in side_effects:  # pragma: no cover              send_attachments.side_effect = side_effect -            with patch(f"{MODULE_PATH}.DuckPond.send_webhook", new_callable=AsyncMock) as send_webhook: +            with patch(f"{MODULE_PATH}.send_webhook", new_callable=AsyncMock) as send_webhook:                  with self.subTest(side_effect=type(side_effect).__name__):                      with self.assertNotLogs(logger=log, level=logging.ERROR):                          await self.cog.relay_message(message)                      self.assertEqual(send_webhook.call_count, 2) -    @patch(f"{MODULE_PATH}.DuckPond.send_webhook", new_callable=AsyncMock) +    @patch(f"{MODULE_PATH}.send_webhook", new_callable=AsyncMock)      @patch(f"{MODULE_PATH}.send_attachments", new_callable=AsyncMock)      async def test_relay_message_handles_attachment_http_error(self, send_attachments, send_webhook):          """The `relay_message` method should handle irretrievable attachments.""" @@ -337,6 +309,7 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.IsolatedAsyncioTestCase):                  await self.cog.relay_message(message)              send_webhook.assert_called_once_with( +                webhook=self.cog.webhook,                  content=message.clean_content,                  username=message.author.display_name,                  avatar_url=message.author.avatar_url diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index cf9adbee0..98dee7a1b 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -233,6 +233,10 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):          self.cog.get_status_emoji = MagicMock(return_value=':yay!:')          self.cog.format_output = AsyncMock(return_value=('[No output]', None)) +        mocked_filter_cog = MagicMock() +        mocked_filter_cog.filter_eval = AsyncMock(return_value=False) +        self.bot.get_cog.return_value = mocked_filter_cog +          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```' @@ -254,6 +258,10 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):          self.cog.get_status_emoji = MagicMock(return_value=':yay!:')          self.cog.format_output = AsyncMock(return_value=('Way too long beard', 'lookatmybeard.com')) +        mocked_filter_cog = MagicMock() +        mocked_filter_cog.filter_eval = AsyncMock(return_value=False) +        self.bot.get_cog.return_value = mocked_filter_cog +          await self.cog.send_eval(ctx, 'MyAwesomeCode')          ctx.send.assert_called_once_with(              '@LemonLemonishBeard#0042 :yay!: Return code 0.' @@ -275,6 +283,10 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):          self.cog.get_status_emoji = MagicMock(return_value=':nope!:')          self.cog.format_output = AsyncMock()  # This function isn't called +        mocked_filter_cog = MagicMock() +        mocked_filter_cog.filter_eval = AsyncMock(return_value=False) +        self.bot.get_cog.return_value = mocked_filter_cog +          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```' | 
