From 68d1b40239d3db4dac09805a322e6cbeeade26d4 Mon Sep 17 00:00:00 2001 From: dolfies Date: Tue, 6 Sep 2022 19:03:54 -0400 Subject: [PATCH] Fix various app command bugs, improve documentation, implement missing fields --- discord/abc.py | 297 +++++++++++++++++--------------- discord/commands.py | 282 ++++++++++++++++++++++++------ discord/components.py | 4 +- discord/ext/commands/context.py | 8 +- discord/http.py | 2 +- discord/message.py | 90 +++------- discord/modal.py | 2 +- docs/api.rst | 75 ++++++-- docs/ext/commands/api.rst | 13 +- 9 files changed, 488 insertions(+), 285 deletions(-) diff --git a/discord/abc.py b/discord/abc.py index 078734825..535cfc815 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -33,6 +33,7 @@ from typing import ( Callable, Dict, List, + Literal, Optional, TYPE_CHECKING, Protocol, @@ -57,7 +58,7 @@ from .http import handle_message_parameters from .voice_client import VoiceClient, VoiceProtocol from .sticker import GuildSticker, StickerItem from .settings import ChannelSettings -from .commands import ApplicationCommand, SlashCommand, UserCommand +from .commands import ApplicationCommand, BaseCommand, SlashCommand, UserCommand, MessageCommand, _command_factory from . import utils __all__ = ( @@ -145,6 +146,127 @@ async def _purge_helper( return ret +@overload +def _handle_commands( + messageable: Messageable, + type: Literal[AppCommandType.chat_input], + *, + query: Optional[str] = ..., + limit: Optional[int] = ..., + command_ids: Optional[List[int]] = ..., + application: Optional[Snowflake] = ..., + include_applications: bool = ..., + target: Optional[Snowflake] = ..., +) -> AsyncIterator[SlashCommand]: + ... + + +@overload +def _handle_commands( + messageable: Messageable, + type: Literal[AppCommandType.user], + *, + query: Optional[str] = ..., + limit: Optional[int] = ..., + command_ids: Optional[List[int]] = ..., + application: Optional[Snowflake] = ..., + include_applications: bool = ..., + target: Optional[Snowflake] = ..., +) -> AsyncIterator[UserCommand]: + ... + + +@overload +def _handle_commands( + messageable: Message, + type: Literal[AppCommandType.message], + *, + query: Optional[str] = ..., + limit: Optional[int] = ..., + command_ids: Optional[List[int]] = ..., + application: Optional[Snowflake] = ..., + include_applications: bool = ..., + target: Optional[Snowflake] = ..., +) -> AsyncIterator[MessageCommand]: + ... + + +async def _handle_commands( + messageable: Union[Messageable, Message], + type: AppCommandType, + *, + query: Optional[str] = None, + limit: Optional[int] = None, + command_ids: Optional[List[int]] = None, + application: Optional[Snowflake] = None, + include_applications: bool = True, + target: Optional[Snowflake] = None, +) -> AsyncIterator[BaseCommand]: + if limit is not None and limit < 0: + raise ValueError('limit must be greater than or equal to 0') + if query and command_ids: + raise TypeError('Cannot specify both query and command_ids') + + state = messageable._state + endpoint = state.http.search_application_commands + channel = await messageable._get_channel() + + application_id = application.id if application else None + if channel.type == ChannelType.private: + recipient: User = channel.recipient # type: ignore + if not recipient.bot: + raise TypeError('Cannot fetch commands in a DM with a non-bot user') + application_id = recipient.id + target = recipient + elif channel.type == ChannelType.group: + return + + while True: + # We keep two cursors because Discord just sends us an infinite loop sometimes + prev_cursor = MISSING + cursor = MISSING + retrieve = min((25 if not command_ids else 0) if limit is None else limit, 25) + if limit is not None: + limit -= retrieve + if (not command_ids and retrieve < 1) or cursor is None or (prev_cursor is not MISSING and prev_cursor == cursor): + return + + data = await endpoint( + channel.id, + type.value, + limit=retrieve if not application_id else None, + query=query if not command_ids and not application_id else None, + command_ids=command_ids if not application_id else None, # type: ignore + application_id=application_id, + include_applications=include_applications, + cursor=cursor, + ) + prev_cursor = cursor + cursor = data['cursor'].get('next') + cmds = data['application_commands'] + apps: Dict[int, dict] = {int(app['id']): app for app in data.get('applications') or []} + + if len(cmds) <= min(limit if limit else 25, 25) or application_id: + limit = 0 + + for cmd in cmds: + # Handle faked parameters + if application_id and command_ids and int(cmd['id']) not in command_ids: + continue + elif application_id and query and query.lower() not in cmd['name']: + continue + elif application_id and limit == 0: + return + + # We follow Discord behavior + if limit is not None and (not command_ids or int(cmd['id']) not in command_ids): + limit -= 1 + + cmd['application'] = apps.get(int(cmd['application_id'])) + _, cls = _command_factory(type.value) + yield cls(state=state, data=cmd, channel=channel, target=target) + + @runtime_checkable class Snowflake(Protocol): """An ABC that details the common operations on a Discord model. @@ -194,6 +316,7 @@ class User(Snowflake, Protocol): name: str discriminator: str bot: bool + system: bool @property def display_name(self) -> str: @@ -1529,7 +1652,7 @@ class Messageable: async def ack_pins(self) -> None: """|coro| - Acks the channel's pins. + Marks a channel's pins as viewed. Raises ------- @@ -1727,14 +1850,14 @@ class Messageable: for raw_message in data: yield self._state.create_message(channel=channel, data=raw_message) - async def slash_commands( + def slash_commands( self, query: Optional[str] = None, *, limit: Optional[int] = None, command_ids: Optional[List[int]] = None, application: Optional[Snowflake] = None, - include_applications: bool = MISSING, + include_applications: bool = True, ) -> AsyncIterator[SlashCommand]: """Returns a :term:`asynchronous iterator` of the slash commands available in the channel. @@ -1760,23 +1883,24 @@ class Messageable: This parameter is faked if ``application`` is specified. limit: Optional[:class:`int`] - The maximum number of commands to send back. Defaults to 25. If ``None``, returns all commands. + The maximum number of commands to send back. Defaults to 0 if ``command_ids`` is passed, else 25. + If ``None``, returns all commands. This parameter is faked if ``application`` is specified. command_ids: Optional[List[:class:`int`]] List of up to 100 command IDs to search for. If the command doesn't exist, it won't be returned. + + If ``limit`` is passed alongside this parameter, this parameter will serve as a "preferred commands" list. + This means that the endpoint will return the found commands + ``limit`` more, if available. application: Optional[:class:`~discord.abc.Snowflake`] - Return this application's commands. Always set to DM recipient in a private channel context. + Whether to return this application's commands. Always set to DM recipient in a private channel context. include_applications: :class:`bool` - Whether to include applications in the response. This defaults to ``True`` if possible. - - Cannot be set to ``True`` if ``application`` is specified. + Whether to include applications in the response. Defaults to ``True``. Raises ------ TypeError Both query and command_ids are passed. - Both application and include_applications are passed. Attempted to fetch commands in a DM with a non-bot user. ValueError The limit was not greater than or equal to 0. @@ -1792,76 +1916,24 @@ class Messageable: :class:`.SlashCommand` A slash command. """ - if limit is not None and limit < 0: - raise ValueError('limit must be greater than or equal to 0') - if query and command_ids: - raise TypeError('Cannot specify both query and command_ids') - - state = self._state - endpoint = state.http.search_application_commands - channel = await self._get_channel() - - application_id = application.id if application else None - if channel.type == ChannelType.private: - recipient: User = channel.recipient # type: ignore - if not recipient.bot: - raise TypeError('Cannot fetch commands in a DM with a non-bot user') - application_id = recipient.id - elif channel.type == ChannelType.group: - return - - if application_id and include_applications: - raise TypeError('Cannot specify both application and include_applications') - include_applications = ( - (False if application_id else True) if include_applications is MISSING else include_applications + return _handle_commands( + self, + AppCommandType.chat_input, + query=query, + limit=limit, + command_ids=command_ids, + application=application, + include_applications=include_applications, ) - while True: - cursor = MISSING - retrieve = min(25 if limit is None else limit, 25) - if retrieve < 1 or cursor is None: - return - - data = await endpoint( - channel.id, - AppCommandType.chat_input.value, - limit=retrieve if not application_id else None, - query=query if not command_ids and not application_id else None, - command_ids=command_ids if not application_id else None, # type: ignore - application_id=application_id, - include_applications=include_applications if not application_id else None, - cursor=cursor if cursor is not MISSING else None, - ) - cursor = data['cursor'].get('next') - cmds = data['application_commands'] - apps: Dict[int, dict] = {int(app['id']): app for app in data.get('applications') or []} - - if len(cmds) < 25: - limit = 0 - - for cmd in cmds: - # Handle faked parameters - if command_ids and int(cmd['id']) not in command_ids: - continue - elif application_id and query and query.lower() not in cmd['name']: - continue - elif application_id and limit == 0: - return - - if limit is not None: - limit -= 1 - - cmd['application'] = apps.get(int(cmd['application_id'])) - yield SlashCommand(state=state, data=cmd, channel=channel) - - async def user_commands( + def user_commands( self, query: Optional[str] = None, *, limit: Optional[int] = None, command_ids: Optional[List[int]] = None, application: Optional[Snowflake] = None, - include_applications: bool = MISSING, + include_applications: bool = True, ) -> AsyncIterator[UserCommand]: """Returns a :term:`asynchronous iterator` of the user commands available to use on the user. @@ -1887,23 +1959,24 @@ class Messageable: This parameter is faked if ``application`` is specified. limit: Optional[:class:`int`] - The maximum number of commands to send back. Defaults to 25. If ``None``, returns all commands. + The maximum number of commands to send back. Defaults to 0 if ``command_ids`` is passed, else 25. + If ``None``, returns all commands. This parameter is faked if ``application`` is specified. command_ids: Optional[List[:class:`int`]] List of up to 100 command IDs to search for. If the command doesn't exist, it won't be returned. + + If ``limit`` is passed alongside this parameter, this parameter will serve as a "preferred commands" list. + This means that the endpoint will return the found commands + ``limit`` more, if available. application: Optional[:class:`~discord.abc.Snowflake`] - Return this application's commands. Always set to DM recipient in a private channel context. + Whether to return this application's commands. Always set to DM recipient in a private channel context. include_applications: :class:`bool` - Whether to include applications in the response. This defaults to ``True`` if possible. - - Cannot be set to ``True`` if ``application`` is specified. + Whether to include applications in the response. Defaults to ``True``. Raises ------ TypeError Both query and command_ids are passed. - Both application and include_applications are passed. Attempted to fetch commands in a DM with a non-bot user. ValueError The limit was not greater than or equal to 0. @@ -1919,68 +1992,16 @@ class Messageable: :class:`.UserCommand` A user command. """ - if limit is not None and limit < 0: - raise ValueError('limit must be greater than or equal to 0') - if query and command_ids: - raise TypeError('Cannot specify both query and command_ids') - - state = self._state - endpoint = state.http.search_application_commands - channel = await self._get_channel() - - application_id = application.id if application else None - if channel.type == ChannelType.private: - recipient: User = channel.recipient # type: ignore - if not recipient.bot: - raise TypeError('Cannot fetch commands in a DM with a non-bot user') - application_id = recipient.id - elif channel.type == ChannelType.group: - return - - if application_id and include_applications: - raise TypeError('Cannot specify both application and include_applications') - include_applications = ( - (False if application_id else True) if include_applications is MISSING else include_applications + return _handle_commands( + self, + AppCommandType.user, + query=query, + limit=limit, + command_ids=command_ids, + application=application, + include_applications=include_applications, ) - while True: - cursor = MISSING - retrieve = min(25 if limit is None else limit, 25) - if retrieve < 1 or cursor is None: - return - - data = await endpoint( - channel.id, - AppCommandType.user.value, - limit=retrieve if not application_id else None, - query=query if not command_ids and not application_id else None, - command_ids=command_ids if not application_id else None, # type: ignore - application_id=application_id, - include_applications=include_applications if not application_id else None, - cursor=cursor if cursor is not MISSING else None, - ) - cursor = data['cursor'].get('next') - cmds = data['application_commands'] - apps: Dict[int, dict] = {int(app['id']): app for app in data.get('applications') or []} - - if len(cmds) < 25: - limit = 0 - - for cmd in cmds: - # Handle faked parameters - if command_ids and int(cmd['id']) not in command_ids: - continue - elif application_id and query and query.lower() not in cmd['name'].lower(): - continue - elif application_id and limit == 0: - return - - if limit is not None: - limit -= 1 - - cmd['application'] = apps.get(int(cmd['application_id'])) - yield UserCommand(state=state, data=cmd, channel=channel, user=getattr(channel, 'recipient', None)) - class Connectable(Protocol): """An ABC that details the common operations on a channel that can diff --git a/discord/commands.py b/discord/commands.py index 1db4f0b39..cc245e5d6 100644 --- a/discord/commands.py +++ b/discord/commands.py @@ -36,10 +36,12 @@ if TYPE_CHECKING: from .abc import Messageable, Snowflake from .appinfo import InteractionApplication from .file import File + from .guild import Guild from .interactions import Interaction from .message import Attachment, Message from .state import ConnectionState + __all__ = ( 'BaseCommand', 'UserCommand', @@ -57,12 +59,13 @@ class ApplicationCommand(Protocol): The following implement this ABC: - - :class:`~discord.BaseCommand` - :class:`~discord.UserCommand` - :class:`~discord.MessageCommand` - :class:`~discord.SlashCommand` - :class:`~discord.SubCommand` + .. versionadded:: 2.0 + Attributes ----------- name: :class:`str` @@ -75,11 +78,16 @@ class ApplicationCommand(Protocol): Whether the command is enabled in guilds by default. dm_permission: :class:`bool` Whether the command is enabled in DMs. + nsfw: :class:`bool` + Whether the command is marked NSFW and only available in NSFW channels. application: Optional[:class:`~discord.InteractionApplication`] The application this command belongs to. Only available if requested. application_id: :class:`int` The ID of the application this command belongs to. + guild_id: Optional[:class:`int`] + The ID of the guild this command is registered in. A value of ``None`` + denotes that it is a global command. """ __slots__ = () @@ -94,8 +102,11 @@ class ApplicationCommand(Protocol): type: AppCommandType default_permission: bool dm_permission: bool + nsfw: bool application_id: int application: Optional[InteractionApplication] + mention: str + guild_id: Optional[int] def __str__(self) -> str: return self.name @@ -117,7 +128,7 @@ class ApplicationCommand(Protocol): i = await state.client.wait_for( 'interaction_finish', check=lambda d: d.nonce == nonce, - timeout=7, + timeout=6, ) except TimeoutError as exc: raise InvalidData('Did not receive a response from Discord') from exc @@ -125,6 +136,13 @@ class ApplicationCommand(Protocol): state._interaction_cache.pop(nonce, None) return i + @property + def guild(self) -> Optional[Guild]: + """Optional[:class:`~discord.Guild`]: Returns the guild this command is registered to + if it exists. + """ + return self._state._get_guild(self.guild_id) + def is_group(self) -> bool: """Query whether this command is a group. @@ -163,49 +181,6 @@ class ApplicationCommand(Protocol): class BaseCommand(ApplicationCommand, Hashable): - """Represents a base command. - - .. container:: operations - - .. describe:: x == y - - Checks if two commands are equal. - - .. describe:: x != y - - Checks if two commands are not equal. - - .. describe:: hash(x) - - Return the command's hash. - - .. describe:: str(x) - - Returns the command's name. - - Attributes - ---------- - id: :class:`int` - The command's ID. - version: :class:`int` - The command's version. - name: :class:`str` - The command's name. - description: :class:`str` - The command's description, if any. - type: :class:`AppCommandType` - The type of application command. - default_permission: :class:`bool` - Whether the command is enabled in guilds by default. - dm_permission: :class:`bool` - Whether the command is enabled in DMs. - application: Optional[:class:`InteractionApplication`] - The application this command belongs to. - Only available if requested. - application_id: :class:`int` - The ID of the application this command belongs to. - """ - __slots__ = ( 'name', 'description', @@ -216,13 +191,15 @@ class BaseCommand(ApplicationCommand, Hashable): 'application', 'application_id', 'dm_permission', + 'nsfw', + 'guild_id', '_data', '_state', '_channel', '_default_member_permissions', ) - def __init__(self, *, state: ConnectionState, data: Dict[str, Any], channel: Optional[Messageable] = None) -> None: + def __init__(self, *, state: ConnectionState, data: Dict[str, Any], channel: Optional[Messageable] = None, **kwargs) -> None: self._state = state self._data = data self.name = data['name'] @@ -240,10 +217,17 @@ class BaseCommand(ApplicationCommand, Hashable): self.default_permission: bool = data.get('default_permission', True) dm_permission = data.get('dm_permission') # Null means true? self.dm_permission = dm_permission if dm_permission is not None else True + self.nsfw: bool = data.get('nsfw', False) + self.guild_id: Optional[int] = _get_as_snowflake(data, 'guild_id') def __repr__(self) -> str: return f'<{self.__class__.__name__} id={self.id} name={self.name!r}>' + @property + def mention(self) -> str: + """:class:`str`: Returns a string that allows you to mention the command.""" + return f'' + class SlashMixin(ApplicationCommand, Protocol): if TYPE_CHECKING: @@ -270,6 +254,8 @@ class SlashMixin(ApplicationCommand, Protocol): 'type': obj.type.value, 'version': str(obj.version), } + if self.guild_id: + data['guild_id'] = str(self.guild_id) return await super().__call__(data, files, channel) def _parse_kwargs(self, kwargs: Dict[str, Any]) -> Tuple[List[Dict[str, Any]], List[File], List[Attachment]]: @@ -330,13 +316,63 @@ class SlashMixin(ApplicationCommand, Protocol): class UserCommand(BaseCommand): - """Represents a user command.""" + """Represents a user command. + + .. versionadded:: 2.0 + + .. container:: operations + + .. describe:: x == y + + Checks if two commands are equal. + + .. describe:: x != y + + Checks if two commands are not equal. + + .. describe:: hash(x) + + Return the command's hash. + + .. describe:: str(x) + + Returns the command's name. + + Attributes + ---------- + id: :class:`int` + The command's ID. + version: :class:`int` + The command's version. + name: :class:`str` + The command's name. + description: :class:`str` + The command's description, if any. + type: :class:`AppCommandType` + The type of application command. This will always be :attr:`AppCommandType.user`. + default_permission: :class:`bool` + Whether the command is enabled in guilds by default. + dm_permission: :class:`bool` + Whether the command is enabled in DMs. + nsfw: :class:`bool` + Whether the command is marked NSFW and only available in NSFW channels. + application: Optional[:class:`InteractionApplication`] + The application this command belongs to. + Only available if requested. + application_id: :class:`int` + The ID of the application this command belongs to. + guild_id: Optional[:class:`int`] + The ID of the guild this command is registered in. A value of ``None`` + denotes that it is a global command. + + .. automethod:: __call__ + """ __slots__ = ('_user',) - def __init__(self, *, user: Optional[Snowflake] = None, **kwargs): + def __init__(self, *, target: Optional[Snowflake] = None, **kwargs): super().__init__(**kwargs) - self._user = user + self._user = target async def __call__(self, user: Optional[Snowflake] = None, *, channel: Optional[Messageable] = None): """Use the user command. @@ -385,13 +421,63 @@ class UserCommand(BaseCommand): class MessageCommand(BaseCommand): - """Represents a message command.""" + """Represents a message command. + + .. versionadded:: 2.0 + + .. container:: operations + + .. describe:: x == y + + Checks if two commands are equal. + + .. describe:: x != y + + Checks if two commands are not equal. + + .. describe:: hash(x) + + Return the command's hash. + + .. describe:: str(x) + + Returns the command's name. + + Attributes + ---------- + id: :class:`int` + The command's ID. + version: :class:`int` + The command's version. + name: :class:`str` + The command's name. + description: :class:`str` + The command's description, if any. + type: :class:`AppCommandType` + The type of application command. This will always be :attr:`AppCommandType.message`. + default_permission: :class:`bool` + Whether the command is enabled in guilds by default. + dm_permission: :class:`bool` + Whether the command is enabled in DMs. + nsfw: :class:`bool` + Whether the command is marked NSFW and only available in NSFW channels. + application: Optional[:class:`InteractionApplication`] + The application this command belongs to. + Only available if requested. + application_id: :class:`int` + The ID of the application this command belongs to. + guild_id: Optional[:class:`int`] + The ID of the guild this command is registered in. A value of ``None`` + denotes that it is a global command. + + .. automethod:: __call__ + """ __slots__ = ('_message',) - def __init__(self, *, message: Optional[Message] = None, **kwargs): + def __init__(self, *, target: Optional[Message] = None, **kwargs): super().__init__(**kwargs) - self._message = message + self._message = target async def __call__(self, message: Optional[Message] = None, *, channel: Optional[Messageable] = None): """Use the message command. @@ -443,13 +529,58 @@ class MessageCommand(BaseCommand): class SlashCommand(BaseCommand, SlashMixin): """Represents a slash command. + .. versionadded:: 2.0 + + .. container:: operations + + .. describe:: x == y + + Checks if two commands are equal. + + .. describe:: x != y + + Checks if two commands are not equal. + + .. describe:: hash(x) + + Return the command's hash. + + .. describe:: str(x) + + Returns the command's name. + Attributes ---------- + id: :class:`int` + The command's ID. + version: :class:`int` + The command's version. + name: :class:`str` + The command's name. + description: :class:`str` + The command's description, if any. + type: :class:`AppCommandType` + The type of application command. + default_permission: :class:`bool` + Whether the command is enabled in guilds by default. + dm_permission: :class:`bool` + Whether the command is enabled in DMs. + nsfw: :class:`bool` + Whether the command is marked NSFW and only available in NSFW channels. + application: Optional[:class:`InteractionApplication`] + The application this command belongs to. + Only available if requested. + application_id: :class:`int` + The ID of the application this command belongs to. + guild_id: Optional[:class:`int`] + The ID of the guild this command is registered in. A value of ``None`` + denotes that it is a global command. options: List[:class:`Option`] The command's options. children: List[:class:`SubCommand`] The command's subcommands. If a command has subcommands, it is a group and cannot be used. - You can access (and use) subcommands directly as attributes of the class. + + .. automethod:: __call__ """ __slots__ = ('_parent', 'options', 'children') @@ -503,6 +634,8 @@ class SlashCommand(BaseCommand, SlashMixin): class SubCommand(SlashMixin): """Represents a slash command child. + .. versionadded:: 2.0 + This could be a subcommand, or a subgroup. .. container:: operations @@ -519,13 +652,14 @@ class SubCommand(SlashMixin): The subcommand's description, if any. type: :class:`AppCommandType` The type of application command. Always :attr:`AppCommandType.chat_input`. - parent: :class:`SlashCommand` + parent: Union[:class:`SlashCommand`, :class:`SubCommand`] The parent command. options: List[:class:`Option`] The subcommand's options. children: List[:class:`SubCommand`] The subcommand's subcommands. If a subcommand has subcommands, it is a group and cannot be used. - You can access (and use) subcommands directly as attributes of the class. + + .. automethod:: __call__ """ __slots__ = ( @@ -608,6 +742,22 @@ class SubCommand(SlashMixin): BASE += f' children={len(self.children)}' return BASE + '>' + @property + def qualified_name(self) -> str: + """:class:`str`: Returns the fully qualified command name. + The qualified name includes the parent name as well. For example, + in a command like ``/foo bar`` the qualified name is ``foo bar``. + """ + names = [self.name, self.parent.name] + if isinstance(self.parent, SubCommand): + names.append(self._parent.name) + return ' '.join(reversed(names)) + + @property + def mention(self) -> str: + """:class:`str`: Returns a string that allows you to mention the subcommand.""" + return f'' + @property def _default_member_permissions(self) -> Optional[int]: return self._parent._default_member_permissions @@ -632,6 +782,24 @@ class SubCommand(SlashMixin): """:class:`bool`: Whether the command is enabled in DMs.""" return self._parent.dm_permission + @property + def nsfw(self) -> bool: + """:class:`bool`: Whether the command is marked NSFW and only available in NSFW channels.""" + return self._parent.nsfw + + @property + def guild_id(self) -> Optional[int]: + """Optional[:class:`int`]: The ID of the guild this command is registered in. A value of ``None`` + denotes that it is a global command.""" + return self._parent.guild_id + + @property + def guild(self) -> Optional[Guild]: + """Optional[:class:`~discord.Guild`]: Returns the guild this command is registered to + if it exists. + """ + return self._parent.guild + def is_group(self) -> bool: """Query whether this command is a group. @@ -665,6 +833,8 @@ class SubCommand(SlashMixin): class Option: """Represents a command option. + .. versionadded:: 2.0 + .. container:: operations .. describe:: str(x) @@ -739,6 +909,8 @@ class Option: class OptionChoice: """Represents a choice for an option. + .. versionadded:: 2.0 + .. container:: operations .. describe:: str(x) diff --git a/discord/components.py b/discord/components.py index 7e3aceae9..b17877fd1 100644 --- a/discord/components.py +++ b/discord/components.py @@ -228,7 +228,7 @@ class Button(Component): i = await state.client.wait_for( 'interaction_finish', check=lambda d: d.nonce == nonce, - timeout=7, + timeout=6, ) except TimeoutError as exc: raise InvalidData('Did not receive a response from Discord') from exc @@ -324,7 +324,7 @@ class SelectMenu(Component): i = await state.client.wait_for( 'interaction_finish', check=lambda d: d.nonce == nonce, - timeout=7, + timeout=6, ) except TimeoutError as exc: raise InvalidData('Did not receive a response from Discord') from exc diff --git a/discord/ext/commands/context.py b/discord/ext/commands/context.py index 52191c64c..e93b386ae 100644 --- a/discord/ext/commands/context.py +++ b/discord/ext/commands/context.py @@ -411,7 +411,8 @@ class Context(discord.abc.Messageable, Generic[BotT]): async def reply(self, content: Optional[str] = None, **kwargs: Any) -> Message: return await self.message.reply(content, **kwargs) - async def message_commands( + @discord.utils.copy_doc(Message.message_commands) + def message_commands( self, query: Optional[str] = None, *, @@ -420,7 +421,6 @@ class Context(discord.abc.Messageable, Generic[BotT]): application: Optional[discord.abc.Snowflake] = None, include_applications: bool = True, ) -> AsyncIterator[MessageCommand]: - async for command in self.message.message_commands( + return self.message.message_commands( query, limit=limit, command_ids=command_ids, include_applications=include_applications, application=application - ): - yield command + ) diff --git a/discord/http.py b/discord/http.py index 5267b2d75..cffc0c60e 100644 --- a/discord/http.py +++ b/discord/http.py @@ -2389,7 +2389,7 @@ class HTTPClient: if cursor: params['cursor'] = cursor if command_ids: - params['command_ids'] = command_ids + params['command_ids'] = ','.join(map(str, command_ids)) if application_id: params['application_id'] = application_id diff --git a/discord/message.py b/discord/message.py index 4b1286c83..34dea3f8a 100644 --- a/discord/message.py +++ b/discord/message.py @@ -66,6 +66,7 @@ from .threads import Thread from .channel import PartialMessageable from .interactions import Interaction from .commands import MessageCommand +from .abc import _handle_commands if TYPE_CHECKING: @@ -1377,6 +1378,9 @@ class Message(PartialMessage, Hashable): f'<{name} id={self.id} channel={self.channel!r} type={self.type!r} author={self.author!r} flags={self.flags!r}>' ) + async def _get_channel(self) -> MessageableChannel: + return self.channel + def _try_patch(self, data, key, transform=None) -> None: try: value = data[key] @@ -1980,14 +1984,14 @@ class Message(PartialMessage, Hashable): """ return await self.edit(attachments=[a for a in self.attachments if a not in attachments]) - async def message_commands( + def message_commands( self, query: Optional[str] = None, *, limit: Optional[int] = None, command_ids: Optional[List[int]] = None, application: Optional[Snowflake] = None, - include_applications: bool = MISSING, + include_applications: bool = True, ) -> AsyncIterator[MessageCommand]: """Returns a :term:`asynchronous iterator` of the message commands available to use on the message. @@ -2013,23 +2017,24 @@ class Message(PartialMessage, Hashable): This parameter is faked if ``application`` is specified. limit: Optional[:class:`int`] - The maximum number of commands to send back. Defaults to 25. If ``None``, returns all commands. + The maximum number of commands to send back. Defaults to 0 if ``command_ids`` is passed, else 25. + If ``None``, returns all commands. This parameter is faked if ``application`` is specified. command_ids: Optional[List[:class:`int`]] List of up to 100 command IDs to search for. If the command doesn't exist, it won't be returned. + + If ``limit`` is passed alongside this parameter, this parameter will serve as a "preferred commands" list. + This means that the endpoint will return the found commands + ``limit`` more, if available. application: Optional[:class:`~discord.abc.Snowflake`] - Return this application's commands. Always set to DM recipient in a private channel context. + Whether to return this application's commands. Always set to DM recipient in a private channel context. include_applications: :class:`bool` - Whether to include applications in the response. This defaults to ``True`` if possible. - - Cannot be set to ``True`` if ``application`` is specified. + Whether to include applications in the response. Defaults to ``True``. Raises ------ TypeError Both query and command_ids are passed. - Both application and include_applications are passed. Attempted to fetch commands in a DM with a non-bot user. ValueError The limit was not greater than or equal to 0. @@ -2045,64 +2050,13 @@ class Message(PartialMessage, Hashable): :class:`.MessageCommand` A message command. """ - if limit is not None and limit < 0: - raise ValueError('limit must be greater than or equal to 0') - if query and command_ids: - raise TypeError('Cannot specify both query and command_ids') - - state = self._state - endpoint = state.http.search_application_commands - channel = self.channel - - application_id = application.id if application else None - if channel.type == ChannelType.private: - recipient: User = channel.recipient # type: ignore - if not recipient.bot: - raise TypeError('Cannot fetch commands in a DM with a non-bot user') - application_id = recipient.id - elif channel.type == ChannelType.group: - return - - if application_id and include_applications: - raise TypeError('Cannot specify both application and include_applications') - include_applications = ( - (False if application_id else True) if include_applications is MISSING else include_applications + return _handle_commands( + self, + AppCommandType.message, + query=query, + limit=limit, + command_ids=command_ids, + application=application, + include_applications=include_applications, + target=self, ) - - while True: - cursor = MISSING - retrieve = min(25 if limit is None else limit, 25) - if retrieve < 1 or cursor is None: - return - - data = await endpoint( - channel.id, - AppCommandType.message.value, - limit=retrieve if not application_id else None, - query=query if not command_ids and not application_id else None, - command_ids=command_ids if not application_id else None, # type: ignore - application_id=application_id, - include_applications=include_applications if not application_id else None, - cursor=cursor if cursor is not MISSING else None, - ) - cursor = data['cursor'].get('next') - cmds = data['application_commands'] - apps: Dict[int, dict] = {int(app['id']): app for app in data.get('applications') or []} - - if len(cmds) < 25: - limit = 0 - - for cmd in cmds: - # Handle faked parameters - if command_ids and int(cmd['id']) not in command_ids: - continue - elif application_id and query and query.lower() not in cmd['name'].lower(): - continue - elif application_id and limit == 0: - return - - if limit is not None: - limit -= 1 - - cmd['application'] = apps.get(int(cmd['application_id'])) - yield MessageCommand(state=state, data=cmd, channel=channel, message=self) diff --git a/discord/modal.py b/discord/modal.py index 44b184351..e6855189d 100644 --- a/discord/modal.py +++ b/discord/modal.py @@ -139,7 +139,7 @@ class Modal(Hashable): i = await state.client.wait_for( 'interaction_finish', check=lambda d: d.nonce == nonce, - timeout=7, + timeout=6, ) except TimeoutError as exc: raise InvalidData('Did not receive a response from Discord') from exc diff --git a/docs/api.rst b/docs/api.rst index 3f9d17094..fa8a0295a 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -4071,11 +4071,17 @@ Messageable .. autoclass:: discord.abc.Messageable() :members: - :exclude-members: typing + :exclude-members: typing, slash_commands, user_commands - .. automethod:: discord.abc.Messageable.typing + .. automethod:: typing :async-with: + .. automethod:: slash_commands + :async-for: + + .. automethod:: user_commands + :async-for: + Connectable ~~~~~~~~~~~~ @@ -4132,11 +4138,17 @@ User .. autoclass:: User() :members: :inherited-members: - :exclude-members: typing + :exclude-members: typing, slash_commands, user_commands .. automethod:: typing :async-with: + .. automethod:: slash_commands + :async-for: + + .. automethod:: user_commands + :async-for: + .. attributetable:: UserProfile .. autoclass:: UserProfile() @@ -4332,11 +4344,17 @@ Member .. autoclass:: Member() :members: :inherited-members: - :exclude-members: typing + :exclude-members: typing, slash_commands, user_commands .. automethod:: typing :async-with: + .. automethod:: slash_commands + :async-for: + + .. automethod:: user_commands + :async-for: + .. attributetable:: MemberProfile .. autoclass:: MemberProfile() @@ -4408,21 +4426,33 @@ GuildChannel .. autoclass:: TextChannel() :members: :inherited-members: - :exclude-members: typing + :exclude-members: typing, slash_commands, user_commands .. automethod:: typing :async-with: + .. automethod:: slash_commands + :async-for: + + .. automethod:: user_commands + :async-for: + .. attributetable:: VoiceChannel .. autoclass:: VoiceChannel() :members: :inherited-members: - :exclude-members: typing + :exclude-members: typing, slash_commands, user_commands .. automethod:: typing :async-with: + .. automethod:: slash_commands + :async-for: + + .. automethod:: user_commands + :async-for: + .. attributetable:: StageChannel .. autoclass:: StageChannel() @@ -4443,21 +4473,33 @@ PrivateChannel .. autoclass:: DMChannel() :members: :inherited-members: - :exclude-members: typing + :exclude-members: typing, slash_commands, user_commands .. automethod:: typing :async-with: + .. automethod:: slash_commands + :async-for: + + .. automethod:: user_commands + :async-for: + .. attributetable:: GroupChannel .. autoclass:: GroupChannel() :members: :inherited-members: - :exclude-members: typing + :exclude-members: typing, slash_commands, user_commands .. automethod:: typing :async-with: + .. automethod:: slash_commands + :async-for: + + .. automethod:: user_commands + :async-for: + PartialMessageable ~~~~~~~~~~~~~~~~~~~ @@ -4475,11 +4517,17 @@ Thread .. autoclass:: Thread() :members: :inherited-members: - :exclude-members: typing + :exclude-members: typing, slash_commands, user_commands .. automethod:: typing :async-with: + .. automethod:: slash_commands + :async-for: + + .. automethod:: user_commands + :async-for: + .. attributetable:: ThreadMember .. autoclass:: ThreadMember() @@ -4520,6 +4568,10 @@ Message .. autoclass:: Message() :members: :inherited-members: + :exclude-members: message_commands + + .. automethod:: message_commands + :async-for: .. attributetable:: PartialMessage @@ -4604,11 +4656,6 @@ Component ApplicationCommand ~~~~~~~~~~~~~~~~~~ -.. attributetable:: BaseCommand - -.. autoclass:: BaseCommand() - :members: - .. attributetable:: UserCommand .. autoclass:: UserCommand() diff --git a/docs/ext/commands/api.rst b/docs/ext/commands/api.rst index bb69202c0..0f1d9edf0 100644 --- a/docs/ext/commands/api.rst +++ b/docs/ext/commands/api.rst @@ -338,11 +338,20 @@ Context .. autoclass:: discord.ext.commands.Context :members: :inherited-members: - :exclude-members: typing + :exclude-members: typing, slash_commands, user_commands, message_commands - .. automethod:: discord.ext.commands.Context.typing + .. automethod:: typing :async-with: + .. automethod:: slash_commands + :async-for: + + .. automethod:: user_commands + :async-for: + + .. automethod:: message_commands + :async-for: + .. _ext_commands_api_converters: Converters