Browse Source

[commands] Add initial implementation of hybrid commands

Hybrid commands allow a regular command to also double as a slash
command, assuming it meets the subset required to function.
pull/7881/head
Rapptz 3 years ago
parent
commit
840eb577d4
  1. 21
      discord/app_commands/commands.py
  2. 1
      discord/ext/commands/__init__.py
  3. 69
      discord/ext/commands/bot.py
  4. 10
      discord/ext/commands/cog.py
  5. 297
      discord/ext/commands/context.py
  6. 10
      discord/ext/commands/core.py
  7. 21
      discord/ext/commands/errors.py
  8. 458
      discord/ext/commands/hybrid.py
  9. 4
      discord/interactions.py
  10. 56
      docs/ext/commands/api.rst

21
discord/app_commands/commands.py

@ -578,10 +578,7 @@ class Command(Generic[GroupT, P, T]):
return False
async def _invoke_with_namespace(self, interaction: Interaction, namespace: Namespace) -> T:
if not await self._check_can_run(interaction):
raise CheckFailure(f'The check functions for command {self.name!r} failed.')
async def _transform_arguments(self, interaction: Interaction, namespace: Namespace) -> Dict[str, Any]:
values = namespace.__dict__
transformed_values = {}
@ -596,12 +593,15 @@ class Command(Generic[GroupT, P, T]):
else:
transformed_values[param.name] = await param.transform(interaction, value)
return transformed_values
async def _do_call(self, interaction: Interaction, params: Dict[str, Any]) -> T:
# These type ignores are because the type checker doesn't quite understand the narrowing here
# Likewise, it thinks we're missing positional arguments when there aren't any.
try:
if self.binding is not None:
return await self._callback(self.binding, interaction, **transformed_values) # type: ignore
return await self._callback(interaction, **transformed_values) # type: ignore
return await self._callback(self.binding, interaction, **params) # type: ignore
return await self._callback(interaction, **params) # type: ignore
except TypeError as e:
# In order to detect mismatch from the provided signature and the Discord data,
# there are many ways it can go wrong yet all of them eventually lead to a TypeError
@ -621,6 +621,13 @@ class Command(Generic[GroupT, P, T]):
except Exception as e:
raise CommandInvokeError(self, e) from e
async def _invoke_with_namespace(self, interaction: Interaction, namespace: Namespace) -> T:
if not await self._check_can_run(interaction):
raise CheckFailure(f'The check functions for command {self.name!r} failed.')
transformed_values = await self._transform_arguments(interaction, namespace)
return await self._do_call(interaction, transformed_values)
async def _invoke_autocomplete(self, interaction: Interaction, name: str, namespace: Namespace):
# The namespace contains the Discord provided names so this will be fine
# even if the name is renamed
@ -1234,7 +1241,7 @@ class Group:
# <self>
# <group>
# this needs to be forbidden
raise ValueError('groups can only be nested at most one level')
raise ValueError(f'{command.name!r} is too nested, groups can only be nested at most one level')
if not override and command.name in self._children:
raise CommandAlreadyRegistered(command.name, guild_id=None)

1
discord/ext/commands/__init__.py

@ -18,3 +18,4 @@ from .errors import *
from .flags import *
from .help import *
from .parameters import *
from .hybrid import *

69
discord/ext/commands/bot.py

@ -67,6 +67,7 @@ if TYPE_CHECKING:
import importlib.machinery
from discord.message import Message
from discord.interactions import Interaction
from discord.abc import User, Snowflake
from ._types import (
_Bot,
@ -76,6 +77,7 @@ if TYPE_CHECKING:
ContextT,
MaybeAwaitableFunc,
)
from .core import Command
_Prefix = Union[Iterable[str], str]
_PrefixCallable = MaybeAwaitableFunc[[BotT, Message], _Prefix]
@ -215,6 +217,38 @@ class BotBase(GroupMixin[None]):
await super().close() # type: ignore
# GroupMixin overrides
@discord.utils.copy_doc(GroupMixin.add_command)
def add_command(self, command: Command[Any, ..., Any], /) -> None:
super().add_command(command)
if hasattr(command, '__commands_is_hybrid__'):
# If a cog is also inheriting from app_commands.Group then it'll also
# add the hybrid commands as text commands, which would recursively add the
# hybrid commands as slash commands. This check just terminates that recursion
# from happening
if command.cog is None or not command.cog.__cog_is_app_commands_group__:
self.tree.add_command(command.app_command) # type: ignore
@discord.utils.copy_doc(GroupMixin.remove_command)
def remove_command(self, name: str, /) -> Optional[Command[Any, ..., Any]]:
cmd = super().remove_command(name)
if cmd is not None and hasattr(cmd, '__commands_is_hybrid__'):
# See above
if cmd.cog is not None and cmd.cog.__cog_is_app_commands_group__:
return cmd
guild_ids: Optional[List[int]] = cmd.app_command._guild_ids # type: ignore
if guild_ids is None:
self.__tree.remove_command(name)
else:
for guild_id in guild_ids:
self.__tree.remove_command(name, guild=discord.Object(id=guild_id))
return cmd
# Error handler
async def on_command_error(self, context: Context[BotT], exception: errors.CommandError, /) -> None:
"""|coro|
@ -1107,7 +1141,7 @@ class BotBase(GroupMixin[None]):
@overload
async def get_context(
self,
message: Message,
origin: Union[Message, Interaction],
/,
) -> Context[Self]: # type: ignore
...
@ -1115,23 +1149,23 @@ class BotBase(GroupMixin[None]):
@overload
async def get_context(
self,
message: Message,
origin: Union[Message, Interaction],
/,
*,
cls: Type[ContextT] = ...,
cls: Type[ContextT],
) -> ContextT:
...
async def get_context(
self,
message: Message,
origin: Union[Message, Interaction],
/,
*,
cls: Type[ContextT] = MISSING,
) -> Any:
r"""|coro|
Returns the invocation context from the message.
Returns the invocation context from the message or interaction.
This is a more low-level counter-part for :meth:`.process_commands`
to allow users more fine grained control over the processing.
@ -1141,14 +1175,20 @@ class BotBase(GroupMixin[None]):
If the context is not valid then it is not a valid candidate to be
invoked under :meth:`~.Bot.invoke`.
.. note::
In order for the custom context to be used inside an interaction-based
context (such as :class:`HybridCommand`) then this method must be
overridden to return that class.
.. versionchanged:: 2.0
``message`` parameter is now positional-only.
``message`` parameter is now positional-only and renamed to ``origin``.
Parameters
-----------
message: :class:`discord.Message`
The message to get the invocation context from.
origin: Union[:class:`discord.Message`, :class:`discord.Interaction`]
The message or interaction to get the invocation context from.
cls
The factory class that will be used to create the context.
By default, this is :class:`.Context`. Should a custom
@ -1164,13 +1204,16 @@ class BotBase(GroupMixin[None]):
if cls is MISSING:
cls = Context # type: ignore
view = StringView(message.content)
ctx = cls(prefix=None, view=view, bot=self, message=message)
if isinstance(origin, discord.Interaction):
return await cls.from_interaction(origin)
view = StringView(origin.content)
ctx = cls(prefix=None, view=view, bot=self, message=origin)
if message.author.id == self.user.id: # type: ignore
if origin.author.id == self.user.id: # type: ignore
return ctx
prefix = await self.get_prefix(message)
prefix = await self.get_prefix(origin)
invoked_prefix = prefix
if isinstance(prefix, str):
@ -1180,7 +1223,7 @@ class BotBase(GroupMixin[None]):
try:
# if the context class' __init__ consumes something from the view this
# will be wrong. That seems unreasonable though.
if message.content.startswith(tuple(prefix)):
if origin.content.startswith(tuple(prefix)):
invoked_prefix = discord.utils.find(view.skip_string, prefix)
else:
return ctx

10
discord/ext/commands/cog.py

@ -239,6 +239,9 @@ class Cog(metaclass=CogMeta):
lookup = {cmd.qualified_name: cmd for cmd in self.__cog_commands__}
# Register the application commands
children: List[Union[app_commands.Group, app_commands.Command[Self, ..., Any]]] = []
# Update the Command instances dynamically as well
for command in self.__cog_commands__:
setattr(self, command.callback.__name__, command)
@ -250,9 +253,12 @@ class Cog(metaclass=CogMeta):
# Update our parent's reference to our self
parent.remove_command(command.name) # type: ignore
parent.add_command(command) # type: ignore
elif cls.__cog_is_app_commands_group__:
if hasattr(command, '__commands_is_hybrid__') and command.parent is None:
# In both of these, the type checker does not see the app_command attribute even though it exists
command.app_command = command.app_command._copy_with(parent=self, binding=self) # type: ignore
children.append(command.app_command) # type: ignore
# Register the application commands
children: List[Union[app_commands.Group, app_commands.Command[Self, ..., Any]]] = []
for command in cls.__cog_app_commands__:
copy = command._copy_with(
# Type checker doesn't understand this type of narrowing.

297
discord/ext/commands/context.py

@ -24,28 +24,35 @@ DEALINGS IN THE SOFTWARE.
from __future__ import annotations
import re
from typing import TYPE_CHECKING, Any, Dict, Generic, List, Optional, TypeVar, Union
from typing import TYPE_CHECKING, Any, Dict, Generic, List, Optional, TypeVar, Union, Sequence
import discord.abc
import discord.utils
from discord.message import Message
from discord import Interaction, Message, Attachment, MessageType, User, PartialMessageable
from .view import StringView
from ._types import BotT
if TYPE_CHECKING:
from typing_extensions import ParamSpec
from typing_extensions import Self, ParamSpec
from discord.abc import MessageableChannel
from discord.guild import Guild
from discord.member import Member
from discord.state import ConnectionState
from discord.user import ClientUser, User
from discord.user import ClientUser
from discord.voice_client import VoiceProtocol
from discord.embeds import Embed
from discord.file import File
from discord.mentions import AllowedMentions
from discord.sticker import GuildSticker, StickerItem
from discord.message import MessageReference, PartialMessage
from discord.ui import View
from discord.types.interactions import ApplicationCommandInteractionData
from .cog import Cog
from .core import Command
from .parameters import Parameter
from .view import StringView
# fmt: off
__all__ = (
@ -78,6 +85,12 @@ class Context(discord.abc.Messageable, Generic[BotT]):
-----------
message: :class:`.Message`
The message that triggered the command being executed.
.. note::
In the case of an interaction based context, this message is "synthetic"
and does not actually exist. Therefore, the ID on it is invalid similar
to ephemeral messages.
bot: :class:`.Bot`
The bot that contains the command being executed.
args: :class:`list`
@ -97,6 +110,10 @@ class Context(discord.abc.Messageable, Generic[BotT]):
The argument string of the :attr:`current_parameter` that is currently being converted.
This is only of use for within converters.
.. versionadded:: 2.0
interaction: Optional[:class:`Interaction`]
The interaction associated with this context.
.. versionadded:: 2.0
prefix: Optional[:class:`str`]
The prefix that was used to invoke the command.
@ -143,6 +160,7 @@ class Context(discord.abc.Messageable, Generic[BotT]):
command_failed: bool = False,
current_parameter: Optional[Parameter] = None,
current_argument: Optional[str] = None,
interaction: Optional[Interaction] = None,
):
self.message: Message = message
self.bot: BotT = bot
@ -158,8 +176,91 @@ class Context(discord.abc.Messageable, Generic[BotT]):
self.command_failed: bool = command_failed
self.current_parameter: Optional[Parameter] = current_parameter
self.current_argument: Optional[str] = current_argument
self.interaction: Optional[Interaction] = interaction
self._state: ConnectionState = self.message._state
@classmethod
async def from_interaction(cls, interaction: Interaction, /) -> Self:
"""|coro|
Creates a context from a :class:`discord.Interaction`. This only
works on application command based interactions, such as slash commands
or context menus.
On slash command based interactions this creates a synthetic :class:`~discord.Message`
that points to an ephemeral message that the command invoker has executed. This means
that :attr:`Context.author` returns the member that invoked the command.
In a message context menu based interaction, the :attr:`Context.message` attribute
is the message that the command is being executed on. This means that :attr:`Context.author`
returns the author of the message being targetted. To get the member that invoked
the command then :attr:`discord.Interaction.user` should be used instead.
.. versionadded:: 2.0
Parameters
-----------
interaction: :class:`discord.Interaction`
The interaction to create a context with.
Raises
-------
ValueError
The interaction does not have a valid command.
TypeError
The interaction client is not derived from :class:`Bot` or :class:`AutoShardedBot`.
"""
# Circular import
from .bot import BotBase
if not isinstance(interaction.client, BotBase):
raise TypeError('Interaction client is not derived from commands.Bot or commands.AutoShardedBot')
command = interaction.command
if command is None:
raise ValueError('interaction does not have command data')
bot: BotT = interaction.client # type: ignore
data: ApplicationCommandInteractionData = interaction.data # type: ignore
if interaction.message is None:
synthetic_payload = {
'id': interaction.id,
'reactions': [],
'embeds': [],
'mention_everyone': False,
'tts': False,
'pinned': False,
'edited_timestamp': None,
'type': MessageType.chat_input_command if data.get('type', 1) == 1 else MessageType.context_menu_command,
'flags': 64,
'content': '',
'mentions': [],
'mention_roles': [],
'attachments': [],
}
if interaction.channel_id is None:
raise RuntimeError('interaction channel ID is null, this is probably a Discord bug')
channel = interaction.channel or PartialMessageable(state=interaction._state, id=interaction.channel_id)
message = Message(state=interaction._state, channel=channel, data=synthetic_payload) # type: ignore
message.author = interaction.user
message.attachments = [a for _, a in interaction.namespace if isinstance(a, Attachment)]
else:
message = interaction.message
return cls(
message=message,
bot=bot,
view=StringView(''),
args=[],
kwargs={},
interaction=interaction,
invoked_with=command.name,
command=command, # type: ignore # this will be a hybrid command, technically
)
async def invoke(self, command: Command[CogT, P, T], /, *args: P.args, **kwargs: P.kwargs) -> T:
r"""|coro|
@ -410,3 +511,189 @@ class Context(discord.abc.Messageable, Generic[BotT]):
@discord.utils.copy_doc(Message.reply)
async def reply(self, content: Optional[str] = None, **kwargs: Any) -> Message:
return await self.message.reply(content, **kwargs)
async def defer(self, *, ephemeral: bool = False) -> None:
"""|coro|
Defers the interaction based contexts.
This is typically used when the interaction is acknowledged
and a secondary action will be done later.
If this isn't an interaction based context then it does nothing.
Parameters
-----------
ephemeral: :class:`bool`
Indicates whether the deferred message will eventually be ephemeral.
Raises
-------
HTTPException
Deferring the interaction failed.
InteractionResponded
This interaction has already been responded to before.
"""
if self.interaction:
await self.interaction.response.defer(ephemeral=ephemeral)
async def send(
self,
content: Optional[str] = None,
*,
tts: bool = False,
embed: Optional[Embed] = None,
embeds: Optional[Sequence[Embed]] = None,
file: Optional[File] = None,
files: Optional[Sequence[File]] = None,
stickers: Optional[Sequence[Union[GuildSticker, StickerItem]]] = None,
delete_after: Optional[float] = None,
nonce: Optional[Union[str, int]] = None,
allowed_mentions: Optional[AllowedMentions] = None,
reference: Optional[Union[Message, MessageReference, PartialMessage]] = None,
mention_author: Optional[bool] = None,
view: Optional[View] = None,
suppress_embeds: bool = False,
ephemeral: bool = False,
) -> Message:
"""|coro|
Sends a message to the destination with the content given.
This works similarly to :meth:`~discord.abc.Messageable.send` for non-interaction contexts.
For interaction based contexts this does one of the following:
- :meth:`discord.InteractionResponse.send_message` if no response has been given.
- A followup message if a response has been given.
- Regular send if the interaction has expired
.. versionchanged:: 2.0
This function will now raise :exc:`TypeError` or
:exc:`ValueError` instead of ``InvalidArgument``.
Parameters
------------
content: Optional[:class:`str`]
The content of the message to send.
tts: :class:`bool`
Indicates if the message should be sent using text-to-speech.
embed: :class:`~discord.Embed`
The rich embed for the content.
file: :class:`~discord.File`
The file to upload.
files: List[:class:`~discord.File`]
A list of files to upload. Must be a maximum of 10.
nonce: :class:`int`
The nonce to use for sending this message. If the message was successfully sent,
then the message will have a nonce with this value.
delete_after: :class:`float`
If provided, the number of seconds to wait in the background
before deleting the message we just sent. If the deletion fails,
then it is silently ignored. This is ignored for interaction based contexts.
allowed_mentions: :class:`~discord.AllowedMentions`
Controls the mentions being processed in this message. If this is
passed, then the object is merged with :attr:`~discord.Client.allowed_mentions`.
The merging behaviour only overrides attributes that have been explicitly passed
to the object, otherwise it uses the attributes set in :attr:`~discord.Client.allowed_mentions`.
If no object is passed at all then the defaults given by :attr:`~discord.Client.allowed_mentions`
are used instead.
.. versionadded:: 1.4
reference: Union[:class:`~discord.Message`, :class:`~discord.MessageReference`, :class:`~discord.PartialMessage`]
A reference to the :class:`~discord.Message` to which you are replying, this can be created using
:meth:`~discord.Message.to_reference` or passed directly as a :class:`~discord.Message`. You can control
whether this mentions the author of the referenced message using the :attr:`~discord.AllowedMentions.replied_user`
attribute of ``allowed_mentions`` or by setting ``mention_author``.
This is ignored for interaction based contexts.
.. versionadded:: 1.6
mention_author: Optional[:class:`bool`]
If set, overrides the :attr:`~discord.AllowedMentions.replied_user` attribute of ``allowed_mentions``.
This is ignored for interaction based contexts.
.. versionadded:: 1.6
view: :class:`discord.ui.View`
A Discord UI View to add to the message.
.. versionadded:: 2.0
embeds: List[:class:`~discord.Embed`]
A list of embeds to upload. Must be a maximum of 10.
.. versionadded:: 2.0
stickers: Sequence[Union[:class:`~discord.GuildSticker`, :class:`~discord.StickerItem`]]
A list of stickers to upload. Must be a maximum of 3. This is ignored for interaction based contexts.
.. versionadded:: 2.0
suppress_embeds: :class:`bool`
Whether to suppress embeds for the message. This sends the message without any embeds if set to ``True``.
.. versionadded:: 2.0
ephemeral: :class:`bool`
Indicates if the message should only be visible to the user who started the interaction.
If a view is sent with an ephemeral message and it has no timeout set then the timeout
is set to 15 minutes. **This is only applicable in contexts with an interaction**.
.. versionadded:: 2.0
Raises
--------
~discord.HTTPException
Sending the message failed.
~discord.Forbidden
You do not have the proper permissions to send the message.
ValueError
The ``files`` list is not of the appropriate size.
TypeError
You specified both ``file`` and ``files``,
or you specified both ``embed`` and ``embeds``,
or the ``reference`` object is not a :class:`~discord.Message`,
:class:`~discord.MessageReference` or :class:`~discord.PartialMessage`.
Returns
---------
:class:`~discord.Message`
The message that was sent.
"""
if self.interaction is None or self.interaction.is_expired():
return await super().send(
content=content,
tts=tts,
embed=embed,
embeds=embeds,
file=file,
files=files,
stickers=stickers,
delete_after=delete_after,
nonce=nonce,
allowed_mentions=allowed_mentions,
reference=reference,
mention_author=mention_author,
view=view,
suppress_embeds=suppress_embeds,
) # type: ignore # The overloads don't support Optional but the implementation does
# Convert the kwargs from None to MISSING to appease the remaining implementations
kwargs = {
'content': content,
'tts': tts,
'embed': MISSING if embed is None else embed,
'embeds': MISSING if embeds is None else embeds,
'file': MISSING if file is None else file,
'files': MISSING if files is None else files,
'allowed_mentions': MISSING if allowed_mentions is None else allowed_mentions,
'view': MISSING if view is None else view,
'suppress_embeds': suppress_embeds,
'ephemeral': ephemeral,
}
if self.interaction.response.is_done():
return await self.interaction.followup.send(**kwargs, wait=True)
await self.interaction.response.send_message(**kwargs)
return await self.interaction.original_message()

10
discord/ext/commands/core.py

@ -395,7 +395,7 @@ class Command(_BaseCommand, Generic[CogT, P, T]):
self.require_var_positional: bool = kwargs.get('require_var_positional', False)
self.ignore_extra: bool = kwargs.get('ignore_extra', True)
self.cooldown_after_parsing: bool = kwargs.get('cooldown_after_parsing', False)
self.cog: CogT = None
self._cog: CogT = None
# bandaid for the fact that sometimes parent can be the bot instance
parent: Optional[GroupMixin[Any]] = kwargs.get('parent')
@ -417,6 +417,14 @@ class Command(_BaseCommand, Generic[CogT, P, T]):
else:
self.after_invoke(after_invoke)
@property
def cog(self) -> CogT:
return self._cog
@cog.setter
def cog(self, value: CogT) -> None:
self._cog = value
@property
def callback(
self,

21
discord/ext/commands/errors.py

@ -32,6 +32,7 @@ if TYPE_CHECKING:
from discord.abc import GuildChannel
from discord.threads import Thread
from discord.types.snowflake import Snowflake, SnowflakeList
from discord.app_commands import AppCommandError
from ._types import BotT
from .context import Context
@ -100,6 +101,7 @@ __all__ = (
'MissingFlagArgument',
'TooManyFlags',
'MissingRequiredFlag',
'HybridCommandError',
)
@ -1123,3 +1125,22 @@ class MissingFlagArgument(FlagError):
def __init__(self, flag: Flag) -> None:
self.flag: Flag = flag
super().__init__(f'Flag {flag.name!r} does not have an argument')
class HybridCommandError(CommandError):
"""An exception raised when a :class:`~discord.ext.commands.HybridCommand` raises
an :exc:`~discord.app_commands.AppCommandError` derived exception that could not be
sufficiently converted to an equivalent :exc:`CommandError` exception.
.. versionadded:: 2.0
Attributes
-----------
original: :exc:`~discord.app_commands.AppCommandError`
The original exception that was raised. You can also get this via
the ``__cause__`` attribute.
"""
def __init__(self, original: AppCommandError) -> None:
self.original: AppCommandError = original
super().__init__(f'Hybrid command raised an error: {original}')

458
discord/ext/commands/hybrid.py

@ -0,0 +1,458 @@
"""
The MIT License (MIT)
Copyright (c) 2015-present Rapptz
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
from typing import (
TYPE_CHECKING,
Any,
Callable,
ClassVar,
Dict,
List,
Type,
TypeVar,
Union,
Optional,
)
import discord
import inspect
from discord import app_commands
from discord.utils import MISSING, maybe_coroutine, async_all
from .core import Command, Group
from .errors import CommandRegistrationError, CommandError, HybridCommandError, ConversionError
from .converter import Converter
from .cog import Cog
if TYPE_CHECKING:
from typing_extensions import Self, ParamSpec, Concatenate
from ._types import ContextT, Coro, BotT
from .bot import Bot
from .context import Context
from .parameters import Parameter
from discord.app_commands.commands import Check as AppCommandCheck
__all__ = (
'HybridCommand',
'HybridGroup',
'hybrid_command',
'hybrid_group',
)
T = TypeVar('T')
CogT = TypeVar('CogT', bound='Cog')
CommandT = TypeVar('CommandT', bound='Command')
# CHT = TypeVar('CHT', bound='Check')
GroupT = TypeVar('GroupT', bound='Group')
if TYPE_CHECKING:
P = ParamSpec('P')
P2 = ParamSpec('P2')
CommandCallback = Union[
Callable[Concatenate[CogT, ContextT, P], Coro[T]],
Callable[Concatenate[ContextT, P], Coro[T]],
]
else:
P = TypeVar('P')
P2 = TypeVar('P2')
def is_converter(converter: Any) -> bool:
return (inspect.isclass(converter) and issubclass(converter, Converter)) or isinstance(converter, Converter)
def make_converter_transformer(converter: Any) -> Type[app_commands.Transformer]:
async def transform(cls, interaction: discord.Interaction, value: str) -> Any:
try:
if inspect.isclass(converter) and issubclass(converter, Converter):
if inspect.ismethod(converter.convert):
return await converter.convert(interaction._baton, value)
else:
return await converter().convert(interaction._baton, value) # type: ignore
elif isinstance(converter, Converter):
return await converter.convert(interaction._baton, value) # type: ignore
except CommandError:
raise
except Exception as exc:
raise ConversionError(converter, exc) from exc # type: ignore
return type('ConverterTransformer', (app_commands.Transformer,), {'transform': classmethod(transform)})
def replace_parameters(parameters: Dict[str, Parameter], signature: inspect.Signature) -> List[inspect.Parameter]:
# Need to convert commands.Parameter back to inspect.Parameter so this will be a bit ugly
params = signature.parameters.copy()
for name, parameter in parameters.items():
if is_converter(parameter.converter) and not hasattr(parameter.converter, '__discord_app_commands_transformer__'):
params[name] = params[name].replace(annotation=make_converter_transformer(parameter.converter))
return list(params.values())
class HybridAppCommand(discord.app_commands.Command[CogT, P, T]):
def __init__(self, wrapped: HybridCommand[CogT, Any, T]) -> None:
signature = inspect.signature(wrapped.callback)
params = replace_parameters(wrapped.params, signature)
wrapped.callback.__signature__ = signature.replace(parameters=params)
try:
super().__init__(
name=wrapped.name,
callback=wrapped.callback, # type: ignore # Signature doesn't match but we're overriding the invoke
description=wrapped.description or wrapped.short_doc or '',
)
finally:
del wrapped.callback.__signature__
self.wrapped: HybridCommand[CogT, Any, T] = wrapped
self.binding = wrapped.cog
def _copy_with(self, **kwargs) -> Self:
copy: Self = super()._copy_with(**kwargs) # type: ignore
copy.wrapped = self.wrapped
return copy
async def _check_can_run(self, interaction: discord.Interaction) -> bool:
# Hybrid checks must run like so:
# - Bot global check once
# - Bot global check
# - Parent interaction check
# - Cog/group interaction check
# - Cog check
# - Local interaction checks
# - Local command checks
bot: Bot = interaction.client # type: ignore
ctx: Context[Bot] = interaction._baton
if not await bot.can_run(ctx, call_once=True):
return False
if not await bot.can_run(ctx):
return False
if self.parent is not None and self.parent is not self.binding:
# For commands with a parent which isn't the binding, i.e.
# <binding>
# <parent>
# <command>
# The parent check needs to be called first
if not await maybe_coroutine(self.parent.interaction_check, interaction):
return False
if self.binding is not None:
try:
# Type checker does not like runtime attribute retrieval
check: AppCommandCheck = self.binding.interaction_check # type: ignore
except AttributeError:
pass
else:
ret = await maybe_coroutine(check, interaction)
if not ret:
return False
local_check = Cog._get_overridden_method(self.binding.cog_check)
if local_check is not None:
ret = await maybe_coroutine(local_check, ctx)
if not ret:
return False
if self.checks and not await async_all(f(interaction) for f in self.checks): # type: ignore
return False
if self.wrapped.checks and not await async_all(f(ctx) for f in self.wrapped.checks): # type: ignore
return False
return True
async def _invoke_with_namespace(self, interaction: discord.Interaction, namespace: app_commands.Namespace) -> Any:
# Wrap the interaction into a Context
bot: Bot = interaction.client # type: ignore
# Unfortunately, `get_context` has to be called for this to work.
# If someone doesn't inherit this to replace it with their custom class
# then this doesn't work.
interaction._baton = ctx = await bot.get_context(interaction)
exc: CommandError
try:
await self.wrapped.prepare(ctx)
# This lies and just always passes a Context instead of an Interaction.
return await self._do_call(ctx, ctx.kwargs) # type: ignore
except app_commands.CommandSignatureMismatch:
raise
except app_commands.TransformerError as e:
if isinstance(e.__cause__, CommandError):
exc = e.__cause__
else:
exc = HybridCommandError(e)
exc.__cause__ = e
except app_commands.AppCommandError as e:
exc = HybridCommandError(e)
exc.__cause__ = e
except CommandError as e:
exc = e
await self.wrapped.dispatch_error(ctx, exc)
class HybridCommand(Command[CogT, P, T]):
r"""A class that is both an application command and a regular text command.
This has the same parameters and attributes as a regular :class:`~discord.ext.commands.Command`.
However, it also doubles as an :class:`application command <discord.app_commands.Command>`. In order
for this to work, the callbacks must have the same subset that is supported by application
commands.
These are not created manually, instead they are created via the
decorator or functional interface.
.. versionadded:: 2.0
"""
__commands_is_hybrid__: ClassVar[bool] = True
def __init__(
self,
func: CommandCallback[CogT, ContextT, P, T],
/,
**kwargs,
) -> None:
super().__init__(func, **kwargs)
self.app_command: HybridAppCommand[CogT, Any, T] = HybridAppCommand(self)
@property
def cog(self) -> CogT:
return self._cog
@cog.setter
def cog(self, value: CogT) -> None:
self._cog = value
self.app_command.binding = value
async def can_run(self, ctx: Context[BotT], /) -> bool:
if ctx.interaction is None:
return await super().can_run(ctx)
else:
return await self.app_command._check_can_run(ctx.interaction)
async def _parse_arguments(self, ctx: Context[BotT]) -> None:
interaction = ctx.interaction
if interaction is None:
return await super()._parse_arguments(ctx)
else:
ctx.kwargs = await self.app_command._transform_arguments(interaction, interaction.namespace)
class HybridGroup(Group[CogT, P, T]):
r"""A class that is both an application command group and a regular text group.
This has the same parameters and attributes as a regular :class:`~discord.ext.commands.Group`.
However, it also doubles as an :class:`application command group <discord.app_commands.Group>`.
Note that application commands groups cannot have callbacks associated with them, so the callback
is only called if it's not invoked as an application command.
These are not created manually, instead they are created via the
decorator or functional interface.
.. versionadded:: 2.0
"""
__commands_is_hybrid__: ClassVar[bool] = True
def __init__(self, *args: Any, **attrs: Any) -> None:
super().__init__(*args, **attrs)
parent = None
if self.parent is not None:
if isinstance(self.parent, HybridGroup):
parent = self.parent.app_command
else:
raise TypeError(f'HybridGroup parent must be HybridGroup not {self.parent.__class__}')
guild_ids = attrs.pop('guild_ids', None) or getattr(self.callback, '__discord_app_commands_default_guilds__', None)
self.app_command: app_commands.Group = app_commands.Group(
name=self.name,
description=self.description or self.short_doc or '',
guild_ids=guild_ids,
)
# This prevents the group from re-adding the command at __init__
self.app_command.parent = parent
def add_command(self, command: Union[HybridGroup[CogT, ..., Any], HybridCommand[CogT, ..., Any]], /) -> None:
"""Adds a :class:`.HybridCommand` into the internal list of commands.
This is usually not called, instead the :meth:`~.GroupMixin.command` or
:meth:`~.GroupMixin.group` shortcut decorators are used instead.
Parameters
-----------
command: :class:`HybridCommand`
The command to add.
Raises
-------
CommandRegistrationError
If the command or its alias is already registered by different command.
TypeError
If the command passed is not a subclass of :class:`.HybridCommand`.
"""
if not isinstance(command, (HybridCommand, HybridGroup)):
raise TypeError('The command passed must be a subclass of HybridCommand or HybridGroup')
if isinstance(command, HybridGroup) and self.parent is not None:
raise ValueError(f'{command.qualified_name!r} is too nested, groups can only be nested at most one level')
self.app_command.add_command(command.app_command)
command.parent = self
if command.name in self.all_commands:
raise CommandRegistrationError(command.name)
self.all_commands[command.name] = command
for alias in command.aliases:
if alias in self.all_commands:
self.remove_command(command.name)
raise CommandRegistrationError(alias, alias_conflict=True)
self.all_commands[alias] = command
def remove_command(self, name: str, /) -> Optional[Command[CogT, ..., Any]]:
cmd = super().remove_command(name)
self.app_command.remove_command(name)
return cmd
def command(
self,
name: str = MISSING,
*args: Any,
**kwargs: Any,
) -> Callable[[CommandCallback[CogT, ContextT, P2, T]], HybridCommand[CogT, P2, T]]:
"""A shortcut decorator that invokes :func:`~discord.ext.commands.hybrid_command` and adds it to
the internal command list via :meth:`add_command`.
Returns
--------
Callable[..., :class:`Command`]
A decorator that converts the provided method into a Command, adds it to the bot, then returns it.
"""
def decorator(func: CommandCallback[CogT, ContextT, P2, T]):
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 = MISSING,
*args: Any,
**kwargs: Any,
) -> Callable[[CommandCallback[CogT, ContextT, P2, T]], HybridGroup[CogT, P2, T]]:
"""A shortcut decorator that invokes :func:`.group` and adds it to
the internal command list via :meth:`~.GroupMixin.add_command`.
Returns
--------
Callable[..., :class:`Group`]
A decorator that converts the provided method into a Group, adds it to the bot, then returns it.
"""
def decorator(func: CommandCallback[CogT, ContextT, P2, T]):
kwargs.setdefault('parent', self)
result = hybrid_group(name=name, *args, **kwargs)(func)
self.add_command(result)
return result
return decorator
def hybrid_command(
name: str = MISSING,
**attrs: Any,
) -> Callable[[CommandCallback[CogT, ContextT, P, T]], HybridCommand[CogT, P, T]]:
"""A decorator that transforms a function into a :class:`.HybridCommand`.
A hybrid command is one that functions both as a regular :class:`.Command`
and one that is also a :class:`app_commands.Command <discord.app_commands.Command>`.
The callback being attached to the command must be representable as an
application command callback. Converters are silently converted into a
:class:`~discord.app_commands.Transformer` with a
:attr:`discord.AppCommandOptionType.string` type.
Checks and error handlers are dispatched and called as-if they were commands
similar to :class:`.Command`. This means that they take :class:`Context` as
a parameter rather than :class:`discord.Interaction`.
All checks added using the :func:`.check` & co. decorators are added into
the function. There is no way to supply your own checks through this
decorator.
.. versionadded:: 2.0
Parameters
-----------
name: :class:`str`
The name to create the command with. By default this uses the
function name unchanged.
attrs
Keyword arguments to pass into the construction of the
hybrid command.
Raises
-------
TypeError
If the function is not a coroutine or is already a command.
"""
def decorator(func: CommandCallback[CogT, ContextT, P, T]):
if isinstance(func, Command):
raise TypeError('Callback is already a command.')
return HybridCommand(func, name=name, **attrs)
return decorator
def hybrid_group(
name: str = MISSING,
**attrs: Any,
) -> Callable[[CommandCallback[CogT, ContextT, P, T]], HybridGroup[CogT, P, T]]:
"""A decorator that transforms a function into a :class:`.HybridGroup`.
This is similar to the :func:`~discord.ext.commands.group` decorator except it creates
a hybrid group instead.
"""
def decorator(func: CommandCallback[CogT, ContextT, P, T]):
if isinstance(func, Command):
raise TypeError('Callback is already a command.')
return HybridGroup(func, name=name, **attrs)
return decorator # type: ignore

4
discord/interactions.py

@ -132,6 +132,7 @@ class Interaction:
'_state',
'_client',
'_session',
'_baton',
'_original_message',
'_cs_response',
'_cs_followup',
@ -145,6 +146,9 @@ class Interaction:
self._client: Client = state._get_client()
self._session: ClientSession = state.http._HTTPClient__session # type: ignore # Mangled attribute for __session
self._original_message: Optional[InteractionMessage] = None
# This baton is used for extra data that might be useful for the lifecycle of
# an interaction. This is mainly for internal purposes and it gives it a free-for-all slot.
self._baton: Any = MISSING
self._from_data(data)
def _from_data(self, data: InteractionPayload):

56
docs/ext/commands/api.rst

@ -114,6 +114,13 @@ Decorators
.. autofunction:: discord.ext.commands.group
:decorator:
.. autofunction:: discord.ext.commands.hybrid_command
:decorator:
.. autofunction:: discord.ext.commands.hybrid_group
:decorator:
Command
~~~~~~~~~
@ -173,6 +180,51 @@ GroupMixin
.. automethod:: GroupMixin.group(*args, **kwargs)
:decorator:
HybridCommand
~~~~~~~~~~~~~~
.. attributetable:: discord.ext.commands.HybridCommand
.. autoclass:: discord.ext.commands.HybridCommand
:members:
:special-members: __call__
:exclude-members: after_invoke, before_invoke, error
.. automethod:: HybridCommand.after_invoke()
:decorator:
.. automethod:: HybridCommand.before_invoke()
:decorator:
.. automethod:: HybridCommand.error()
:decorator:
HybridGroup
~~~~~~~~~~~~
.. attributetable:: discord.ext.commands.HybridGroup
.. autoclass:: discord.ext.commands.HybridGroup
:members:
:inherited-members:
:exclude-members: after_invoke, before_invoke, command, error, group
.. automethod:: HybridGroup.after_invoke()
:decorator:
.. automethod:: HybridGroup.before_invoke()
:decorator:
.. automethod:: HybridGroup.command(*args, **kwargs)
:decorator:
.. automethod:: HybridGroup.error()
:decorator:
.. automethod:: HybridGroup.group(*args, **kwargs)
:decorator:
.. _ext_commands_api_cogs:
Cogs
@ -631,6 +683,9 @@ Exceptions
.. autoexception:: discord.ext.commands.CommandRegistrationError
:members:
.. autoexception:: discord.ext.commands.HybridCommandError
:members:
Exception Hierarchy
~~~~~~~~~~~~~~~~~~~~~
@ -687,6 +742,7 @@ Exception Hierarchy
- :exc:`~.commands.CommandInvokeError`
- :exc:`~.commands.CommandOnCooldown`
- :exc:`~.commands.MaxConcurrencyReached`
- :exc:`~.commands.HybridCommandError`
- :exc:`~.commands.ExtensionError`
- :exc:`~.commands.ExtensionAlreadyLoaded`
- :exc:`~.commands.ExtensionNotLoaded`

Loading…
Cancel
Save