diff options
| -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/defcon.py | 4 | ||||
| -rw-r--r-- | bot/cogs/extensions.py | 2 | ||||
| -rw-r--r-- | bot/cogs/help.py | 4 | ||||
| -rw-r--r-- | bot/cogs/site.py | 10 | ||||
| -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-- | tests/bot/cogs/test_cogs.py | 1 |
11 files changed, 88 insertions, 82 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/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/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/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/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 |