diff options
| -rw-r--r-- | .github/workflows/codeql-analysis.yml | 32 | ||||
| -rw-r--r-- | bot/__main__.py | 8 | ||||
| -rw-r--r-- | bot/cogs/clean.py | 75 | ||||
| -rw-r--r-- | bot/cogs/filtering.py | 61 | ||||
| -rw-r--r-- | bot/cogs/help_channels.py | 2 | ||||
| -rw-r--r-- | bot/cogs/information.py | 65 | ||||
| -rw-r--r-- | bot/cogs/moderation/modlog.py | 4 | ||||
| -rw-r--r-- | bot/cogs/moderation/scheduler.py | 41 | ||||
| -rw-r--r-- | bot/cogs/moderation/utils.py | 1 | ||||
| -rw-r--r-- | bot/cogs/site.py | 5 | ||||
| -rw-r--r-- | bot/cogs/stats.py | 3 | ||||
| -rw-r--r-- | bot/cogs/sync/cog.py | 4 | ||||
| -rw-r--r-- | bot/cogs/sync/syncers.py | 3 | ||||
| -rw-r--r-- | bot/cogs/utils.py | 4 | ||||
| -rw-r--r-- | bot/constants.py | 1 | ||||
| -rw-r--r-- | bot/converters.py | 5 | ||||
| -rw-r--r-- | bot/resources/tags/modmail.md | 9 | ||||
| -rw-r--r-- | bot/utils/redis_cache.py | 11 | ||||
| -rw-r--r-- | config-default.yml | 2 | ||||
| -rw-r--r-- | docker-compose.yml | 9 | ||||
| -rw-r--r-- | tests/bot/cogs/sync/test_cog.py | 3 | ||||
| -rw-r--r-- | tests/bot/cogs/sync/test_users.py | 2 | ||||
| -rw-r--r-- | tests/bot/cogs/test_information.py | 12 | ||||
| -rw-r--r-- | tests/bot/test_converters.py | 113 | ||||
| -rw-r--r-- | tests/bot/utils/test_redis_cache.py | 10 | 
25 files changed, 322 insertions, 163 deletions
| diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 000000000..8760b35ec --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,32 @@ +name: "Code scanning - action" + +on: +  push: +  pull_request: +  schedule: +    - cron: '0 12 * * *' + +jobs: +  CodeQL-Build: + +    runs-on: ubuntu-latest + +    steps: +    - name: Checkout repository +      uses: actions/checkout@v2 +      with: +        fetch-depth: 2 + +    - run: git checkout HEAD^2 +      if: ${{ github.event_name == 'pull_request' }} + +    - name: Initialize CodeQL +      uses: github/codeql-action/init@v1 +      with: +        languages: python + +    - name: Autobuild +      uses: github/codeql-action/autobuild@v1 + +    - name: Perform CodeQL Analysis +      uses: github/codeql-action/analyze@v1 diff --git a/bot/__main__.py b/bot/__main__.py index aa1d1aee8..4e0d4a111 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -3,7 +3,9 @@ import logging  import discord  import sentry_sdk  from discord.ext.commands import when_mentioned_or +from sentry_sdk.integrations.aiohttp import AioHttpIntegration  from sentry_sdk.integrations.logging import LoggingIntegration +from sentry_sdk.integrations.redis import RedisIntegration  from bot import constants, patches  from bot.bot import Bot @@ -15,7 +17,11 @@ sentry_logging = LoggingIntegration(  sentry_sdk.init(      dsn=constants.Bot.sentry_dsn, -    integrations=[sentry_logging] +    integrations=[ +        sentry_logging, +        AioHttpIntegration(), +        RedisIntegration(), +    ]  )  bot = Bot( diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py index b5d9132cb..368d91c85 100644 --- a/bot/cogs/clean.py +++ b/bot/cogs/clean.py @@ -1,16 +1,16 @@  import logging  import random  import re -from typing import Optional +from typing import Iterable, Optional  from discord import Colour, Embed, Message, TextChannel, User +from discord.ext import commands  from discord.ext.commands import Cog, Context, group  from bot.bot import Bot  from bot.cogs.moderation import ModLog  from bot.constants import ( -    Channels, CleanMessages, Colours, Event, -    Icons, MODERATION_ROLES, NEGATIVE_REPLIES +    Channels, CleanMessages, Colours, Event, Icons, MODERATION_ROLES, NEGATIVE_REPLIES  )  from bot.decorators import with_role @@ -41,10 +41,10 @@ class Clean(Cog):          self,          amount: int,          ctx: Context, +        channels: Iterable[TextChannel],          bots_only: bool = False,          user: User = None,          regex: Optional[str] = None, -        channel: Optional[TextChannel] = None      ) -> None:          """A helper function that does the actual message cleaning."""          def predicate_bots_only(message: Message) -> bool: @@ -110,48 +110,39 @@ class Clean(Cog):              predicate = None                     # Delete all messages          # Default to using the invoking context's channel -        if not channel: -            channel = ctx.channel +        if not channels: +            channels = [ctx.channel] + +        # Delete the invocation first +        self.mod_log.ignore(Event.message_delete, ctx.message.id) +        await ctx.message.delete() -        # Look through the history and retrieve message data          messages = []          message_ids = []          self.cleaning = True -        invocation_deleted = False - -        # To account for the invocation message, we index `amount + 1` messages. -        async for message in channel.history(limit=amount + 1): -            # If at any point the cancel command is invoked, we should stop. -            if not self.cleaning: -                return +        # Find the IDs of the messages to delete. IDs are needed in order to ignore mod log events. +        for channel in channels: +            async for message in channel.history(limit=amount): -            # Always start by deleting the invocation -            if not invocation_deleted: -                self.mod_log.ignore(Event.message_delete, message.id) -                await message.delete() -                invocation_deleted = True -                continue +                # If at any point the cancel command is invoked, we should stop. +                if not self.cleaning: +                    return -            # If the message passes predicate, let's save it. -            if predicate is None or predicate(message): -                message_ids.append(message.id) -                messages.append(message) +                # If the message passes predicate, let's save it. +                if predicate is None or predicate(message): +                    message_ids.append(message.id)          self.cleaning = False -        # We should ignore the ID's we stored, so we don't get mod-log spam. +        # Now let's delete the actual messages with purge.          self.mod_log.ignore(Event.message_delete, *message_ids) - -        # Use bulk delete to actually do the cleaning. It's far faster. -        await channel.purge( -            limit=amount, -            check=predicate -        ) +        for channel in channels: +            messages += await channel.purge(limit=amount, check=predicate)          # Reverse the list to restore chronological order          if messages: -            messages = list(reversed(messages)) +            messages = reversed(messages)              log_url = await self.mod_log.upload_log(messages, ctx.author.id)          else:              # Can't build an embed, nothing to clean! @@ -163,8 +154,10 @@ class Clean(Cog):              return          # Build the embed and send it +        target_channels = ", ".join(channel.mention for channel in channels) +          message = ( -            f"**{len(message_ids)}** messages deleted in <#{channel.id}> by **{ctx.author.name}**\n\n" +            f"**{len(message_ids)}** messages deleted in {target_channels} by **{ctx.author.name}**\n\n"              f"A log of the deleted messages can be found [here]({log_url})."          ) @@ -189,10 +182,10 @@ class Clean(Cog):          ctx: Context,          user: User,          amount: Optional[int] = 10, -        channel: TextChannel = None +        channels: commands.Greedy[TextChannel] = None      ) -> None:          """Delete messages posted by the provided user, stop cleaning after traversing `amount` messages.""" -        await self._clean_messages(amount, ctx, user=user, channel=channel) +        await self._clean_messages(amount, ctx, user=user, channels=channels)      @clean_group.command(name="all", aliases=["everything"])      @with_role(*MODERATION_ROLES) @@ -200,10 +193,10 @@ class Clean(Cog):          self,          ctx: Context,          amount: Optional[int] = 10, -        channel: TextChannel = None +        channels: commands.Greedy[TextChannel] = None      ) -> None:          """Delete all messages, regardless of poster, stop cleaning after traversing `amount` messages.""" -        await self._clean_messages(amount, ctx, channel=channel) +        await self._clean_messages(amount, ctx, channels=channels)      @clean_group.command(name="bots", aliases=["bot"])      @with_role(*MODERATION_ROLES) @@ -211,10 +204,10 @@ class Clean(Cog):          self,          ctx: Context,          amount: Optional[int] = 10, -        channel: TextChannel = None +        channels: commands.Greedy[TextChannel] = None      ) -> None:          """Delete all messages posted by a bot, stop cleaning after traversing `amount` messages.""" -        await self._clean_messages(amount, ctx, bots_only=True, channel=channel) +        await self._clean_messages(amount, ctx, bots_only=True, channels=channels)      @clean_group.command(name="regex", aliases=["word", "expression"])      @with_role(*MODERATION_ROLES) @@ -223,10 +216,10 @@ class Clean(Cog):          ctx: Context,          regex: str,          amount: Optional[int] = 10, -        channel: TextChannel = None +        channels: commands.Greedy[TextChannel] = None      ) -> None:          """Delete all messages that match a certain regex, stop cleaning after traversing `amount` messages.""" -        await self._clean_messages(amount, ctx, regex=regex, channel=channel) +        await self._clean_messages(amount, ctx, regex=regex, channels=channels)      @clean_group.command(name="stop", aliases=["cancel", "abort"])      @with_role(*MODERATION_ROLES) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 1d9fddb12..4ebc831e1 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -1,6 +1,8 @@ +import asyncio  import logging  import re -from typing import Optional, Union +from datetime import datetime, timedelta +from typing import List, Optional, Union  import discord.errors  from dateutil.relativedelta import relativedelta @@ -14,6 +16,7 @@ from bot.constants import (      Channels, Colours,      Filter, Icons, URLs  ) +from bot.utils.redis_cache import RedisCache  log = logging.getLogger(__name__) @@ -40,6 +43,8 @@ TOKEN_WATCHLIST_PATTERNS = [  ]  WATCHLIST_PATTERNS = WORD_WATCHLIST_PATTERNS + TOKEN_WATCHLIST_PATTERNS +DAYS_BETWEEN_ALERTS = 3 +  def expand_spoilers(text: str) -> str:      """Return a string containing all interpretations of a spoilered message.""" @@ -52,8 +57,12 @@ def expand_spoilers(text: str) -> str:  class Filtering(Cog):      """Filtering out invites, blacklisting domains, and warning us of certain regular expressions.""" +    # Redis cache mapping a user ID to the last timestamp a bad nickname alert was sent +    name_alerts = RedisCache() +      def __init__(self, bot: Bot):          self.bot = bot +        self.name_lock = asyncio.Lock()          staff_mistake_str = "If you believe this was a mistake, please let staff know!"          self.filters = { @@ -112,6 +121,7 @@ class Filtering(Cog):      async def on_message(self, msg: Message) -> None:          """Invoke message filter for new messages."""          await self._filter_message(msg) +        await self.check_bad_words_in_name(msg.author)      @Cog.listener()      async def on_message_edit(self, before: Message, after: Message) -> None: @@ -126,6 +136,55 @@ class Filtering(Cog):              delta = relativedelta(after.edited_at, before.edited_at).microseconds          await self._filter_message(after, delta) +    @staticmethod +    def get_name_matches(name: str) -> List[re.Match]: +        """Check bad words from passed string (name). Return list of matches.""" +        matches = [] +        for pattern in WATCHLIST_PATTERNS: +            if match := pattern.search(name): +                matches.append(match) +        return matches + +    async def check_send_alert(self, member: Member) -> bool: +        """When there is less than 3 days after last alert, return `False`, otherwise `True`.""" +        if last_alert := await self.name_alerts.get(member.id): +            last_alert = datetime.utcfromtimestamp(last_alert) +            if datetime.utcnow() - timedelta(days=DAYS_BETWEEN_ALERTS) < last_alert: +                log.trace(f"Last alert was too recent for {member}'s nickname.") +                return False + +        return True + +    async def check_bad_words_in_name(self, member: Member) -> None: +        """Send a mod alert every 3 days if a username still matches a watchlist pattern.""" +        # Use lock to avoid race conditions +        async with self.name_lock: +            # Check whether the users display name contains any words in our blacklist +            matches = self.get_name_matches(member.display_name) + +            if not matches or not await self.check_send_alert(member): +                return + +            log.info(f"Sending bad nickname alert for '{member.display_name}' ({member.id}).") + +            log_string = ( +                f"**User:** {member.mention} (`{member.id}`)\n" +                f"**Display Name:** {member.display_name}\n" +                f"**Bad Matches:** {', '.join(match.group() for match in matches)}" +            ) + +            await self.mod_log.send_log_message( +                icon_url=Icons.token_removed, +                colour=Colours.soft_red, +                title="Username filtering alert", +                text=log_string, +                channel_id=Channels.mod_alerts, +                thumbnail=member.avatar_url +            ) + +            # Update time when alert sent +            await self.name_alerts.set(member.id, datetime.utcnow().timestamp()) +      async def _filter_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? diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 70cef339a..6ff285c37 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -22,7 +22,7 @@ log = logging.getLogger(__name__)  ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/"  MAX_CHANNELS_PER_CATEGORY = 50 -EXCLUDED_CHANNELS = (constants.Channels.how_to_get_help,) +EXCLUDED_CHANNELS = (constants.Channels.how_to_get_help, constants.Channels.cooldown)  HELP_CHANNEL_TOPIC = """  This is a Python help channel. You can claim your own help channel in the Python Help: Available category. diff --git a/bot/cogs/information.py b/bot/cogs/information.py index f0eb3a1ea..f0bd1afdb 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -6,7 +6,8 @@ from collections import Counter, defaultdict  from string import Template  from typing import Any, Mapping, Optional, Union -from discord import Colour, Embed, Member, Message, Role, Status, utils +from discord import ChannelType, Colour, Embed, Guild, Member, Message, Role, Status, utils +from discord.abc import GuildChannel  from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group  from discord.utils import escape_markdown @@ -26,6 +27,49 @@ class Information(Cog):      def __init__(self, bot: Bot):          self.bot = bot +    @staticmethod +    def role_can_read(channel: GuildChannel, role: Role) -> bool: +        """Return True if `role` can read messages in `channel`.""" +        overwrites = channel.overwrites_for(role) +        return overwrites.read_messages is True + +    def get_staff_channel_count(self, guild: Guild) -> int: +        """ +        Get the number of channels that are staff-only. + +        We need to know two things about a channel: +        - Does the @everyone role have explicit read deny permissions? +        - Do staff roles have explicit read allow permissions? + +        If the answer to both of these questions is yes, it's a staff channel. +        """ +        channel_ids = set() +        for channel in guild.channels: +            if channel.type is ChannelType.category: +                continue + +            everyone_can_read = self.role_can_read(channel, guild.default_role) + +            for role in constants.STAFF_ROLES: +                role_can_read = self.role_can_read(channel, guild.get_role(role)) +                if role_can_read and not everyone_can_read: +                    channel_ids.add(channel.id) +                    break + +        return len(channel_ids) + +    @staticmethod +    def get_channel_type_counts(guild: Guild) -> str: +        """Return the total amounts of the various types of channels in `guild`.""" +        channel_counter = Counter(c.type for c in guild.channels) +        channel_type_list = [] +        for channel, count in channel_counter.items(): +            channel_type = str(channel).title() +            channel_type_list.append(f"{channel_type} channels: {count}") + +        channel_type_list = sorted(channel_type_list) +        return "\n".join(channel_type_list) +      @with_role(*constants.MODERATION_ROLES)      @command(name="roles")      async def roles_info(self, ctx: Context) -> None: @@ -102,15 +146,16 @@ class Information(Cog):          roles = len(ctx.guild.roles)          member_count = ctx.guild.member_count - -        # How many of each type of channel? -        channels = Counter(c.type for c in ctx.guild.channels) -        channel_counts = "".join(sorted(f"{str(ch).title()} channels: {channels[ch]}\n" for ch in channels)).strip() +        channel_counts = self.get_channel_type_counts(ctx.guild)          # How many of each user status?          statuses = Counter(member.status for member in ctx.guild.members)          embed = Embed(colour=Colour.blurple()) +        # How many staff members and staff channels do we have? +        staff_member_count = len(ctx.guild.get_role(constants.Roles.helpers).members) +        staff_channel_count = self.get_staff_channel_count(ctx.guild) +          # Because channel_counts lacks leading whitespace, it breaks the dedent if it's inserted directly by the          # f-string. While this is correctly formated by Discord, it makes unit testing difficult. To keep the formatting          # without joining a tuple of strings we can use a Template string to insert the already-formatted channel_counts @@ -122,12 +167,16 @@ class Information(Cog):                  Voice region: {region}                  Features: {features} -                **Counts** +                **Channel counts** +                $channel_counts +                Staff channels: {staff_channel_count} + +                **Member counts**                  Members: {member_count:,} +                Staff members: {staff_member_count}                  Roles: {roles} -                $channel_counts -                **Members** +                **Member statuses**                  {constants.Emojis.status_online} {statuses[Status.online]:,}                  {constants.Emojis.status_idle} {statuses[Status.idle]:,}                  {constants.Emojis.status_dnd} {statuses[Status.dnd]:,} diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 9d28030d9..41472c64c 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -555,6 +555,10 @@ class ModLog(Cog, name="ModLog"):          channel = message.channel          author = message.author +        # Ignore DMs. +        if not message.guild: +            return +          if message.guild.id != GuildConstant.id or channel.id in GuildConstant.modlog_blacklist:              return diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index f0a3ad1b1..b03d89537 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -106,6 +106,27 @@ class InfractionScheduler(Scheduler):          log_content = None          failed = False +        # DM the user about the infraction if it's not a shadow/hidden infraction. +        # This needs to happen before we apply the infraction, as the bot cannot +        # send DMs to user that it doesn't share a guild with. If we were to +        # apply kick/ban infractions first, this would mean that we'd make it +        # impossible for us to deliver a DM. See python-discord/bot#982. +        if not infraction["hidden"]: +            dm_result = f"{constants.Emojis.failmail} " +            dm_log_text = "\nDM: **Failed**" + +            # Sometimes user is a discord.Object; make it a proper user. +            try: +                if not isinstance(user, (discord.Member, discord.User)): +                    user = await self.bot.fetch_user(user.id) +            except discord.HTTPException as e: +                log.error(f"Failed to DM {user.id}: could not fetch user (status {e.status})") +            else: +                # Accordingly display whether the user was successfully notified via DM. +                if await utils.notify_infraction(user, infr_type, expiry, reason, icon): +                    dm_result = ":incoming_envelope: " +                    dm_log_text = "\nDM: Sent" +          if infraction["actor"] == self.bot.user.id:              log.trace(                  f"Infraction #{id_} actor is bot; including the reason in the confirmation message." @@ -150,27 +171,7 @@ class InfractionScheduler(Scheduler):                      log.exception(log_msg)                  failed = True -        # DM the user about the infraction if it's not a shadow/hidden infraction. -        # Don't send DM when applying failed. -        if not infraction["hidden"] and not failed: -            dm_result = f"{constants.Emojis.failmail} " -            dm_log_text = "\nDM: **Failed**" - -            # Sometimes user is a discord.Object; make it a proper user. -            try: -                if not isinstance(user, (discord.Member, discord.User)): -                    user = await self.bot.fetch_user(user.id) -            except discord.HTTPException as e: -                log.error(f"Failed to DM {user.id}: could not fetch user (status {e.status})") -            else: -                # Accordingly display whether the user was successfully notified via DM. -                if await utils.notify_infraction(user, infr_type, expiry, reason, icon): -                    dm_result = ":incoming_envelope: " -                    dm_log_text = "\nDM: Sent" -          if failed: -            dm_log_text = "\nDM: **Canceled**" -            dm_result = f"{constants.Emojis.failmail} "              log.trace(f"Deleted infraction {infraction['id']} from database because applying infraction failed.")              try:                  await self.bot.api_client.delete(f"bot/infractions/{id_}") diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index 1b716b2ea..fb55287b6 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -41,7 +41,6 @@ async def post_user(ctx: Context, user: UserSnowflake) -> t.Optional[dict]:          log.debug("The user being added to the DB is not a Member or User object.")      payload = { -        'avatar_hash': getattr(user, 'avatar', 0),          'discriminator': int(getattr(user, 'discriminator', 0)),          'id': user.id,          'in_guild': False, diff --git a/bot/cogs/site.py b/bot/cogs/site.py index 7fc2a9c34..ac29daa1d 100644 --- a/bot/cogs/site.py +++ b/bot/cogs/site.py @@ -33,7 +33,7 @@ class Site(Cog):          embed.colour = Colour.blurple()          embed.description = (              f"[Our official website]({url}) is an open-source community project " -            "created with Python and Flask. It contains information about the server " +            "created with Python and Django. It contains information about the server "              "itself, lets you sign up for upcoming events, has its own wiki, contains "              "a list of valuable learning resources, and much more."          ) @@ -133,6 +133,9 @@ class Site(Cog):              await ctx.send(f":x: Invalid rule indices: {indices}")              return +        for rule in rules: +            self.bot.stats.incr(f"rule_uses.{rule}") +          final_rules = tuple(f"**{pick}.** {full_rules[pick - 1]}" for pick in rules)          await LinePaginator.paginate(final_rules, ctx, rules_embed, max_lines=3) diff --git a/bot/cogs/stats.py b/bot/cogs/stats.py index 4ebb6423c..d42f55466 100644 --- a/bot/cogs/stats.py +++ b/bot/cogs/stats.py @@ -36,7 +36,8 @@ class Stats(Cog):          if message.guild.id != Guild.id:              return -        if message.channel.category.id == Categories.modmail: +        cat = getattr(message.channel, "category", None) +        if cat is not None and cat.id == Categories.modmail:              if message.channel.id != Channels.incidents:                  # Do not report modmail channels to stats, there are too many                  # of them for interesting statistics to be drawn out of this. diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index 5708be3f4..7cc3726b2 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -94,7 +94,6 @@ class Sync(Cog):          the database, the user is added.          """          packed = { -            'avatar_hash': member.avatar,              'discriminator': int(member.discriminator),              'id': member.id,              'in_guild': True, @@ -135,12 +134,11 @@ class Sync(Cog):      @Cog.listener()      async def on_user_update(self, before: User, after: User) -> None:          """Update the user information in the database if a relevant change is detected.""" -        attrs = ("name", "discriminator", "avatar") +        attrs = ("name", "discriminator")          if any(getattr(before, attr) != getattr(after, attr) for attr in attrs):              updated_information = {                  "name": after.name,                  "discriminator": int(after.discriminator), -                "avatar_hash": after.avatar,              }              await self.patch_user(after.id, updated_information=updated_information) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index e55bf27fd..536455668 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -17,7 +17,7 @@ log = logging.getLogger(__name__)  # These objects are declared as namedtuples because tuples are hashable,  # something that we make use of when diffing site roles against guild roles.  _Role = namedtuple('Role', ('id', 'name', 'colour', 'permissions', 'position')) -_User = namedtuple('User', ('id', 'name', 'discriminator', 'avatar_hash', 'roles', 'in_guild')) +_User = namedtuple('User', ('id', 'name', 'discriminator', 'roles', 'in_guild'))  _Diff = namedtuple('Diff', ('created', 'updated', 'deleted')) @@ -298,7 +298,6 @@ class UserSyncer(Syncer):                  id=member.id,                  name=member.name,                  discriminator=int(member.discriminator), -                avatar_hash=member.avatar,                  roles=tuple(sorted(role.id for role in member.roles)),                  in_guild=True              ) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 73b4a1c0a..697bf60ce 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -6,7 +6,7 @@ from email.parser import HeaderParser  from io import StringIO  from typing import Tuple, Union -from discord import Colour, Embed +from discord import Colour, Embed, utils  from discord.ext.commands import BadArgument, Cog, Context, command  from bot.bot import Bot @@ -145,7 +145,7 @@ class Utils(Cog):                  u_code = f"\\U{digit:>08}"              url = f"https://www.compart.com/en/unicode/U+{digit:>04}"              name = f"[{unicodedata.name(char, '')}]({url})" -            info = f"`{u_code.ljust(10)}`: {name} - {char}" +            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)) diff --git a/bot/constants.py b/bot/constants.py index b31a9c99e..470221369 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -389,6 +389,7 @@ class Channels(metaclass=YAMLGetter):      attachment_log: int      big_brother_logs: int      bot_commands: int +    cooldown: int      defcon: int      dev_contrib: int      dev_core: int diff --git a/bot/converters.py b/bot/converters.py index 72c46fdf0..4deb59f87 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -217,7 +217,10 @@ class Duration(Converter):          delta = relativedelta(**duration_dict)          now = datetime.utcnow() -        return now + delta +        try: +            return now + delta +        except ValueError: +            raise BadArgument(f"`{duration}` results in a datetime outside the supported range.")  class ISODateTime(Converter): diff --git a/bot/resources/tags/modmail.md b/bot/resources/tags/modmail.md new file mode 100644 index 000000000..7545419ee --- /dev/null +++ b/bot/resources/tags/modmail.md @@ -0,0 +1,9 @@ +**Contacting the moderation team via ModMail** + +<@!683001325440860340> is a bot that will relay your messages to our moderation team, so that you can start a conversation with the moderation team. Your messages will be relayed to the entire moderator team, who will be able to respond to you via the bot. + +It supports attachments, codeblocks, and reactions. As communication happens over direct messages, the conversation will stay between you and the mod team. + +**To use it, simply send a direct message to the bot.** + +Should there be an urgent and immediate need for a moderator or admin to look at a channel, feel free to ping the <@&267629731250176001> or <@&267628507062992896> role instead. diff --git a/bot/utils/redis_cache.py b/bot/utils/redis_cache.py index de80cee84..354e987b9 100644 --- a/bot/utils/redis_cache.py +++ b/bot/utils/redis_cache.py @@ -100,16 +100,7 @@ class RedisCache:      def _set_namespace(self, namespace: str) -> None:          """Try to set the namespace, but do not permit collisions.""" -        # We need a unique namespace, to prevent collisions. This loop -        # will try appending underscores to the end of the namespace until -        # it finds one that is unique. -        # -        # For example, if `john` and `john_`  are both taken, the namespace will -        # be `john__` at the end of this loop. -        while namespace in self._namespaces: -            namespace += "_" - -        log.trace(f"RedisCache setting namespace to {self._namespace}") +        log.trace(f"RedisCache setting namespace to {namespace}")          self._namespaces.append(namespace)          self._namespace = namespace diff --git a/config-default.yml b/config-default.yml index 2c85f5ef3..3388e5f78 100644 --- a/config-default.yml +++ b/config-default.yml @@ -142,6 +142,7 @@ guild:          # Python Help: Available          how_to_get_help:    704250143020417084 +        cooldown:           720603994149486673          # Logs          attachment_log:     &ATTACH_LOG     649243850006855680 @@ -297,6 +298,7 @@ filter:          - 613425648685547541  # Discord Developers          - 185590609631903755  # Blender Hub          - 420324994703163402  # /r/FlutterDev +        - 488751051629920277  # Python Atlanta      domain_blacklist:          - pornhub.com diff --git a/docker-compose.yml b/docker-compose.yml index 9884e35f0..cff7d33d6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,14 @@ services:      ports:        - "127.0.0.1:6379:6379" +  snekbox: +    image: pythondiscord/snekbox:latest +    init: true +    ipc: none +    ports: +     - "127.0.0.1:8060:8060" +    privileged: true +    web:      image: pythondiscord/site:latest      command: ["run", "--debug"] @@ -47,6 +55,7 @@ services:      depends_on:        - web        - redis +      - snekbox      environment:        BOT_TOKEN: ${BOT_TOKEN}        BOT_API_KEY: badbot13m0n8f570f942013fc818f234916ca531 diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index 81398c61f..14fd909c4 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -247,14 +247,12 @@ class SyncCogListenerTests(SyncCogTestCase):          before_data = {              "name": "old name",              "discriminator": "1234", -            "avatar": "old avatar",              "bot": False,          }          subtests = (              (True, "name", "name", "new name", "new name"),              (True, "discriminator", "discriminator", "8765", 8765), -            (True, "avatar", "avatar_hash", "9j2e9", "9j2e9"),              (False, "bot", "bot", True, True),          ) @@ -295,7 +293,6 @@ class SyncCogListenerTests(SyncCogTestCase):          )          data = { -            "avatar_hash": member.avatar,              "discriminator": int(member.discriminator),              "id": member.id,              "in_guild": True, diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py index 818883012..002a947ad 100644 --- a/tests/bot/cogs/sync/test_users.py +++ b/tests/bot/cogs/sync/test_users.py @@ -10,7 +10,6 @@ def fake_user(**kwargs):      kwargs.setdefault("id", 43)      kwargs.setdefault("name", "bob the test man")      kwargs.setdefault("discriminator", 1337) -    kwargs.setdefault("avatar_hash", None)      kwargs.setdefault("roles", (666,))      kwargs.setdefault("in_guild", True) @@ -32,7 +31,6 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase):          for member in members:              member = member.copy() -            member["avatar"] = member.pop("avatar_hash")              del member["in_guild"]              mock_member = helpers.MockMember(**member) diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index aca6b594f..79c0e0ad3 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -148,14 +148,18 @@ class InformationCogTests(unittest.TestCase):                  Voice region: {self.ctx.guild.region}                  Features: {', '.join(self.ctx.guild.features)} -                **Counts** -                Members: {self.ctx.guild.member_count:,} -                Roles: {len(self.ctx.guild.roles)} +                **Channel counts**                  Category channels: 1                  Text channels: 1                  Voice channels: 1 +                Staff channels: 0 + +                **Member counts** +                Members: {self.ctx.guild.member_count:,} +                Staff members: 0 +                Roles: {len(self.ctx.guild.roles)} -                **Members** +                **Member statuses**                  {constants.Emojis.status_online} 2                  {constants.Emojis.status_idle} 1                  {constants.Emojis.status_dnd} 4 diff --git a/tests/bot/test_converters.py b/tests/bot/test_converters.py index ca8cb6825..c42111f3f 100644 --- a/tests/bot/test_converters.py +++ b/tests/bot/test_converters.py @@ -1,5 +1,5 @@ -import asyncio  import datetime +import re  import unittest  from unittest.mock import MagicMock, patch @@ -16,7 +16,7 @@ from bot.converters import (  ) -class ConverterTests(unittest.TestCase): +class ConverterTests(unittest.IsolatedAsyncioTestCase):      """Tests our custom argument converters."""      @classmethod @@ -26,7 +26,7 @@ class ConverterTests(unittest.TestCase):          cls.fixed_utc_now = datetime.datetime.fromisoformat('2019-01-01T00:00:00') -    def test_tag_content_converter_for_valid(self): +    async def test_tag_content_converter_for_valid(self):          """TagContentConverter should return correct values for valid input."""          test_values = (              ('hello', 'hello'), @@ -35,10 +35,10 @@ class ConverterTests(unittest.TestCase):          for content, expected_conversion in test_values:              with self.subTest(content=content, expected_conversion=expected_conversion): -                conversion = asyncio.run(TagContentConverter.convert(self.context, content)) +                conversion = await TagContentConverter.convert(self.context, content)                  self.assertEqual(conversion, expected_conversion) -    def test_tag_content_converter_for_invalid(self): +    async def test_tag_content_converter_for_invalid(self):          """TagContentConverter should raise the proper exception for invalid input."""          test_values = (              ('', "Tag contents should not be empty, or filled with whitespace."), @@ -47,10 +47,10 @@ class ConverterTests(unittest.TestCase):          for value, exception_message in test_values:              with self.subTest(tag_content=value, exception_message=exception_message): -                with self.assertRaises(BadArgument, msg=exception_message): -                    asyncio.run(TagContentConverter.convert(self.context, value)) +                with self.assertRaisesRegex(BadArgument, re.escape(exception_message)): +                    await TagContentConverter.convert(self.context, value) -    def test_tag_name_converter_for_valid(self): +    async def test_tag_name_converter_for_valid(self):          """TagNameConverter should return the correct values for valid tag names."""          test_values = (              ('tracebacks', 'tracebacks'), @@ -60,10 +60,10 @@ class ConverterTests(unittest.TestCase):          for name, expected_conversion in test_values:              with self.subTest(name=name, expected_conversion=expected_conversion): -                conversion = asyncio.run(TagNameConverter.convert(self.context, name)) +                conversion = await TagNameConverter.convert(self.context, name)                  self.assertEqual(conversion, expected_conversion) -    def test_tag_name_converter_for_invalid(self): +    async def test_tag_name_converter_for_invalid(self):          """TagNameConverter should raise the correct exception for invalid tag names."""          test_values = (              ('👋', "Don't be ridiculous, you can't use that character!"), @@ -75,29 +75,29 @@ class ConverterTests(unittest.TestCase):          for invalid_name, exception_message in test_values:              with self.subTest(invalid_name=invalid_name, exception_message=exception_message): -                with self.assertRaises(BadArgument, msg=exception_message): -                    asyncio.run(TagNameConverter.convert(self.context, invalid_name)) +                with self.assertRaisesRegex(BadArgument, re.escape(exception_message)): +                    await TagNameConverter.convert(self.context, invalid_name) -    def test_valid_python_identifier_for_valid(self): +    async def test_valid_python_identifier_for_valid(self):          """ValidPythonIdentifier returns valid identifiers unchanged."""          test_values = ('foo', 'lemon')          for name in test_values:              with self.subTest(identifier=name): -                conversion = asyncio.run(ValidPythonIdentifier.convert(self.context, name)) +                conversion = await ValidPythonIdentifier.convert(self.context, name)                  self.assertEqual(name, conversion) -    def test_valid_python_identifier_for_invalid(self): +    async def test_valid_python_identifier_for_invalid(self):          """ValidPythonIdentifier raises the proper exception for invalid identifiers."""          test_values = ('nested.stuff', '#####')          for name in test_values:              with self.subTest(identifier=name):                  exception_message = f'`{name}` is not a valid Python identifier' -                with self.assertRaises(BadArgument, msg=exception_message): -                    asyncio.run(ValidPythonIdentifier.convert(self.context, name)) +                with self.assertRaisesRegex(BadArgument, re.escape(exception_message)): +                    await ValidPythonIdentifier.convert(self.context, name) -    def test_duration_converter_for_valid(self): +    async def test_duration_converter_for_valid(self):          """Duration returns the correct `datetime` for valid duration strings."""          test_values = (              # Simple duration strings @@ -159,35 +159,35 @@ class ConverterTests(unittest.TestCase):                  mock_datetime.utcnow.return_value = self.fixed_utc_now                  with self.subTest(duration=duration, duration_dict=duration_dict): -                    converted_datetime = asyncio.run(converter.convert(self.context, duration)) +                    converted_datetime = await converter.convert(self.context, duration)                      self.assertEqual(converted_datetime, expected_datetime) -    def test_duration_converter_for_invalid(self): +    async def test_duration_converter_for_invalid(self):          """Duration raises the right exception for invalid duration strings."""          test_values = (              # Units in wrong order -            ('1d1w'), -            ('1s1y'), +            '1d1w', +            '1s1y',              # Duplicated units -            ('1 year 2 years'), -            ('1 M 10 minutes'), +            '1 year 2 years', +            '1 M 10 minutes',              # Unknown substrings -            ('1MVes'), -            ('1y3breads'), +            '1MVes', +            '1y3breads',              # Missing amount -            ('ym'), +            'ym',              # Incorrect whitespace -            (" 1y"), -            ("1S "), -            ("1y  1m"), +            " 1y", +            "1S ", +            "1y  1m",              # Garbage -            ('Guido van Rossum'), -            ('lemon lemon lemon lemon lemon lemon lemon'), +            'Guido van Rossum', +            'lemon lemon lemon lemon lemon lemon lemon',          )          converter = Duration() @@ -195,10 +195,21 @@ class ConverterTests(unittest.TestCase):          for invalid_duration in test_values:              with self.subTest(invalid_duration=invalid_duration):                  exception_message = f'`{invalid_duration}` is not a valid duration string.' -                with self.assertRaises(BadArgument, msg=exception_message): -                    asyncio.run(converter.convert(self.context, invalid_duration)) +                with self.assertRaisesRegex(BadArgument, re.escape(exception_message)): +                    await converter.convert(self.context, invalid_duration) -    def test_isodatetime_converter_for_valid(self): +    @patch("bot.converters.datetime") +    async def test_duration_converter_out_of_range(self, mock_datetime): +        """Duration converter should raise BadArgument if datetime raises a ValueError.""" +        mock_datetime.__add__.side_effect = ValueError +        mock_datetime.utcnow.return_value = mock_datetime + +        duration = f"{datetime.MAXYEAR}y" +        exception_message = f"`{duration}` results in a datetime outside the supported range." +        with self.assertRaisesRegex(BadArgument, re.escape(exception_message)): +            await Duration().convert(self.context, duration) + +    async def test_isodatetime_converter_for_valid(self):          """ISODateTime converter returns correct datetime for valid datetime string."""          test_values = (              # `YYYY-mm-ddTHH:MM:SSZ` | `YYYY-mm-dd HH:MM:SSZ` @@ -243,37 +254,37 @@ class ConverterTests(unittest.TestCase):          for datetime_string, expected_dt in test_values:              with self.subTest(datetime_string=datetime_string, expected_dt=expected_dt): -                converted_dt = asyncio.run(converter.convert(self.context, datetime_string)) +                converted_dt = await converter.convert(self.context, datetime_string)                  self.assertIsNone(converted_dt.tzinfo)                  self.assertEqual(converted_dt, expected_dt) -    def test_isodatetime_converter_for_invalid(self): +    async def test_isodatetime_converter_for_invalid(self):          """ISODateTime converter raises the correct exception for invalid datetime strings."""          test_values = (              # Make sure it doesn't interfere with the Duration converter -            ('1Y'), -            ('1d'), -            ('1H'), +            '1Y', +            '1d', +            '1H',              # Check if it fails when only providing the optional time part -            ('10:10:10'), -            ('10:00'), +            '10:10:10', +            '10:00',              # Invalid date format -            ('19-01-01'), +            '19-01-01',              # Other non-valid strings -            ('fisk the tag master'), +            'fisk the tag master',          )          converter = ISODateTime()          for datetime_string in test_values:              with self.subTest(datetime_string=datetime_string):                  exception_message = f"`{datetime_string}` is not a valid ISO-8601 datetime string" -                with self.assertRaises(BadArgument, msg=exception_message): -                    asyncio.run(converter.convert(self.context, datetime_string)) +                with self.assertRaisesRegex(BadArgument, re.escape(exception_message)): +                    await converter.convert(self.context, datetime_string) -    def test_hush_duration_converter_for_valid(self): +    async def test_hush_duration_converter_for_valid(self):          """HushDurationConverter returns correct value for minutes duration or `"forever"` strings."""          test_values = (              ("0", 0), @@ -286,10 +297,10 @@ class ConverterTests(unittest.TestCase):          converter = HushDurationConverter()          for minutes_string, expected_minutes in test_values:              with self.subTest(minutes_string=minutes_string, expected_minutes=expected_minutes): -                converted = asyncio.run(converter.convert(self.context, minutes_string)) +                converted = await converter.convert(self.context, minutes_string)                  self.assertEqual(expected_minutes, converted) -    def test_hush_duration_converter_for_invalid(self): +    async def test_hush_duration_converter_for_invalid(self):          """HushDurationConverter raises correct exception for invalid minutes duration strings."""          test_values = (              ("16", "Duration must be at most 15 minutes."), @@ -299,5 +310,5 @@ class ConverterTests(unittest.TestCase):          converter = HushDurationConverter()          for invalid_minutes_string, exception_message in test_values:              with self.subTest(invalid_minutes_string=invalid_minutes_string, exception_message=exception_message): -                with self.assertRaisesRegex(BadArgument, exception_message): -                    asyncio.run(converter.convert(self.context, invalid_minutes_string)) +                with self.assertRaisesRegex(BadArgument, re.escape(exception_message)): +                    await converter.convert(self.context, invalid_minutes_string) diff --git a/tests/bot/utils/test_redis_cache.py b/tests/bot/utils/test_redis_cache.py index 8c1a40640..e5d6e4078 100644 --- a/tests/bot/utils/test_redis_cache.py +++ b/tests/bot/utils/test_redis_cache.py @@ -44,16 +44,6 @@ class RedisCacheTests(unittest.IsolatedAsyncioTestCase):          with self.assertRaises(RuntimeError):              await bad_cache.set("test", "me_up_deadman") -    def test_namespace_collision(self): -        """Test that we prevent colliding namespaces.""" -        bob_cache_1 = RedisCache() -        bob_cache_1._set_namespace("BobRoss") -        self.assertEqual(bob_cache_1._namespace, "BobRoss") - -        bob_cache_2 = RedisCache() -        bob_cache_2._set_namespace("BobRoss") -        self.assertEqual(bob_cache_2._namespace, "BobRoss_") -      async def test_set_get_item(self):          """Test that users can set and get items from the RedisDict."""          test_cases = ( | 
