diff --git a/docs/framework_commands.rst b/docs/framework_commands.rst index 5a2f1aec0..38a574707 100644 --- a/docs/framework_commands.rst +++ b/docs/framework_commands.rst @@ -11,8 +11,12 @@ extend functionalities used throughout the bot, as outlined below. .. autofunction:: redbot.core.commands.command +.. autofunction:: redbot.core.commands.hybrid_command + .. autofunction:: redbot.core.commands.group +.. autofunction:: redbot.core.commands.hybrid_group + .. autoclass:: redbot.core.commands.Cog .. automethod:: format_help_for_context @@ -21,13 +25,21 @@ extend functionalities used throughout the bot, as outlined below. .. automethod:: red_delete_data_for_user +.. autoclass:: redbot.core.commands.GroupCog + .. autoclass:: redbot.core.commands.Command :members: :inherited-members: format_help_for_context +.. autoclass:: redbot.core.commands.HybridCommand + :members: + .. autoclass:: redbot.core.commands.Group :members: +.. autoclass:: redbot.core.commands.HybridGroup + :members: + .. autoclass:: redbot.core.commands.Context :members: diff --git a/redbot/core/bot.py b/redbot/core/bot.py index faf3b3940..e45e8278a 100644 --- a/redbot/core/bot.py +++ b/redbot/core/bot.py @@ -1,3 +1,4 @@ +from __future__ import annotations import asyncio import inspect import logging @@ -29,6 +30,7 @@ from typing import ( MutableMapping, Set, overload, + TYPE_CHECKING, ) from types import MappingProxyType @@ -54,6 +56,13 @@ from .rpc import RPCMixin from .utils import can_user_send_messages_in, common_filters, AsyncIter from .utils._internal_utils import send_to_owners_with_prefix_replaced +if TYPE_CHECKING: + from discord.ext.commands.hybrid import CommandCallback, ContextT, P + from discord import app_commands + + +_T = TypeVar("_T") + CUSTOM_GROUPS = "CUSTOM_GROUPS" COMMAND_SCOPE = "COMMAND" SHARED_API_TOKENS = "SHARED_API_TOKENS" @@ -1796,6 +1805,58 @@ class Red( subcommand.requires.reset() return command + def hybrid_command( + self, + name: Union[str, app_commands.locale_str] = discord.utils.MISSING, + with_app_command: bool = True, + *args: Any, + **kwargs: Any, + ) -> Callable[[CommandCallback[Any, ContextT, P, _T]], commands.HybridCommand[Any, P, _T]]: + """A shortcut decorator that invokes :func:`~redbot.core.commands.hybrid_command` and adds it to + the internal command list via :meth:`add_command`. + + Returns + -------- + Callable[..., :class:`HybridCommand`] + A decorator that converts the provided method into a Command, adds it to the bot, then returns it. + """ + + def decorator(func: CommandCallback[Any, ContextT, P, _T]): + kwargs.setdefault("parent", self) + result = commands.hybrid_command( + name=name, *args, with_app_command=with_app_command, **kwargs + )(func) + self.add_command(result) + return result + + return decorator + + def hybrid_group( + self, + name: Union[str, app_commands.locale_str] = discord.utils.MISSING, + with_app_command: bool = True, + *args: Any, + **kwargs: Any, + ) -> Callable[[CommandCallback[Any, ContextT, P, _T]], commands.HybridGroup[Any, P, _T]]: + """A shortcut decorator that invokes :func:`~redbot.core.commands.hybrid_group` and adds it to + the internal command list via :meth:`add_command`. + + Returns + -------- + Callable[..., :class:`HybridGroup`] + A decorator that converts the provided method into a Group, adds it to the bot, then returns it. + """ + + def decorator(func: CommandCallback[Any, ContextT, P, _T]): + kwargs.setdefault("parent", self) + result = commands.hybrid_group( + name=name, *args, with_app_command=with_app_command, **kwargs + )(func) + self.add_command(result) + return result + + return decorator + def clear_permission_rules(self, guild_id: Optional[int], **kwargs) -> None: """Clear all permission overrides in a scope. diff --git a/redbot/core/commands/__init__.py b/redbot/core/commands/__init__.py index 09a0e708f..a768c928f 100644 --- a/redbot/core/commands/__init__.py +++ b/redbot/core/commands/__init__.py @@ -12,8 +12,13 @@ from .commands import ( CogGroupMixin as CogGroupMixin, Command as Command, Group as Group, + GroupCog as GroupCog, GroupMixin as GroupMixin, command as command, + HybridCommand as HybridCommand, + HybridGroup as HybridGroup, + hybrid_command as hybrid_command, + hybrid_group as hybrid_group, group as group, RedUnhandledAPI as RedUnhandledAPI, RESERVED_COMMAND_NAMES as RESERVED_COMMAND_NAMES, @@ -198,4 +203,5 @@ from discord.ext.commands import ( Range as Range, RangeError as RangeError, parameter as parameter, + HybridCommandError as HybridCommandError, ) diff --git a/redbot/core/commands/commands.py b/redbot/core/commands/commands.py index ceed466f3..09415f5e5 100644 --- a/redbot/core/commands/commands.py +++ b/redbot/core/commands/commands.py @@ -14,11 +14,13 @@ from typing import ( Any, Awaitable, Callable, + ClassVar, Dict, List, Literal, Optional, Tuple, + TypeVar, Union, MutableMapping, TYPE_CHECKING, @@ -33,6 +35,9 @@ from discord.ext.commands import ( DisabledCommand, command as dpy_command_deco, Command as DPYCommand, + GroupCog as DPYGroupCog, + HybridCommand as DPYHybridCommand, + HybridGroup as DPYHybridGroup, Cog as DPYCog, CogMeta as DPYCogMeta, Group as DPYGroup, @@ -43,9 +48,24 @@ from .errors import ConversionFailure from .requires import PermState, PrivilegeLevel, Requires, PermStateAllowedStates from ..i18n import Translator +_T = TypeVar("_T") +_CogT = TypeVar("_CogT", bound="Cog") + + if TYPE_CHECKING: # circular import avoidance from .context import Context + from typing_extensions import ParamSpec, Concatenate + from discord.ext.commands._types import ContextT, Coro + + _P = ParamSpec("_P") + + CommandCallback = Union[ + Callable[Concatenate[_CogT, ContextT, _P], Coro[_T]], + Callable[Concatenate[ContextT, _P], Coro[_T]], + ] +else: + _P = TypeVar("_P") __all__ = [ @@ -55,9 +75,12 @@ __all__ = [ "CogGroupMixin", "Command", "Group", + "GroupCog", "GroupMixin", "command", "group", + "hybrid_command", + "hybrid_group", "RESERVED_COMMAND_NAMES", "RedUnhandledAPI", ] @@ -287,9 +310,9 @@ class Command(CogCommandMixin, DPYCommand): def __init__(self, *args, **kwargs): self.ignore_optional_for_conversion = kwargs.pop("ignore_optional_for_conversion", False) - super().__init__(*args, **kwargs) self._help_override = kwargs.pop("help_override", None) self.translator = kwargs.pop("i18n", None) + super().__init__(*args, **kwargs) if self.parent is None: for name in (self.name, *self.aliases): if name in RESERVED_COMMAND_NAMES: @@ -984,6 +1007,131 @@ class Cog(CogMixin, DPYCog, metaclass=DPYCogMeta): return {cmd.name: cmd for cmd in self.__cog_commands__} +class GroupCog(Cog, DPYGroupCog): + """ + Red's Cog base class with app commands group as the base. + + This class inherits from `Cog` and `discord.ext.commands.GroupCog` + """ + + +class HybridCommand(Command, DPYHybridCommand[_CogT, _P, _T]): + """HybridCommand class for Red. + + This should not be created directly, and instead via the decorator. + + This class inherits from `Command` and `discord.ext.commands.HybridCommand`. + + .. warning:: + + This class is not intended to be subclassed. + """ + + +class HybridGroup(Group, DPYHybridGroup[_CogT, _P, _T]): + """HybridGroup command class for Red. + + This should not be created directly, and instead via the decorator. + + This class inherits from `Group` and `discord.ext.commands.HybridGroup`. + + .. note:: + Red's HybridGroups differ from `discord.ext.commands.HybridGroup` + by setting `discord.ext.commands.Group.invoke_without_command` to be `False` by default. + If `discord.ext.commands.HybridGroup.fallback` is provided then + `discord.ext.commands.Group.invoke_without_command` is + set to `True`. + + .. warning:: + + This class is not intended to be subclassed. + """ + + def __init__(self, *args: Any, **kwargs: Any): + fallback = "fallback" in kwargs and kwargs["fallback"] is not None + invoke_without_command = kwargs.pop("invoke_without_command", False) or fallback + kwargs["invoke_without_command"] = invoke_without_command + super().__init__(*args, **kwargs) + self.invoke_without_command = invoke_without_command + + @property + def callback(self): + return self._callback + + @callback.setter + def callback(self, function): + # Below should be mostly the same as discord.py + super(__class__, __class__).callback.__set__(self, function) + + if not self.invoke_without_command and self.params: + raise TypeError( + "You cannot have a group command with callbacks and `invoke_without_command` set to False." + ) + + def command(self, name: str = discord.utils.MISSING, *args: Any, **kwargs: Any): + def decorator(func): + kwargs.setdefault("parent", self) + result = hybrid_command(name=name, *args, **kwargs)(func) + self.add_command(result) + return result + + return decorator + + def group( + self, + name: str = discord.utils.MISSING, + *args: Any, + **kwargs: Any, + ): + def decorator(func): + kwargs.setdefault("parent", self) + result = hybrid_group(name=name, *args, **kwargs)(func) + self.add_command(result) + return result + + return decorator + + +def hybrid_command( + name: Union[str, discord.app_commands.locale_str] = discord.utils.MISSING, + *, + with_app_command: bool = True, + **attrs: Any, +) -> Callable[[CommandCallback[_CogT, ContextT, _P, _T]], HybridCommand[_CogT, _P, _T]]: + """A decorator which transforms an async function into a `HybridCommand`. + + Same interface as `discord.ext.commands.hybrid_command`. + """ + + def decorator(func: CommandCallback[_CogT, ContextT, _P, _T]) -> HybridCommand[_CogT, _P, _T]: + if isinstance(func, Command): + raise TypeError("callback is already a command.") + attrs["help_override"] = attrs.pop("help", None) + return HybridCommand(func, name=name, with_app_command=with_app_command, **attrs) + + return decorator + + +def hybrid_group( + name: Union[str, discord.app_commands.locale_str] = discord.utils.MISSING, + *, + with_app_command: bool = True, + **attrs: Any, +) -> Callable[[CommandCallback[_CogT, ContextT, _P, _T]], HybridGroup[_CogT, _P, _T]]: + """A decorator which transforms an async function into a `HybridGroup`. + + Same interface as `discord.ext.commands.hybrid_group`. + """ + + def decorator(func: CommandCallback[_CogT, ContextT, _P, _T]): + if isinstance(func, Command): + raise TypeError("callback is already a command.") + attrs["help_override"] = attrs.pop("help", None) + return HybridGroup(func, name=name, with_app_command=with_app_command, **attrs) + + return decorator + + def command(name=None, cls=Command, **attrs): """A decorator which transforms an async function into a `Command`.