diff options
| author | 2020-08-27 22:18:19 -0400 | |
|---|---|---|
| committer | 2020-08-27 22:18:19 -0400 | |
| commit | c6ccc40011b680d8c1b481f27dcc5e85de1ee8ef (patch) | |
| tree | 2c9b13c76e169930caba851bedaa05bd2f54575c | |
| parent | Don't send code block help if message has a webhook token (diff) | |
| parent | Update Discord Partner badge (diff) | |
Merge branch 'master' into bug/mod/1094/watch-leaked-token
| -rw-r--r-- | bot/__init__.py | 10 | ||||
| -rw-r--r-- | bot/bot.py | 41 | ||||
| -rw-r--r-- | bot/cogs/alias.py | 70 | ||||
| -rw-r--r-- | bot/cogs/bot.py | 2 | ||||
| -rw-r--r-- | bot/cogs/defcon.py | 4 | ||||
| -rw-r--r-- | bot/cogs/extensions.py | 2 | ||||
| -rw-r--r-- | bot/cogs/help.py | 4 | ||||
| -rw-r--r-- | bot/cogs/help_channels.py | 4 | ||||
| -rw-r--r-- | bot/cogs/information.py | 117 | ||||
| -rw-r--r-- | bot/cogs/reminders.py | 26 | ||||
| -rw-r--r-- | bot/cogs/site.py | 10 | ||||
| -rw-r--r-- | bot/cogs/snekbox.py | 4 | ||||
| -rw-r--r-- | bot/cogs/tags.py | 4 | ||||
| -rw-r--r-- | bot/cogs/watchchannels/bigbrother.py | 4 | ||||
| -rw-r--r-- | bot/cogs/watchchannels/talentpool.py | 6 | ||||
| -rw-r--r-- | bot/command.py | 18 | ||||
| -rw-r--r-- | bot/constants.py | 11 | ||||
| -rw-r--r-- | bot/utils/messages.py | 13 | ||||
| -rw-r--r-- | config-default.yml | 11 | ||||
| -rw-r--r-- | tests/bot/cogs/test_cogs.py | 1 | ||||
| -rw-r--r-- | tests/bot/cogs/test_information.py | 87 | 
21 files changed, 278 insertions, 171 deletions
| diff --git a/bot/__init__.py b/bot/__init__.py index d63086fe2..3ee70c4e9 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -2,10 +2,14 @@ import asyncio  import logging  import os  import sys +from functools import partial, partialmethod  from logging import Logger, handlers  from pathlib import Path  import coloredlogs +from discord.ext import commands + +from bot.command import Command  TRACE_LEVEL = logging.TRACE = 5  logging.addLevelName(TRACE_LEVEL, "TRACE") @@ -66,3 +70,9 @@ logging.getLogger(__name__)  # On Windows, the selector event loop is required for aiodns.  if os.name == "nt":      asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + + +# Monkey-patch discord.py decorators to use the Command subclass which supports root aliases. +# Must be patched before any cogs are added. +commands.command = partial(commands.command, cls=Command) +commands.GroupMixin.command = partialmethod(commands.GroupMixin.command, cls=Command) diff --git a/bot/bot.py b/bot/bot.py index 756449293..d25074fd9 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -130,6 +130,26 @@ class Bot(commands.Bot):          super().add_cog(cog)          log.info(f"Cog loaded: {cog.qualified_name}") +    def add_command(self, command: commands.Command) -> None: +        """Add `command` as normal and then add its root aliases to the bot.""" +        super().add_command(command) +        self._add_root_aliases(command) + +    def remove_command(self, name: str) -> Optional[commands.Command]: +        """ +        Remove a command/alias as normal and then remove its root aliases from the bot. + +        Individual root aliases cannot be removed by this function. +        To remove them, either remove the entire command or manually edit `bot.all_commands`. +        """ +        command = super().remove_command(name) +        if command is None: +            # Even if it's a root alias, there's no way to get the Bot instance to remove the alias. +            return + +        self._remove_root_aliases(command) +        return command +      def clear(self) -> None:          """          Clears the internal state of the bot and recreates the connector and sessions. @@ -235,3 +255,24 @@ class Bot(commands.Bot):              scope.set_extra("kwargs", kwargs)              log.exception(f"Unhandled exception in {event}.") + +    def _add_root_aliases(self, command: commands.Command) -> None: +        """Recursively add root aliases for `command` and any of its subcommands.""" +        if isinstance(command, commands.Group): +            for subcommand in command.commands: +                self._add_root_aliases(subcommand) + +        for alias in getattr(command, "root_aliases", ()): +            if alias in self.all_commands: +                raise commands.CommandRegistrationError(alias, alias_conflict=True) + +            self.all_commands[alias] = command + +    def _remove_root_aliases(self, command: commands.Command) -> None: +        """Recursively remove root aliases for `command` and any of its subcommands.""" +        if isinstance(command, commands.Group): +            for subcommand in command.commands: +                self._remove_root_aliases(subcommand) + +        for alias in getattr(command, "root_aliases", ()): +            self.all_commands.pop(alias, None) diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index 55c7efe65..c6ba8d6f3 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -3,13 +3,12 @@ import logging  from discord import Colour, Embed  from discord.ext.commands import ( -    Cog, Command, Context, Greedy, +    Cog, Command, Context,      clean_content, command, group,  )  from bot.bot import Bot -from bot.cogs.extensions import Extension -from bot.converters import FetchedMember, TagNameConverter +from bot.converters import TagNameConverter  from bot.pagination import LinePaginator  log = logging.getLogger(__name__) @@ -51,56 +50,6 @@ class Alias (Cog):              ctx, embed, empty=False, max_lines=20          ) -    @command(name="resources", aliases=("resource",), hidden=True) -    async def site_resources_alias(self, ctx: Context) -> None: -        """Alias for invoking <prefix>site resources.""" -        await self.invoke(ctx, "site resources") - -    @command(name="tools", hidden=True) -    async def site_tools_alias(self, ctx: Context) -> None: -        """Alias for invoking <prefix>site tools.""" -        await self.invoke(ctx, "site tools") - -    @command(name="watch", hidden=True) -    async def bigbrother_watch_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: -        """Alias for invoking <prefix>bigbrother watch [user] [reason].""" -        await self.invoke(ctx, "bigbrother watch", user, reason=reason) - -    @command(name="unwatch", hidden=True) -    async def bigbrother_unwatch_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: -        """Alias for invoking <prefix>bigbrother unwatch [user] [reason].""" -        await self.invoke(ctx, "bigbrother unwatch", user, reason=reason) - -    @command(name="home", hidden=True) -    async def site_home_alias(self, ctx: Context) -> None: -        """Alias for invoking <prefix>site home.""" -        await self.invoke(ctx, "site home") - -    @command(name="faq", hidden=True) -    async def site_faq_alias(self, ctx: Context) -> None: -        """Alias for invoking <prefix>site faq.""" -        await self.invoke(ctx, "site faq") - -    @command(name="rules", aliases=("rule",), hidden=True) -    async def site_rules_alias(self, ctx: Context, rules: Greedy[int], *_: str) -> None: -        """Alias for invoking <prefix>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: -        """Alias for invoking <prefix>extensions reload [extensions...].""" -        await self.invoke(ctx, "extensions reload", *extensions) - -    @command(name="defon", hidden=True) -    async def defcon_enable_alias(self, ctx: Context) -> None: -        """Alias for invoking <prefix>defcon enable.""" -        await self.invoke(ctx, "defcon enable") - -    @command(name="defoff", hidden=True) -    async def defcon_disable_alias(self, ctx: Context) -> None: -        """Alias for invoking <prefix>defcon disable.""" -        await self.invoke(ctx, "defcon disable") -      @command(name="exception", hidden=True)      async def tags_get_traceback_alias(self, ctx: Context) -> None:          """Alias for invoking <prefix>tags get traceback.""" @@ -132,21 +81,6 @@ class Alias (Cog):          """Alias for invoking <prefix>docs get [symbol]."""          await self.invoke(ctx, "docs get", symbol) -    @command(name="nominate", hidden=True) -    async def nomination_add_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: -        """Alias for invoking <prefix>talentpool add [user] [reason].""" -        await self.invoke(ctx, "talentpool add", user, reason=reason) - -    @command(name="unnominate", hidden=True) -    async def nomination_end_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: -        """Alias for invoking <prefix>nomination end [user] [reason].""" -        await self.invoke(ctx, "nomination end", user, reason=reason) - -    @command(name="nominees", hidden=True) -    async def nominees_alias(self, ctx: Context) -> None: -        """Alias for invoking <prefix>tp watched.""" -        await self.invoke(ctx, "talentpool watched") -  def setup(bot: Bot) -> None:      """Load the Alias cog.""" diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index 93f2eae7c..ddd1cef8d 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -339,7 +339,7 @@ class BotCog(Cog, name="Bot"):                          self.codeblock_message_ids[msg.id] = bot_message.id                          self.bot.loop.create_task( -                            wait_for_deletion(bot_message, user_ids=(msg.author.id,), client=self.bot) +                            wait_for_deletion(bot_message, (msg.author.id,), self.bot)                          )                      else:                          return diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index 4c0ad5914..de0f4545e 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -162,7 +162,7 @@ class Defcon(Cog):              self.bot.stats.gauge("defcon.threshold", days) -    @defcon_group.command(name='enable', aliases=('on', 'e')) +    @defcon_group.command(name='enable', aliases=('on', 'e'), root_aliases=("defon",))      @with_role(Roles.admins, Roles.owners)      async def enable_command(self, ctx: Context) -> None:          """ @@ -175,7 +175,7 @@ class Defcon(Cog):          await self._defcon_action(ctx, days=0, action=Action.ENABLED)          await self.update_channel_topic() -    @defcon_group.command(name='disable', aliases=('off', 'd')) +    @defcon_group.command(name='disable', aliases=('off', 'd'), root_aliases=("defoff",))      @with_role(Roles.admins, Roles.owners)      async def disable_command(self, ctx: Context) -> None:          """Disable DEFCON mode. Useful in a pinch, but be sure you know what you're doing!""" diff --git a/bot/cogs/extensions.py b/bot/cogs/extensions.py index 365f198ff..396e406b0 100644 --- a/bot/cogs/extensions.py +++ b/bot/cogs/extensions.py @@ -107,7 +107,7 @@ class Extensions(commands.Cog):          await ctx.send(msg) -    @extensions_group.command(name="reload", aliases=("r",)) +    @extensions_group.command(name="reload", aliases=("r",), root_aliases=("reload",))      async def reload_command(self, ctx: Context, *extensions: Extension) -> None:          r"""          Reload extensions given their fully qualified or unqualified names. diff --git a/bot/cogs/help.py b/bot/cogs/help.py index 3d1d6fd10..25ce4ae0f 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -189,7 +189,9 @@ class CustomHelpCommand(HelpCommand):          command_details = f"**```{PREFIX}{name} {command.signature}```**\n"          # show command aliases -        aliases = ", ".join(f"`{alias}`" if not parent else f"`{parent} {alias}`" for alias in command.aliases) +        aliases = [f"`{alias}`" if not parent else f"`{parent} {alias}`" for alias in command.aliases] +        aliases += [f"`{alias}`" for alias in getattr(command, "root_aliases", ())] +        aliases = ", ".join(sorted(aliases))          if aliases:              command_details += f"**Can also use:** {aliases}\n\n" diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 57094751e..541c6f336 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -36,7 +36,7 @@ the **Help: Dormant** category.  Try to write the best question you can by providing a detailed description and telling us what \  you've tried already. For more information on asking a good question, \ -check out our guide on [asking good questions]({ASKING_GUIDE_URL}). +check out our guide on [**asking good questions**]({ASKING_GUIDE_URL}).  """  DORMANT_MSG = f""" @@ -47,7 +47,7 @@ channel until it becomes available again.  If your question wasn't answered yet, you can claim a new help channel from the \  **Help: Available** category by simply asking your question again. Consider rephrasing the \  question to maximize your chance of getting a good answer. If you're not sure how, have a look \ -through our guide for [asking a good question]({ASKING_GUIDE_URL}). +through our guide for [**asking a good question**]({ASKING_GUIDE_URL}).  """  CoroutineFunc = t.Callable[..., t.Coroutine] diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 8982196d1..55ecb2836 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -4,9 +4,9 @@ import pprint  import textwrap  from collections import Counter, defaultdict  from string import Template -from typing import Any, Mapping, Optional, Union +from typing import Any, Mapping, Optional, Tuple, Union -from discord import ChannelType, Colour, Embed, Guild, Member, Message, Role, Status, utils +from discord import ChannelType, Colour, CustomActivity, 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 @@ -20,6 +20,12 @@ from bot.utils.time import time_since  log = logging.getLogger(__name__) +STATUS_EMOTES = { +    Status.offline: constants.Emojis.status_offline, +    Status.dnd: constants.Emojis.status_dnd, +    Status.idle: constants.Emojis.status_idle +} +  class Information(Cog):      """A cog with commands for generating embeds with server info, such as server stats and user info.""" @@ -211,53 +217,88 @@ class Information(Cog):          # Custom status          custom_status = ''          for activity in user.activities: -            # Check activity.state for None value if user has a custom status set -            # This guards against a custom status with an emoji but no text, which will cause -            # escape_markdown to raise an exception -            # This can be reworked after a move to d.py 1.3.0+, which adds a CustomActivity class -            if activity.name == 'Custom Status' and activity.state: -                state = escape_markdown(activity.state) -                custom_status = f'Status: {state}\n' +            if isinstance(activity, CustomActivity): +                state = "" + +                if activity.name: +                    state = escape_markdown(activity.name) + +                emoji = "" +                if activity.emoji: +                    # If an emoji is unicode use the emoji, else write the emote like :abc: +                    if not activity.emoji.id: +                        emoji += activity.emoji.name + " " +                    else: +                        emoji += f"`:{activity.emoji.name}:` " + +                custom_status = f'Status: {emoji}{state}\n'          name = str(user)          if user.nick:              name = f"{user.nick} ({name})" +        badges = [] + +        for badge, is_set in user.public_flags: +            if is_set and (emoji := getattr(constants.Emojis, f"badge_{badge}", None)): +                badges.append(emoji) +          joined = time_since(user.joined_at, max_units=3)          roles = ", ".join(role.mention for role in user.roles[1:]) -        description = [ -            textwrap.dedent(f""" -                **User Information** -                Created: {created} -                Profile: {user.mention} -                ID: {user.id} -                {custom_status} -                **Member Information** -                Joined: {joined} -                Roles: {roles or None} -            """).strip() +        desktop_status = STATUS_EMOTES.get(user.desktop_status, constants.Emojis.status_online) +        web_status = STATUS_EMOTES.get(user.web_status, constants.Emojis.status_online) +        mobile_status = STATUS_EMOTES.get(user.mobile_status, constants.Emojis.status_online) + +        fields = [ +            ( +                "User information", +                textwrap.dedent(f""" +                    Created: {created} +                    Profile: {user.mention} +                    ID: {user.id} +                    {custom_status} +                """).strip() +            ), +            ( +                "Member information", +                textwrap.dedent(f""" +                    Joined: {joined} +                    Roles: {roles or None} +                """).strip() +            ), +            ( +                "Status", +                textwrap.dedent(f""" +                    {desktop_status} Desktop +                    {web_status} Web +                    {mobile_status} Mobile +                """).strip() +            )          ]          # Show more verbose output in moderation channels for infractions and nominations          if ctx.channel.id in constants.MODERATION_CHANNELS: -            description.append(await self.expanded_user_infraction_counts(user)) -            description.append(await self.user_nomination_counts(user)) +            fields.append(await self.expanded_user_infraction_counts(user)) +            fields.append(await self.user_nomination_counts(user))          else: -            description.append(await self.basic_user_infraction_counts(user)) +            fields.append(await self.basic_user_infraction_counts(user))          # Let's build the embed now          embed = Embed(              title=name, -            description="\n\n".join(description) +            description=" ".join(badges)          ) +        for field_name, field_content in fields: +            embed.add_field(name=field_name, value=field_content, inline=False) +          embed.set_thumbnail(url=user.avatar_url_as(static_format="png"))          embed.colour = user.top_role.colour if roles else Colour.blurple()          return embed -    async def basic_user_infraction_counts(self, member: Member) -> str: +    async def basic_user_infraction_counts(self, member: Member) -> Tuple[str, str]:          """Gets the total and active infraction counts for the given `member`."""          infractions = await self.bot.api_client.get(              'bot/infractions', @@ -270,11 +311,11 @@ class Information(Cog):          total_infractions = len(infractions)          active_infractions = sum(infraction['active'] for infraction in infractions) -        infraction_output = f"**Infractions**\nTotal: {total_infractions}\nActive: {active_infractions}" +        infraction_output = f"Total: {total_infractions}\nActive: {active_infractions}" -        return infraction_output +        return "Infractions", infraction_output -    async def expanded_user_infraction_counts(self, member: Member) -> str: +    async def expanded_user_infraction_counts(self, member: Member) -> Tuple[str, str]:          """          Gets expanded infraction counts for the given `member`. @@ -288,9 +329,9 @@ class Information(Cog):              }          ) -        infraction_output = ["**Infractions**"] +        infraction_output = []          if not infractions: -            infraction_output.append("This user has never received an infraction.") +            infraction_output.append("No infractions")          else:              # Count infractions split by `type` and `active` status for this user              infraction_types = set() @@ -313,9 +354,9 @@ class Information(Cog):                  infraction_output.append(line) -        return "\n".join(infraction_output) +        return "Infractions", "\n".join(infraction_output) -    async def user_nomination_counts(self, member: Member) -> str: +    async def user_nomination_counts(self, member: Member) -> Tuple[str, str]:          """Gets the active and historical nomination counts for the given `member`."""          nominations = await self.bot.api_client.get(              'bot/nominations', @@ -324,21 +365,21 @@ class Information(Cog):              }          ) -        output = ["**Nominations**"] +        output = []          if not nominations: -            output.append("This user has never been nominated.") +            output.append("No nominations")          else:              count = len(nominations)              is_currently_nominated = any(nomination["active"] for nomination in nominations)              nomination_noun = "nomination" if count == 1 else "nominations"              if is_currently_nominated: -                output.append(f"This user is **currently** nominated ({count} {nomination_noun} in total).") +                output.append(f"This user is **currently** nominated\n({count} {nomination_noun} in total)")              else:                  output.append(f"This user has {count} historical {nomination_noun}, but is currently not nominated.") -        return "\n".join(output) +        return "Nominations", "\n".join(output)      def format_fields(self, mapping: Mapping[str, Any], field_width: Optional[int] = None) -> str:          """Format a mapping to be readable to a human.""" @@ -376,7 +417,7 @@ class Information(Cog):          return out.rstrip()      @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_ROLES) -    @group(invoke_without_command=True) +    @group(invoke_without_command=True, enabled=False)      @in_whitelist(channels=(constants.Channels.bot_commands,), roles=constants.STAFF_ROLES)      async def raw(self, ctx: Context, *, message: Message, json: bool = False) -> None:          """Shows information about the raw API response.""" @@ -411,7 +452,7 @@ class Information(Cog):          for page in paginator.pages:              await ctx.send(page) -    @raw.command() +    @raw.command(enabled=False)      async def json(self, ctx: Context, message: Message) -> None:          """Shows information about the raw API response in a copy-pasteable Python format."""          await ctx.invoke(self.raw, message=message, json=True) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 670493bcf..08bce2153 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -12,10 +12,10 @@ from dateutil.relativedelta import relativedelta  from discord.ext.commands import Cog, Context, Greedy, group  from bot.bot import Bot -from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, STAFF_ROLES +from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, Roles, STAFF_ROLES  from bot.converters import Duration  from bot.pagination import LinePaginator -from bot.utils.checks import without_role_check +from bot.utils.checks import with_role_check, without_role_check  from bot.utils.messages import send_denial  from bot.utils.scheduling import Scheduler  from bot.utils.time import humanize_delta @@ -396,6 +396,8 @@ class Reminders(Cog):      async def edit_reminder(self, ctx: Context, id_: int, payload: dict) -> None:          """Edits a reminder with the given payload, then sends a confirmation message.""" +        if not await self._can_modify(ctx, id_): +            return          reminder = await self._edit_reminder(id_, payload)          # Parse the reminder expiration back into a datetime @@ -413,6 +415,8 @@ class Reminders(Cog):      @remind_group.command("delete", aliases=("remove", "cancel"))      async def delete_reminder(self, ctx: Context, id_: int) -> None:          """Delete one of your active reminders.""" +        if not await self._can_modify(ctx, id_): +            return          await self._delete_reminder(id_)          await self._send_confirmation(              ctx, @@ -421,6 +425,24 @@ class Reminders(Cog):              delivery_dt=None,          ) +    async def _can_modify(self, ctx: Context, reminder_id: t.Union[str, int]) -> bool: +        """ +        Check whether the reminder can be modified by the ctx author. + +        The check passes when the user is an admin, or if they created the reminder. +        """ +        if with_role_check(ctx, Roles.admins): +            return True + +        api_response = await self.bot.api_client.get(f"bot/reminders/{reminder_id}") +        if not api_response["author"] == ctx.author.id: +            log.debug(f"{ctx.author} is not the reminder author and does not pass the check.") +            await send_denial(ctx, "You can't modify reminders of other users!") +            return False + +        log.debug(f"{ctx.author} is the reminder author and passes the check.") +        return True +  def setup(bot: Bot) -> None:      """Load the Reminders cog.""" diff --git a/bot/cogs/site.py b/bot/cogs/site.py index ac29daa1d..2d3a3d9f3 100644 --- a/bot/cogs/site.py +++ b/bot/cogs/site.py @@ -23,7 +23,7 @@ class Site(Cog):          """Commands for getting info about our website."""          await ctx.send_help(ctx.command) -    @site_group.command(name="home", aliases=("about",)) +    @site_group.command(name="home", aliases=("about",), root_aliases=("home",))      async def site_main(self, ctx: Context) -> None:          """Info about the website itself."""          url = f"{URLs.site_schema}{URLs.site}/" @@ -40,7 +40,7 @@ class Site(Cog):          await ctx.send(embed=embed) -    @site_group.command(name="resources") +    @site_group.command(name="resources", root_aliases=("resources", "resource"))      async def site_resources(self, ctx: Context) -> None:          """Info about the site's Resources page."""          learning_url = f"{PAGES_URL}/resources" @@ -56,7 +56,7 @@ class Site(Cog):          await ctx.send(embed=embed) -    @site_group.command(name="tools") +    @site_group.command(name="tools", root_aliases=("tools",))      async def site_tools(self, ctx: Context) -> None:          """Info about the site's Tools page."""          tools_url = f"{PAGES_URL}/resources/tools" @@ -87,7 +87,7 @@ class Site(Cog):          await ctx.send(embed=embed) -    @site_group.command(name="faq") +    @site_group.command(name="faq", root_aliases=("faq",))      async def site_faq(self, ctx: Context) -> None:          """Info about the site's FAQ page."""          url = f"{PAGES_URL}/frequently-asked-questions" @@ -104,7 +104,7 @@ class Site(Cog):          await ctx.send(embed=embed) -    @site_group.command(aliases=['r', 'rule'], name='rules') +    @site_group.command(name="rules", aliases=("r", "rule"), root_aliases=("rules", "rule"))      async def site_rules(self, ctx: Context, *rules: int) -> None:          """Provides a link to all rules or, if specified, displays specific rule(s)."""          rules_embed = Embed(title='Rules', color=Colour.blurple()) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 52c8b6f88..63e6d7f31 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -220,9 +220,7 @@ class Snekbox(Cog):                  response = await ctx.send("Attempt to circumvent filter detected. Moderator team has been alerted.")              else:                  response = await ctx.send(msg) -            self.bot.loop.create_task( -                wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot) -            ) +            self.bot.loop.create_task(wait_for_deletion(response, (ctx.author.id,), ctx.bot))              log.info(f"{ctx.author}'s job had a return code of {results['returncode']}")          return response diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 3d76c5c08..d01647312 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -236,7 +236,7 @@ class Tags(Cog):                  await wait_for_deletion(                      await ctx.send(embed=Embed.from_dict(tag['embed'])),                      [ctx.author.id], -                    client=self.bot +                    self.bot                  )              elif founds and len(tag_name) >= 3:                  await wait_for_deletion( @@ -247,7 +247,7 @@ class Tags(Cog):                          )                      ),                      [ctx.author.id], -                    client=self.bot +                    self.bot                  )          else: diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index 7aa9cec58..11ab8917a 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -59,7 +59,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):          """          await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache) -    @bigbrother_group.command(name='watch', aliases=('w',)) +    @bigbrother_group.command(name='watch', aliases=('w',), root_aliases=('watch',))      @with_role(*MODERATION_ROLES)      async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:          """ @@ -70,7 +70,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):          """          await self.apply_watch(ctx, user, reason) -    @bigbrother_group.command(name='unwatch', aliases=('uw',)) +    @bigbrother_group.command(name='unwatch', aliases=('uw',), root_aliases=('unwatch',))      @with_role(*MODERATION_ROLES)      async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:          """Stop relaying messages by the given `user`.""" diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index a6df84c23..76d6fe9bd 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -37,7 +37,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):          """Highlights the activity of helper nominees by relaying their messages to the talent pool channel."""          await ctx.send_help(ctx.command) -    @nomination_group.command(name='watched', aliases=('all', 'list')) +    @nomination_group.command(name='watched', aliases=('all', 'list'), root_aliases=("nominees",))      @with_role(*MODERATION_ROLES)      async def watched_command(          self, ctx: Context, oldest_first: bool = False, update_cache: bool = True @@ -63,7 +63,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):          """          await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache) -    @nomination_group.command(name='watch', aliases=('w', 'add', 'a')) +    @nomination_group.command(name='watch', aliases=('w', 'add', 'a'), root_aliases=("nominate",))      @with_role(*STAFF_ROLES)      async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:          """ @@ -157,7 +157,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):              max_size=1000          ) -    @nomination_group.command(name='unwatch', aliases=('end', )) +    @nomination_group.command(name='unwatch', aliases=('end', ), root_aliases=("unnominate",))      @with_role(*MODERATION_ROLES)      async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:          """ diff --git a/bot/command.py b/bot/command.py new file mode 100644 index 000000000..0fb900f7b --- /dev/null +++ b/bot/command.py @@ -0,0 +1,18 @@ +from discord.ext import commands + + +class Command(commands.Command): +    """ +    A `discord.ext.commands.Command` subclass which supports root aliases. + +    A `root_aliases` keyword argument is added, which is a sequence of alias names that will act as +    top-level commands rather than being aliases of the command's group. It's stored as an attribute +    also named `root_aliases`. +    """ + +    def __init__(self, *args, **kwargs): +        super().__init__(*args, **kwargs) +        self.root_aliases = kwargs.get("root_aliases", []) + +        if not isinstance(self.root_aliases, (list, tuple)): +            raise TypeError("Root aliases of a command must be a list or a tuple of strings.") diff --git a/bot/constants.py b/bot/constants.py index d01dcb0fc..f3db80279 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -268,6 +268,17 @@ class Emojis(metaclass=YAMLGetter):      status_idle: str      status_dnd: str +    badge_staff: str +    badge_partner: str +    badge_hypesquad: str +    badge_bug_hunter: str +    badge_hypesquad_bravery: str +    badge_hypesquad_brilliance: str +    badge_hypesquad_balance: str +    badge_early_supporter: str +    badge_bug_hunter_level_2: str +    badge_verified_bot_developer: str +      incident_actioned: str      incident_unactioned: str      incident_investigating: str diff --git a/bot/utils/messages.py b/bot/utils/messages.py index 670289941..aa8f17f75 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -19,25 +19,20 @@ log = logging.getLogger(__name__)  async def wait_for_deletion(      message: Message,      user_ids: Sequence[Snowflake], +    client: Client,      deletion_emojis: Sequence[str] = (Emojis.trashcan,),      timeout: float = 60 * 5,      attach_emojis: bool = True, -    client: Optional[Client] = None  ) -> None:      """      Wait for up to `timeout` seconds for a reaction by any of the specified `user_ids` to delete the message.      An `attach_emojis` bool may be specified to determine whether to attach the given -    `deletion_emojis` to the message in the given `context` - -    A `client` instance may be optionally specified, otherwise client will be taken from the -    guild of the message. +    `deletion_emojis` to the message in the given `context`.      """ -    if message.guild is None and client is None: +    if message.guild is None:          raise ValueError("Message must be sent on a guild") -    bot = client or message.guild.me -      if attach_emojis:          for emoji in deletion_emojis:              await message.add_reaction(emoji) @@ -51,7 +46,7 @@ async def wait_for_deletion(          )      with contextlib.suppress(asyncio.TimeoutError): -        await bot.wait_for('reaction_add', check=check, timeout=timeout) +        await client.wait_for('reaction_add', check=check, timeout=timeout)          await message.delete() diff --git a/config-default.yml b/config-default.yml index e3ba9fb05..b4dc34e85 100644 --- a/config-default.yml +++ b/config-default.yml @@ -38,6 +38,17 @@ style:          status_dnd:     "<:status_dnd:470326272082313216>"          status_offline: "<:status_offline:470326266537705472>" +        badge_staff: "<:discord_staff:743882896498098226>" +        badge_partner: "<:partner:748666453242413136>" +        badge_hypesquad: "<:hypesquad_events:743882896892362873>" +        badge_bug_hunter: "<:bug_hunter_lvl1:743882896372269137>" +        badge_hypesquad_bravery: "<:hypesquad_bravery:743882896745693335>" +        badge_hypesquad_brilliance: "<:hypesquad_brilliance:743882896938631248>" +        badge_hypesquad_balance: "<:hypesquad_balance:743882896460480625>" +        badge_early_supporter: "<:early_supporter:743882896909140058>" +        badge_bug_hunter_level_2: "<:bug_hunter_lvl2:743882896611344505>" +        badge_verified_bot_developer: "<:verified_bot_dev:743882897299210310>" +          incident_actioned:      "<:incident_actioned:719645530128646266>"          incident_unactioned:    "<:incident_unactioned:719645583245180960>"          incident_investigating: "<:incident_investigating:719645658671480924>" diff --git a/tests/bot/cogs/test_cogs.py b/tests/bot/cogs/test_cogs.py index fdda59a8f..30a04422a 100644 --- a/tests/bot/cogs/test_cogs.py +++ b/tests/bot/cogs/test_cogs.py @@ -53,6 +53,7 @@ class CommandNameTests(unittest.TestCase):          """Return a list of all qualified names, including aliases, for the `command`."""          names = [f"{command.full_parent_name} {alias}".strip() for alias in command.aliases]          names.append(command.qualified_name) +        names += getattr(command, "root_aliases", [])          return names diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index 79c0e0ad3..77b0ddf17 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -215,10 +215,10 @@ class UserInfractionHelperMethodTests(unittest.TestCase):              with self.subTest(method=method, api_response=api_response, expected_lines=expected_lines):                  self.bot.api_client.get.return_value = api_response -                expected_output = "\n".join(default_header + expected_lines) +                expected_output = "\n".join(expected_lines)                  actual_output = asyncio.run(method(self.member)) -                self.assertEqual(expected_output, actual_output) +                self.assertEqual((default_header, expected_output), actual_output)      def test_basic_user_infraction_counts_returns_correct_strings(self):          """The method should correctly list both the total and active number of non-hidden infractions.""" @@ -249,7 +249,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase):              },          ) -        header = ["**Infractions**"] +        header = "Infractions"          self._method_subtests(self.cog.basic_user_infraction_counts, test_values, header) @@ -258,7 +258,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase):          test_values = (              {                  "api response": [], -                "expected_lines": ["This user has never received an infraction."], +                "expected_lines": ["No infractions"],              },              # Shows non-hidden inactive infraction as expected              { @@ -304,7 +304,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase):              },          ) -        header = ["**Infractions**"] +        header = "Infractions"          self._method_subtests(self.cog.expanded_user_infraction_counts, test_values, header) @@ -313,15 +313,15 @@ class UserInfractionHelperMethodTests(unittest.TestCase):          test_values = (              {                  "api response": [], -                "expected_lines": ["This user has never been nominated."], +                "expected_lines": ["No nominations"],              },              {                  "api response": [{'active': True}], -                "expected_lines": ["This user is **currently** nominated (1 nomination in total)."], +                "expected_lines": ["This user is **currently** nominated", "(1 nomination in total)"],              },              {                  "api response": [{'active': True}, {'active': False}], -                "expected_lines": ["This user is **currently** nominated (2 nominations in total)."], +                "expected_lines": ["This user is **currently** nominated", "(2 nominations in total)"],              },              {                  "api response": [{'active': False}], @@ -334,7 +334,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase):          ) -        header = ["**Nominations**"] +        header = "Nominations"          self._method_subtests(self.cog.user_nomination_counts, test_values, header) @@ -350,7 +350,10 @@ class UserEmbedTests(unittest.TestCase):          self.bot.api_client.get = unittest.mock.AsyncMock()          self.cog = information.Information(self.bot) -    @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) +    @unittest.mock.patch( +        f"{COG_PATH}.basic_user_infraction_counts", +        new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) +    )      def test_create_user_embed_uses_string_representation_of_user_in_title_if_nick_is_not_available(self):          """The embed should use the string representation of the user if they don't have a nick."""          ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) @@ -362,7 +365,10 @@ class UserEmbedTests(unittest.TestCase):          self.assertEqual(embed.title, "Mr. Hemlock") -    @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) +    @unittest.mock.patch( +        f"{COG_PATH}.basic_user_infraction_counts", +        new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) +    )      def test_create_user_embed_uses_nick_in_title_if_available(self):          """The embed should use the nick if it's available."""          ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) @@ -374,7 +380,10 @@ class UserEmbedTests(unittest.TestCase):          self.assertEqual(embed.title, "Cat lover (Mr. Hemlock)") -    @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) +    @unittest.mock.patch( +        f"{COG_PATH}.basic_user_infraction_counts", +        new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) +    )      def test_create_user_embed_ignores_everyone_role(self):          """Created `!user` embeds should not contain mention of the @everyone-role."""          ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) @@ -386,8 +395,8 @@ class UserEmbedTests(unittest.TestCase):          embed = asyncio.run(self.cog.create_user_embed(ctx, user)) -        self.assertIn("&Admins", embed.description) -        self.assertNotIn("&Everyone", embed.description) +        self.assertIn("&Admins", embed.fields[1].value) +        self.assertNotIn("&Everyone", embed.fields[1].value)      @unittest.mock.patch(f"{COG_PATH}.expanded_user_infraction_counts", new_callable=unittest.mock.AsyncMock)      @unittest.mock.patch(f"{COG_PATH}.user_nomination_counts", new_callable=unittest.mock.AsyncMock) @@ -398,8 +407,8 @@ class UserEmbedTests(unittest.TestCase):          moderators_role = helpers.MockRole(name='Moderators')          moderators_role.colour = 100 -        infraction_counts.return_value = "expanded infractions info" -        nomination_counts.return_value = "nomination info" +        infraction_counts.return_value = ("Infractions", "expanded infractions info") +        nomination_counts.return_value = ("Nominations", "nomination info")          user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role)          embed = asyncio.run(self.cog.create_user_embed(ctx, user)) @@ -409,20 +418,19 @@ class UserEmbedTests(unittest.TestCase):          self.assertEqual(              textwrap.dedent(f""" -                **User Information**                  Created: {"1 year ago"}                  Profile: {user.mention}                  ID: {user.id} +            """).strip(), +            embed.fields[0].value +        ) -                **Member Information** +        self.assertEqual( +            textwrap.dedent(f"""                  Joined: {"1 year ago"}                  Roles: &Moderators - -                expanded infractions info - -                nomination info              """).strip(), -            embed.description +            embed.fields[1].value          )      @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new_callable=unittest.mock.AsyncMock) @@ -433,7 +441,7 @@ class UserEmbedTests(unittest.TestCase):          moderators_role = helpers.MockRole(name='Moderators')          moderators_role.colour = 100 -        infraction_counts.return_value = "basic infractions info" +        infraction_counts.return_value = ("Infractions", "basic infractions info")          user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role)          embed = asyncio.run(self.cog.create_user_embed(ctx, user)) @@ -442,21 +450,30 @@ class UserEmbedTests(unittest.TestCase):          self.assertEqual(              textwrap.dedent(f""" -                **User Information**                  Created: {"1 year ago"}                  Profile: {user.mention}                  ID: {user.id} +            """).strip(), +            embed.fields[0].value +        ) -                **Member Information** +        self.assertEqual( +            textwrap.dedent(f"""                  Joined: {"1 year ago"}                  Roles: &Moderators - -                basic infractions info              """).strip(), -            embed.description +            embed.fields[1].value +        ) + +        self.assertEqual( +            "basic infractions info", +            embed.fields[3].value          ) -    @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) +    @unittest.mock.patch( +        f"{COG_PATH}.basic_user_infraction_counts", +        new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) +    )      def test_create_user_embed_uses_top_role_colour_when_user_has_roles(self):          """The embed should be created with the colour of the top role, if a top role is available."""          ctx = helpers.MockContext() @@ -469,7 +486,10 @@ class UserEmbedTests(unittest.TestCase):          self.assertEqual(embed.colour, discord.Colour(moderators_role.colour)) -    @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) +    @unittest.mock.patch( +        f"{COG_PATH}.basic_user_infraction_counts", +        new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) +    )      def test_create_user_embed_uses_blurple_colour_when_user_has_no_roles(self):          """The embed should be created with a blurple colour if the user has no assigned roles."""          ctx = helpers.MockContext() @@ -479,7 +499,10 @@ class UserEmbedTests(unittest.TestCase):          self.assertEqual(embed.colour, discord.Colour.blurple()) -    @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) +    @unittest.mock.patch( +        f"{COG_PATH}.basic_user_infraction_counts", +        new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) +    )      def test_create_user_embed_uses_png_format_of_user_avatar_as_thumbnail(self):          """The embed thumbnail should be set to the user's avatar in `png` format."""          ctx = helpers.MockContext() | 
