diff options
Diffstat (limited to '')
| -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/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/snekbox.py | 12 | ||||
| -rw-r--r-- | bot/cogs/utils.py | 132 | ||||
| -rw-r--r-- | bot/constants.py | 7 | ||||
| -rw-r--r-- | bot/utils/checks.py | 48 | ||||
| -rw-r--r-- | config-default.yml | 4 | 
13 files changed, 291 insertions, 92 deletions
| 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/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/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/constants.py b/bot/constants.py index f84889e10..c49242d5e 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -477,6 +477,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/config-default.yml b/config-default.yml index c43ea4f8f..3487dff27 100644 --- a/config-default.yml +++ b/config-default.yml @@ -349,6 +349,10 @@ free:      cooldown_rate: 1      cooldown_per: 60.0 +mention: +    message_timeout: 300 +    reset_delay: 5 +  redirect_output:      delete_invocation: true      delete_delay: 15 | 
