diff options
Diffstat (limited to '')
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | bot/cogs/alias.py | 6 | ||||
| -rw-r--r-- | bot/cogs/antispam.py | 2 | ||||
| -rw-r--r-- | bot/cogs/defcon.py | 5 | ||||
| -rw-r--r-- | bot/cogs/doc.py | 3 | ||||
| -rw-r--r-- | bot/cogs/filtering.py | 2 | ||||
| -rw-r--r-- | bot/cogs/information.py | 86 | ||||
| -rw-r--r-- | bot/cogs/moderation/infractions.py | 12 | ||||
| -rw-r--r-- | bot/cogs/moderation/modlog.py | 30 | ||||
| -rw-r--r-- | bot/cogs/moderation/superstarify.py | 4 | ||||
| -rw-r--r-- | bot/cogs/off_topic_names.py | 48 | ||||
| -rw-r--r-- | bot/cogs/site.py | 6 | ||||
| -rw-r--r-- | bot/cogs/snekbox.py | 12 | ||||
| -rw-r--r-- | bot/cogs/utils.py | 132 | ||||
| -rw-r--r-- | bot/cogs/verification.py | 38 | ||||
| -rw-r--r-- | bot/constants.py | 7 | ||||
| -rw-r--r-- | bot/utils/checks.py | 48 | ||||
| -rw-r--r-- | bot/utils/time.py | 19 | ||||
| -rw-r--r-- | config-default.yml | 4 | ||||
| -rw-r--r-- | docker-compose.yml | 2 | ||||
| -rw-r--r-- | tests/helpers.py | 4 | ||||
| -rw-r--r-- | tests/utils/test_time.py | 62 | 
22 files changed, 428 insertions, 107 deletions
| diff --git a/.gitignore b/.gitignore index 261fa179f..a191523b6 100644 --- a/.gitignore +++ b/.gitignore @@ -116,3 +116,6 @@ config.yml  # JUnit XML reports from pytest  junit.xml + +# Mac OS .DS_Store, which is a file that stores custom attributes of its containing folder +.DS_Store diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index 6648805e9..5190c559b 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -79,10 +79,10 @@ class Alias (Cog):          """Alias for invoking <prefix>site faq."""          await self.invoke(ctx, "site faq") -    @command(name="rules", hidden=True) -    async def site_rules_alias(self, ctx: Context) -> None: +    @command(name="rules", aliases=("rule",), hidden=True) +    async def site_rules_alias(self, ctx: Context, *rules: int) -> None:          """Alias for invoking <prefix>site rules.""" -        await self.invoke(ctx, "site rules") +        await self.invoke(ctx, "site rules", *rules)      @command(name="reload", hidden=True)      async def extensions_reload_alias(self, ctx: Context, *extensions: Extension) -> None: diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index 1b394048a..1340eb608 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -59,7 +59,7 @@ class DeletionContext:      async def upload_messages(self, actor_id: int, modlog: ModLog) -> None:          """Method that takes care of uploading the queue and posting modlog alert.""" -        triggered_by_users = ", ".join(f"{m.display_name}#{m.discriminator} (`{m.id}`)" for m in self.members.values()) +        triggered_by_users = ", ".join(f"{m} (`{m.id}`)" for m in self.members.values())          mod_alert_message = (              f"**Triggered by:** {triggered_by_users}\n" diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index 70e101baa..38a0915e5 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -90,8 +90,7 @@ class Defcon(Cog):                  await member.kick(reason="DEFCON active, user is too new")                  message = ( -                    f"{member.name}#{member.discriminator} (`{member.id}`) " -                    f"was denied entry because their account is too new." +                    f"{member} (`{member.id}`) was denied entry because their account is too new."                  )                  if not message_sent: @@ -254,7 +253,7 @@ class Defcon(Cog):          `change` string may be one of the following: ('enabled', 'disabled', 'updated')          """ -        log_msg = f"**Staffer:** {actor.name}#{actor.discriminator} (`{actor.id}`)\n" +        log_msg = f"**Staffer:** {actor} (`{actor.id}`)\n"          if change.lower() == "enabled":              icon = Icons.defcon_enabled diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index a13464bff..65cabe46f 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -336,8 +336,7 @@ class Doc(commands.Cog):          await self.bot.api_client.post('bot/documentation-links', json=body)          log.info( -            f"User @{ctx.author.name}#{ctx.author.discriminator} ({ctx.author.id}) " -            "added a new documentation package:\n" +            f"User @{ctx.author} ({ctx.author.id}) added a new documentation package:\n"              f"Package name: {package_name}\n"              f"Base url: {base_url}\n"              f"Inventory URL: {inventory_url}" diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 265ae5160..1d1d74e74 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -186,7 +186,7 @@ class Filtering(Cog):                          message = (                              f"The {filter_name} {_filter['type']} was triggered " -                            f"by **{msg.author.name}#{msg.author.discriminator}** " +                            f"by **{msg.author}** "                              f"(`{msg.author.id}`) {channel_str} with [the "                              f"following message]({msg.jump_url}):\n\n"                              f"{msg.content}" diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 1afb37103..3a7ba0444 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -1,14 +1,18 @@  import colorsys  import logging +import pprint  import textwrap  import typing +from typing import Any, Mapping, Optional +import discord  from discord import CategoryChannel, Colour, Embed, Member, Role, TextChannel, VoiceChannel, utils -from discord.ext.commands import Bot, Cog, Context, command +from discord.ext import commands +from discord.ext.commands import Bot, BucketType, Cog, Context, command, group  from bot.constants import Channels, Emojis, MODERATION_ROLES, STAFF_ROLES -from bot.decorators import InChannelCheckFailure, with_role -from bot.utils.checks import with_role_check +from bot.decorators import InChannelCheckFailure, in_channel, with_role +from bot.utils.checks import cooldown_with_role_bypass, with_role_check  from bot.utils.time import time_since  log = logging.getLogger(__name__) @@ -229,6 +233,82 @@ class Information(Cog):          await ctx.send(embed=embed) +    def format_fields(self, mapping: Mapping[str, Any], field_width: Optional[int] = None) -> str: +        """Format a mapping to be readable to a human.""" +        # sorting is technically superfluous but nice if you want to look for a specific field +        fields = sorted(mapping.items(), key=lambda item: item[0]) + +        if field_width is None: +            field_width = len(max(mapping.keys(), key=len)) + +        out = '' + +        for key, val in fields: +            if isinstance(val, dict): +                # if we have dicts inside dicts we want to apply the same treatment to the inner dictionaries +                inner_width = int(field_width * 1.6) +                val = '\n' + self.format_fields(val, field_width=inner_width) + +            elif isinstance(val, str): +                # split up text since it might be long +                text = textwrap.fill(val, width=100, replace_whitespace=False) + +                # indent it, I guess you could do this with `wrap` and `join` but this is nicer +                val = textwrap.indent(text, ' ' * (field_width + len(': '))) + +                # the first line is already indented so we `str.lstrip` it +                val = val.lstrip() + +            if key == 'color': +                # makes the base 10 representation of a hex number readable to humans +                val = hex(val) + +            out += '{0:>{width}}: {1}\n'.format(key, val, width=field_width) + +        # remove trailing whitespace +        return out.rstrip() + +    @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=STAFF_ROLES) +    @group(invoke_without_command=True) +    @in_channel(Channels.bot, bypass_roles=STAFF_ROLES) +    async def raw(self, ctx: Context, *, message: discord.Message, json: bool = False) -> None: +        """Shows information about the raw API response.""" +        # I *guess* it could be deleted right as the command is invoked but I felt like it wasn't worth handling +        # doing this extra request is also much easier than trying to convert everything back into a dictionary again +        raw_data = await ctx.bot.http.get_message(message.channel.id, message.id) + +        paginator = commands.Paginator() + +        def add_content(title: str, content: str) -> None: +            paginator.add_line(f'== {title} ==\n') +            # replace backticks as it breaks out of code blocks. Spaces seemed to be the most reasonable solution. +            # we hope it's not close to 2000 +            paginator.add_line(content.replace('```', '`` `')) +            paginator.close_page() + +        if message.content: +            add_content('Raw message', message.content) + +        transformer = pprint.pformat if json else self.format_fields +        for field_name in ('embeds', 'attachments'): +            data = raw_data[field_name] + +            if not data: +                continue + +            total = len(data) +            for current, item in enumerate(data, start=1): +                title = f'Raw {field_name} ({current}/{total})' +                add_content(title, transformer(item)) + +        for page in paginator.pages: +            await ctx.send(page) + +    @raw.command() +    async def json(self, ctx: Context, message: discord.Message) -> None: +        """Shows information about the raw API response in a copy-pasteable Python format.""" +        await ctx.invoke(self.raw, message=message, json=True) +  def setup(bot: Bot) -> None:      """Information cog load.""" diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 592ead60f..f2ae7b95d 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -2,6 +2,7 @@ import logging  import textwrap  import typing as t  from datetime import datetime +from gettext import ngettext  import dateutil.parser  import discord @@ -436,7 +437,13 @@ class Infractions(Scheduler, commands.Cog):          # Default values for the confirmation message and mod log.          confirm_msg = f":ok_hand: applied" -        expiry_msg = f" until {expiry}" if expiry else " permanently" + +        # Specifying an expiry for a note or warning makes no sense. +        if infr_type in ("note", "warning"): +            expiry_msg = "" +        else: +            expiry_msg = f" until {expiry}" if expiry else " permanently" +          dm_result = ""          dm_log_text = ""          expiry_log_text = f"Expires: {expiry}" if expiry else "" @@ -463,7 +470,8 @@ class Infractions(Scheduler, commands.Cog):                  "bot/infractions",                  params={"user__id": str(user.id)}              ) -            end_msg = f" ({len(infractions)} infractions total)" +            total = len(infractions) +            end_msg = f" ({total} infraction{ngettext('', 's', total)} total)"          # Execute the necessary actions to apply the infraction on Discord.          if action_coro: diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 118503517..88f2b6c67 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -363,7 +363,7 @@ class ModLog(Cog, name="ModLog"):          await self.send_log_message(              Icons.user_ban, Colours.soft_red, -            "User banned", f"{member.name}#{member.discriminator} (`{member.id}`)", +            "User banned", f"{member} (`{member.id}`)",              thumbnail=member.avatar_url_as(static_format="png"),              channel_id=Channels.userlog          ) @@ -374,7 +374,7 @@ class ModLog(Cog, name="ModLog"):          if member.guild.id != GuildConstant.id:              return -        message = f"{member.name}#{member.discriminator} (`{member.id}`)" +        message = f"{member} (`{member.id}`)"          now = datetime.utcnow()          difference = abs(relativedelta(now, member.created_at)) @@ -402,7 +402,7 @@ class ModLog(Cog, name="ModLog"):          await self.send_log_message(              Icons.sign_out, Colours.soft_red, -            "User left", f"{member.name}#{member.discriminator} (`{member.id}`)", +            "User left", f"{member} (`{member.id}`)",              thumbnail=member.avatar_url_as(static_format="png"),              channel_id=Channels.userlog          ) @@ -419,7 +419,7 @@ class ModLog(Cog, name="ModLog"):          await self.send_log_message(              Icons.user_unban, Colour.blurple(), -            "User unbanned", f"{member.name}#{member.discriminator} (`{member.id}`)", +            "User unbanned", f"{member} (`{member.id}`)",              thumbnail=member.avatar_url_as(static_format="png"),              channel_id=Channels.modlog          ) @@ -511,7 +511,7 @@ class ModLog(Cog, name="ModLog"):          for item in sorted(changes):              message += f"{Emojis.bullet} {item}\n" -        message = f"**{after.name}#{after.discriminator}** (`{after.id}`)\n{message}" +        message = f"**{after}** (`{after.id}`)\n{message}"          await self.send_log_message(              Icons.user_update, Colour.blurple(), @@ -540,14 +540,14 @@ class ModLog(Cog, name="ModLog"):          if channel.category:              response = ( -                f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" +                f"**Author:** {author} (`{author.id}`)\n"                  f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n"                  f"**Message ID:** `{message.id}`\n"                  "\n"              )          else:              response = ( -                f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" +                f"**Author:** {author} (`{author.id}`)\n"                  f"**Channel:** #{channel.name} (`{channel.id}`)\n"                  f"**Message ID:** `{message.id}`\n"                  "\n" @@ -638,7 +638,7 @@ class ModLog(Cog, name="ModLog"):          if channel.category:              before_response = ( -                f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" +                f"**Author:** {author} (`{author.id}`)\n"                  f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n"                  f"**Message ID:** `{before.id}`\n"                  "\n" @@ -646,7 +646,7 @@ class ModLog(Cog, name="ModLog"):              )              after_response = ( -                f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" +                f"**Author:** {author} (`{author.id}`)\n"                  f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n"                  f"**Message ID:** `{before.id}`\n"                  "\n" @@ -654,7 +654,7 @@ class ModLog(Cog, name="ModLog"):              )          else:              before_response = ( -                f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" +                f"**Author:** {author} (`{author.id}`)\n"                  f"**Channel:** #{channel.name} (`{channel.id}`)\n"                  f"**Message ID:** `{before.id}`\n"                  "\n" @@ -662,7 +662,7 @@ class ModLog(Cog, name="ModLog"):              )              after_response = ( -                f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" +                f"**Author:** {author} (`{author.id}`)\n"                  f"**Channel:** #{channel.name} (`{channel.id}`)\n"                  f"**Message ID:** `{before.id}`\n"                  "\n" @@ -721,7 +721,7 @@ class ModLog(Cog, name="ModLog"):          if channel.category:              before_response = ( -                f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" +                f"**Author:** {author} (`{author.id}`)\n"                  f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n"                  f"**Message ID:** `{message.id}`\n"                  "\n" @@ -729,7 +729,7 @@ class ModLog(Cog, name="ModLog"):              )              after_response = ( -                f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" +                f"**Author:** {author} (`{author.id}`)\n"                  f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n"                  f"**Message ID:** `{message.id}`\n"                  "\n" @@ -737,7 +737,7 @@ class ModLog(Cog, name="ModLog"):              )          else:              before_response = ( -                f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" +                f"**Author:** {author} (`{author.id}`)\n"                  f"**Channel:** #{channel.name} (`{channel.id}`)\n"                  f"**Message ID:** `{message.id}`\n"                  "\n" @@ -745,7 +745,7 @@ class ModLog(Cog, name="ModLog"):              )              after_response = ( -                f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" +                f"**Author:** {author} (`{author.id}`)\n"                  f"**Channel:** #{channel.name} (`{channel.id}`)\n"                  f"**Message ID:** `{message.id}`\n"                  "\n" diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index ccc6395d9..82f8621fc 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -129,7 +129,7 @@ class Superstarify(Cog):              # Log to the mod_log channel              log.trace("Logging to the #mod-log channel. This could fail because of channel permissions.")              mod_log_message = ( -                f"**{member.name}#{member.discriminator}** (`{member.id}`)\n\n" +                f"**{member}** (`{member.id}`)\n\n"                  f"Superstarified member potentially tried to escape the prison.\n"                  f"Restored enforced nickname: `{forced_nick}`\n"                  f"Superstardom ends: **{end_timestamp_human}**" @@ -183,7 +183,7 @@ class Superstarify(Cog):          # Log to the mod_log channel          log.trace("Logging to the #mod-log channel. This could fail because of channel permissions.")          mod_log_message = ( -            f"**{member.name}#{member.discriminator}** (`{member.id}`)\n\n" +            f"**{member}** (`{member.id}`)\n\n"              f"Superstarified by **{ctx.author.name}**\n"              f"Old nickname: `{member.display_name}`\n"              f"New nickname: `{forced_nick}`\n" diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index 2977e4ebb..1f9fb0b4f 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -98,15 +98,42 @@ class OffTopicNames(Cog):      @otname_group.command(name='add', aliases=('a',))      @with_role(*MODERATION_ROLES)      async def add_command(self, ctx: Context, *names: OffTopicName) -> None: -        """Adds a new off-topic name to the rotation.""" +        """ +        Adds a new off-topic name to the rotation. + +        The name is not added if it is too similar to an existing name. +        """          # Chain multiple words to a single one          name = "-".join(names) -        await self.bot.api_client.post(f'bot/off-topic-channel-names', params={'name': name}) -        log.info( -            f"{ctx.author.name}#{ctx.author.discriminator}" -            f" added the off-topic channel name '{name}" -        ) +        existing_names = await self.bot.api_client.get('bot/off-topic-channel-names') +        close_match = difflib.get_close_matches(name, existing_names, n=1, cutoff=0.8) + +        if close_match: +            match = close_match[0] +            log.info( +                f"{ctx.author} tried to add channel name '{name}' but it was too similar to '{match}'" +            ) +            await ctx.send( +                f":x: The channel name `{name}` is too similar to `{match}`, and thus was not added. " +                "Use `!otn forceadd` to override this check." +            ) +        else: +            await self._add_name(ctx, name) + +    @otname_group.command(name='forceadd', aliases=('fa',)) +    @with_role(*MODERATION_ROLES) +    async def force_add_command(self, ctx: Context, *names: OffTopicName) -> None: +        """Forcefully adds a new off-topic name to the rotation.""" +        # Chain multiple words to a single one +        name = "-".join(names) +        await self._add_name(ctx, name) + +    async def _add_name(self, ctx: Context, name: str) -> None: +        """Adds an off-topic channel name to the site storage.""" +        await self.bot.api_client.post('bot/off-topic-channel-names', params={'name': name}) + +        log.info(f"{ctx.author} added the off-topic channel name '{name}'")          await ctx.send(f":ok_hand: Added `{name}` to the names list.")      @otname_group.command(name='delete', aliases=('remove', 'rm', 'del', 'd')) @@ -115,12 +142,9 @@ class OffTopicNames(Cog):          """Removes a off-topic name from the rotation."""          # Chain multiple words to a single one          name = "-".join(names) -          await self.bot.api_client.delete(f'bot/off-topic-channel-names/{name}') -        log.info( -            f"{ctx.author.name}#{ctx.author.discriminator}" -            f" deleted the off-topic channel name '{name}" -        ) + +        log.info(f"{ctx.author} deleted the off-topic channel name '{name}'")          await ctx.send(f":ok_hand: Removed `{name}` from the names list.")      @otname_group.command(name='list', aliases=('l',)) @@ -152,7 +176,7 @@ class OffTopicNames(Cog):          close_matches = difflib.get_close_matches(query, result, n=10, cutoff=0.70)          lines = sorted(f"• {name}" for name in in_matches.union(close_matches))          embed = Embed( -            title=f"Query results", +            title="Query results",              colour=Colour.blue()          ) diff --git a/bot/cogs/site.py b/bot/cogs/site.py index c3bdf85e4..d95359159 100644 --- a/bot/cogs/site.py +++ b/bot/cogs/site.py @@ -126,15 +126,15 @@ class Site(Cog):          invalid_indices = tuple(              pick              for pick in rules -            if pick < 0 or pick >= len(full_rules) +            if pick < 1 or pick > len(full_rules)          )          if invalid_indices:              indices = ', '.join(map(str, invalid_indices)) -            await ctx.send(f":x: Invalid rule indices {indices}") +            await ctx.send(f":x: Invalid rule indices: {indices}")              return -        final_rules = tuple(f"**{pick}.** {full_rules[pick]}" for pick in rules) +        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/snekbox.py b/bot/cogs/snekbox.py index 81185cf3e..c0390cb1e 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -178,7 +178,7 @@ class Snekbox(Cog):          if ctx.author.id in self.jobs:              await ctx.send(                  f"{ctx.author.mention} You've already got a job running - " -                f"please wait for it to finish!" +                "please wait for it to finish!"              )              return @@ -186,10 +186,7 @@ class Snekbox(Cog):              await ctx.invoke(self.bot.get_command("help"), "eval")              return -        log.info( -            f"Received code from {ctx.author.name}#{ctx.author.discriminator} " -            f"for evaluation:\n{code}" -        ) +        log.info(f"Received code from {ctx.author} for evaluation:\n{code}")          self.jobs[ctx.author.id] = datetime.datetime.now()          code = self.prepare_input(code) @@ -213,10 +210,7 @@ class Snekbox(Cog):                      wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot)                  ) -                log.info( -                    f"{ctx.author.name}#{ctx.author.discriminator}'s job had a return code of " -                    f"{results['returncode']}" -                ) +                log.info(f"{ctx.author}'s job had a return code of {results['returncode']}")          finally:              del self.jobs[ctx.author.id] diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index b6cecdc7c..793fe4c1a 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -1,15 +1,18 @@  import logging  import re  import unicodedata +from asyncio import TimeoutError, sleep  from email.parser import HeaderParser  from io import StringIO  from typing import Tuple -from discord import Colour, Embed +from dateutil import relativedelta +from discord import Colour, Embed, Message, Role  from discord.ext.commands import Bot, Cog, Context, command -from bot.constants import Channels, STAFF_ROLES -from bot.decorators import in_channel +from bot.constants import Channels, MODERATION_ROLES, Mention, STAFF_ROLES +from bot.decorators import in_channel, with_role +from bot.utils.time import humanize_delta  log = logging.getLogger(__name__) @@ -32,56 +35,58 @@ class Utils(Cog):              await ctx.invoke(self.bot.get_command("help"), "pep")              return -        # Newer PEPs are written in RST instead of txt -        if pep_number > 542: -            pep_url = f"{self.base_github_pep_url}{pep_number:04}.rst" -        else: -            pep_url = f"{self.base_github_pep_url}{pep_number:04}.txt" - -        # Attempt to fetch the PEP -        log.trace(f"Requesting PEP {pep_number} with {pep_url}") -        response = await self.bot.http_session.get(pep_url) - -        if response.status == 200: -            log.trace("PEP found") +        possible_extensions = ['.txt', '.rst'] +        found_pep = False +        for extension in possible_extensions: +            # Attempt to fetch the PEP +            pep_url = f"{self.base_github_pep_url}{pep_number:04}{extension}" +            log.trace(f"Requesting PEP {pep_number} with {pep_url}") +            response = await self.bot.http_session.get(pep_url) -            pep_content = await response.text() +            if response.status == 200: +                log.trace("PEP found") +                found_pep = True -            # Taken from https://github.com/python/peps/blob/master/pep0/pep.py#L179 -            pep_header = HeaderParser().parse(StringIO(pep_content)) +                pep_content = await response.text() -            # Assemble the embed -            pep_embed = Embed( -                title=f"**PEP {pep_number} - {pep_header['Title']}**", -                description=f"[Link]({self.base_pep_url}{pep_number:04})", -            ) - -            pep_embed.set_thumbnail(url="https://www.python.org/static/opengraph-icon-200x200.png") +                # Taken from https://github.com/python/peps/blob/master/pep0/pep.py#L179 +                pep_header = HeaderParser().parse(StringIO(pep_content)) -            # Add the interesting information -            if "Status" in pep_header: -                pep_embed.add_field(name="Status", value=pep_header["Status"]) -            if "Python-Version" in pep_header: -                pep_embed.add_field(name="Python-Version", value=pep_header["Python-Version"]) -            if "Created" in pep_header: -                pep_embed.add_field(name="Created", value=pep_header["Created"]) -            if "Type" in pep_header: -                pep_embed.add_field(name="Type", value=pep_header["Type"]) +                # Assemble the embed +                pep_embed = Embed( +                    title=f"**PEP {pep_number} - {pep_header['Title']}**", +                    description=f"[Link]({self.base_pep_url}{pep_number:04})", +                ) -        elif response.status == 404: +                pep_embed.set_thumbnail(url="https://www.python.org/static/opengraph-icon-200x200.png") + +                # Add the interesting information +                if "Status" in pep_header: +                    pep_embed.add_field(name="Status", value=pep_header["Status"]) +                if "Python-Version" in pep_header: +                    pep_embed.add_field(name="Python-Version", value=pep_header["Python-Version"]) +                if "Created" in pep_header: +                    pep_embed.add_field(name="Created", value=pep_header["Created"]) +                if "Type" in pep_header: +                    pep_embed.add_field(name="Type", value=pep_header["Type"]) + +            elif response.status != 404: +                # any response except 200 and 404 is expected +                found_pep = True  # actually not, but it's easier to display this way +                log.trace(f"The user requested PEP {pep_number}, but the response had an unexpected status code: " +                          f"{response.status}.\n{response.text}") + +                error_message = "Unexpected HTTP error during PEP search. Please let us know." +                pep_embed = Embed(title="Unexpected error", description=error_message) +                pep_embed.colour = Colour.red() +                break + +        if not found_pep:              log.trace("PEP was not found")              not_found = f"PEP {pep_number} does not exist."              pep_embed = Embed(title="PEP not found", description=not_found)              pep_embed.colour = Colour.red() -        else: -            log.trace(f"The user requested PEP {pep_number}, but the response had an unexpected status code: " -                      f"{response.status}.\n{response.text}") - -            error_message = "Unexpected HTTP error during PEP search. Please let us know." -            pep_embed = Embed(title="Unexpected error", description=error_message) -            pep_embed.colour = Colour.red() -          await ctx.message.channel.send(embed=pep_embed)      @command() @@ -128,6 +133,47 @@ class Utils(Cog):          await ctx.send(embed=embed) +    @command() +    @with_role(*MODERATION_ROLES) +    async def mention(self, ctx: Context, *, role: Role) -> None: +        """Set a role to be mentionable for a limited time.""" +        if role.mentionable: +            await ctx.send(f"{role} is already mentionable!") +            return + +        await role.edit(reason=f"Role unlocked by {ctx.author}", mentionable=True) + +        human_time = humanize_delta(relativedelta.relativedelta(seconds=Mention.message_timeout)) +        await ctx.send( +            f"{role} has been made mentionable. I will reset it in {human_time}, or when someone mentions this role." +        ) + +        def check(m: Message) -> bool: +            """Checks that the message contains the role mention.""" +            return role in m.role_mentions + +        try: +            msg = await self.bot.wait_for("message", check=check, timeout=Mention.message_timeout) +        except TimeoutError: +            await role.edit(mentionable=False, reason="Automatic role lock - timeout.") +            await ctx.send(f"{ctx.author.mention}, you took too long. I have reset {role} to be unmentionable.") +            return + +        if any(r.id in MODERATION_ROLES for r in msg.author.roles): +            await sleep(Mention.reset_delay) +            await role.edit(mentionable=False, reason=f"Automatic role lock by {msg.author}") +            await ctx.send( +                f"{ctx.author.mention}, I have reset {role} to be unmentionable as " +                f"{msg.author if msg.author != ctx.author else 'you'} sent a message mentioning it." +            ) +            return + +        await role.edit(mentionable=False, reason=f"Automatic role lock - unauthorised use by {msg.author}") +        await ctx.send( +            f"{ctx.author.mention}, I have reset {role} to be unmentionable " +            f"as I detected unauthorised use by {msg.author} (ID: {msg.author.id})." +        ) +  def setup(bot: Bot) -> None:      """Utils cog load.""" diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index acd7a7865..5b115deaa 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -1,10 +1,12 @@  import logging +from datetime import datetime  from discord import Message, NotFound, Object +from discord.ext import tasks  from discord.ext.commands import Bot, Cog, Context, command  from bot.cogs.moderation import ModLog -from bot.constants import Channels, Event, Roles +from bot.constants import Bot as BotConfig, Channels, Event, Roles  from bot.decorators import InChannelCheckFailure, in_channel, without_role  log = logging.getLogger(__name__) @@ -27,12 +29,18 @@ from time to time, you can send `!subscribe` to <#{Channels.bot}> at any time to  If you'd like to unsubscribe from the announcement notifications, simply send `!unsubscribe` to <#{Channels.bot}>.  """ +PERIODIC_PING = ( +    f"@everyone To verify that you have read our rules, please type `{BotConfig.prefix}accept`." +    f" Ping <@&{Roles.admin}> if you encounter any problems during the verification process." +) +  class Verification(Cog):      """User verification and role self-management."""      def __init__(self, bot: Bot):          self.bot = bot +        self.periodic_ping.start()      @property      def mod_log(self) -> ModLog: @@ -155,6 +163,34 @@ class Verification(Cog):          else:              return True +    @tasks.loop(hours=12) +    async def periodic_ping(self) -> None: +        """Every week, mention @everyone to remind them to verify.""" +        messages = self.bot.get_channel(Channels.verification).history(limit=10) +        need_to_post = True  # True if a new message needs to be sent. + +        async for message in messages: +            if message.author == self.bot.user and message.content == PERIODIC_PING: +                delta = datetime.utcnow() - message.created_at  # Time since last message. +                if delta.days >= 7:  # Message is older than a week. +                    await message.delete() +                else: +                    need_to_post = False + +                break + +        if need_to_post: +            await self.bot.get_channel(Channels.verification).send(PERIODIC_PING) + +    @periodic_ping.before_loop +    async def before_ping(self) -> None: +        """Only start the loop when the bot is ready.""" +        await self.bot.wait_until_ready() + +    def cog_unload(self) -> None: +        """Cancel the periodic ping task when the cog is unloaded.""" +        self.periodic_ping.cancel() +  def setup(bot: Bot) -> None:      """Verification cog load.""" diff --git a/bot/constants.py b/bot/constants.py index 13f25e4f8..4beae84e9 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -482,6 +482,13 @@ class Free(metaclass=YAMLGetter):      cooldown_per: float +class Mention(metaclass=YAMLGetter): +    section = 'mention' + +    message_timeout: int +    reset_delay: int + +  class RedirectOutput(metaclass=YAMLGetter):      section = 'redirect_output' diff --git a/bot/utils/checks.py b/bot/utils/checks.py index 19f64ff9f..ad892e512 100644 --- a/bot/utils/checks.py +++ b/bot/utils/checks.py @@ -1,6 +1,8 @@ +import datetime  import logging +from typing import Callable, Iterable -from discord.ext.commands import Context +from discord.ext.commands import BucketType, Cog, Command, CommandOnCooldown, Context, Cooldown, CooldownMapping  log = logging.getLogger(__name__) @@ -42,3 +44,47 @@ def in_channel_check(ctx: Context, channel_id: int) -> bool:      log.trace(f"{ctx.author} tried to call the '{ctx.command.name}' command. "                f"The result of the in_channel check was {check}.")      return check + + +def cooldown_with_role_bypass(rate: int, per: float, type: BucketType = BucketType.default, *, +                              bypass_roles: Iterable[int]) -> Callable: +    """ +    Applies a cooldown to a command, but allows members with certain roles to be ignored. + +    NOTE: this replaces the `Command.before_invoke` callback, which *might* introduce problems in the future. +    """ +    # make it a set so lookup is hash based +    bypass = set(bypass_roles) + +    # this handles the actual cooldown logic +    buckets = CooldownMapping(Cooldown(rate, per, type)) + +    # will be called after the command has been parse but before it has been invoked, ensures that +    # the cooldown won't be updated if the user screws up their input to the command +    async def predicate(cog: Cog, ctx: Context) -> None: +        nonlocal bypass, buckets + +        if any(role.id in bypass for role in ctx.author.roles): +            return + +        # cooldown logic, taken from discord.py internals +        current = ctx.message.created_at.replace(tzinfo=datetime.timezone.utc).timestamp() +        bucket = buckets.get_bucket(ctx.message) +        retry_after = bucket.update_rate_limit(current) +        if retry_after: +            raise CommandOnCooldown(bucket, retry_after) + +    def wrapper(command: Command) -> Command: +        # NOTE: this could be changed if a subclass of Command were to be used. I didn't see the need for it +        # so I just made it raise an error when the decorator is applied before the actual command object exists. +        # +        # if the `before_invoke` detail is ever a problem then I can quickly just swap over. +        if not isinstance(command, Command): +            raise TypeError('Decorator `cooldown_with_role_bypass` must be applied after the command decorator. ' +                            'This means it has to be above the command decorator in the code.') + +        command._before_invoke = predicate + +        return command + +    return wrapper diff --git a/bot/utils/time.py b/bot/utils/time.py index da28f2c76..2aea2c099 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -1,5 +1,6 @@  import asyncio  import datetime +from typing import Optional  import dateutil.parser  from dateutil.relativedelta import relativedelta @@ -34,6 +35,9 @@ def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units:      precision specifies the smallest unit of time to include (e.g. "seconds", "minutes").      max_units specifies the maximum number of units of time to include (e.g. 1 may include days but not hours).      """ +    if max_units <= 0: +        raise ValueError("max_units must be positive") +      units = (          ("years", delta.years),          ("months", delta.months), @@ -83,15 +87,20 @@ def time_since(past_datetime: datetime.datetime, precision: str = "seconds", max      return f"{humanized} ago" -def parse_rfc1123(time_str: str) -> datetime.datetime: +def parse_rfc1123(stamp: str) -> datetime.datetime:      """Parse RFC1123 time string into datetime.""" -    return datetime.datetime.strptime(time_str, RFC1123_FORMAT).replace(tzinfo=datetime.timezone.utc) +    return datetime.datetime.strptime(stamp, RFC1123_FORMAT).replace(tzinfo=datetime.timezone.utc)  # Hey, this could actually be used in the off_topic_names and reddit cogs :) -async def wait_until(time: datetime.datetime) -> None: -    """Wait until a given time.""" -    delay = time - datetime.datetime.utcnow() +async def wait_until(time: datetime.datetime, start: Optional[datetime.datetime] = None) -> None: +    """ +    Wait until a given time. + +    :param time: A datetime.datetime object to wait until. +    :param start: The start from which to calculate the waiting duration. Defaults to UTC time. +    """ +    delay = time - (start or datetime.datetime.utcnow())      delay_seconds = delay.total_seconds()      # Incorporate a small delay so we don't rapid-fire the event due to time precision errors diff --git a/config-default.yml b/config-default.yml index 071478206..197743296 100644 --- a/config-default.yml +++ b/config-default.yml @@ -369,6 +369,10 @@ free:      cooldown_rate: 1      cooldown_per: 60.0 +mention: +    message_timeout: 300 +    reset_delay: 5 +  redirect_output:      delete_invocation: true      delete_delay: 15 diff --git a/docker-compose.yml b/docker-compose.yml index 9684a3c62..f79fdba58 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,7 @@ version: "3.7"  services:    postgres: -    image: postgres:11-alpine +    image: postgres:12-alpine      environment:        POSTGRES_DB: pysite        POSTGRES_PASSWORD: pysite diff --git a/tests/helpers.py b/tests/helpers.py index 2908294f7..25059fa3a 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -7,6 +7,10 @@ __all__ = ('AsyncMock', 'async_test')  # TODO: Remove me on 3.8 +# Allows you to mock a coroutine. Since the default `__call__` of `MagicMock` +# is not a coroutine, trying to mock a coroutine with it will result in errors +# as the default `__call__` is not awaitable. Use this class for monkeypatching +# coroutines instead.  class AsyncMock(MagicMock):      async def __call__(self, *args, **kwargs):          return super(AsyncMock, self).__call__(*args, **kwargs) diff --git a/tests/utils/test_time.py b/tests/utils/test_time.py new file mode 100644 index 000000000..4baa6395c --- /dev/null +++ b/tests/utils/test_time.py @@ -0,0 +1,62 @@ +import asyncio +from datetime import datetime, timezone +from unittest.mock import patch + +import pytest +from dateutil.relativedelta import relativedelta + +from bot.utils import time +from tests.helpers import AsyncMock + + +    ('delta', 'precision', 'max_units', 'expected'), +    ( +        (relativedelta(days=2), 'seconds', 1, '2 days'), +        (relativedelta(days=2, hours=2), 'seconds', 2, '2 days and 2 hours'), +        (relativedelta(days=2, hours=2), 'seconds', 1, '2 days'), +        (relativedelta(days=2, hours=2), 'days', 2, '2 days'), + +        # Does not abort for unknown units, as the unit name is checked +        # against the attribute of the relativedelta instance. +        (relativedelta(days=2, hours=2), 'elephants', 2, '2 days and 2 hours'), + +        # Very high maximum units, but it only ever iterates over +        # each value the relativedelta might have. +        (relativedelta(days=2, hours=2), 'hours', 20, '2 days and 2 hours'), +    ) +) +def test_humanize_delta( +        delta: relativedelta, +        precision: str, +        max_units: int, +        expected: str +): +    assert time.humanize_delta(delta, precision, max_units) == expected + + [email protected]('max_units', (-1, 0)) +def test_humanize_delta_raises_for_invalid_max_units(max_units: int): +    with pytest.raises(ValueError, match='max_units must be positive'): +        time.humanize_delta(relativedelta(days=2, hours=2), 'hours', max_units) + + +    ('stamp', 'expected'), +    ( +        ('Sun, 15 Sep 2019 12:00:00 GMT', datetime(2019, 9, 15, 12, 0, 0, tzinfo=timezone.utc)), +    ) +) +def test_parse_rfc1123(stamp: str, expected: str): +    assert time.parse_rfc1123(stamp) == expected + + +@patch('asyncio.sleep', new_callable=AsyncMock) +def test_wait_until(sleep_patch): +    start = datetime(2019, 1, 1, 0, 0) +    then = datetime(2019, 1, 1, 0, 10) + +    # No return value +    assert asyncio.run(time.wait_until(then, start)) is None + +    sleep_patch.assert_called_once_with(10 * 60) | 
