From c407aa1506df2be84a37bab5e7c4dbd0d9277680 Mon Sep 17 00:00:00 2001 From: Lunarmagpie <65521138+Lunarmagpie@users.noreply.github.com> Date: Thu, 6 Jan 2022 21:35:24 -0500 Subject: [PATCH 1/6] :sparkles: Command groups (#342) * :sparkles: register slash command groups Signed-off-by: Lunarmagpie * :sparkles: i think it works Signed-off-by: Lunarmagpie * :fire: remove code that probably didn't do anything * :memo: added amazing docs * :memo: added docs for Group and SubGroup * :memo: add command groups to the interaction guide * :skull: fix typo * :speech_balloon: fix spelling * :bug: fix doc building issue * :recycle: Apply suggestions from code review Co-authored-by: trag1c <77130613+trag1c@users.noreply.github.com> * :art: requsted changes * :art: changed SubGroup name to Subgroup * :art: Update pincer/commands/groups.py Co-authored-by: Yohann Boniface * :art: Update pincer/commands/groups.py Co-authored-by: Yohann Boniface * :art: Update pincer/middleware/interaction_create.py * :art: switched to recursion for finding command options and made hash functions protected * :recycle: improve interaction name resolution * :art: suggesession from trag1c * :zap: improved built register to not care about ClientCommandStructure * :art: Update pincer/commands/commands.py Co-authored-by: Yohann Boniface * :art: added missing return type __get_local_registered_commands * :art: fix get_local_registered_commands typehint * :art: Apply suggestions from code review Co-authored-by: Endercheif <45527309+Endercheif@users.noreply.github.com> * :art: Update pincer/commands/groups.py Co-authored-by: Endercheif <45527309+Endercheif@users.noreply.github.com> Co-authored-by: trag1c <77130613+trag1c@users.noreply.github.com> Co-authored-by: Yohann Boniface Co-authored-by: Endercheif <45527309+Endercheif@users.noreply.github.com> --- docs/api/commands.rst | 9 + docs/api/core.rst | 1 + docs/api/pincer.rst | 2 + docs/interactions.rst | 65 ++++++ pincer/commands/__init__.py | 14 +- pincer/commands/commands.py | 276 ++++++++++++++++++++---- pincer/commands/groups.py | 77 +++++++ pincer/core/http.py | 14 +- pincer/middleware/interaction_create.py | 59 +++-- pincer/objects/app/command.py | 6 + pincer/utils/types.py | 4 +- 11 files changed, 456 insertions(+), 71 deletions(-) create mode 100644 pincer/commands/groups.py diff --git a/docs/api/commands.rst b/docs/api/commands.rst index 98a62fb9..181d47be 100644 --- a/docs/api/commands.rst +++ b/docs/api/commands.rst @@ -42,9 +42,11 @@ Message Components .. autoclass:: ActionRow() .. autoclass:: Button() + :inherited-members: .. autoclass:: LinkButton() .. autoclass:: ButtonStyle() .. autoclass:: SelectMenu() + :inherited-members: .. autoclass:: SelectOption() .. currentmodule:: pincer.commands.components._component @@ -58,3 +60,10 @@ Message Components :decorator: .. autofunction:: component :decorator: + +Command Groups +~~~~~~~~~~~~~~ +.. currentmodule:: pincer.commands.groups + +.. autoclass:: Group() +.. autoclass:: Subgroup() \ No newline at end of file diff --git a/docs/api/core.rst b/docs/api/core.rst index a8f20396..2afcd6dc 100644 --- a/docs/api/core.rst +++ b/docs/api/core.rst @@ -33,6 +33,7 @@ GatewayInfo .. autoclass:: GatewayInfo() + Http ---- diff --git a/docs/api/pincer.rst b/docs/api/pincer.rst index b0c151df..97d51519 100644 --- a/docs/api/pincer.rst +++ b/docs/api/pincer.rst @@ -20,6 +20,8 @@ Client Exceptions ---------- +.. currentmodule:: pincer.exceptions + .. autoexception:: PincerError() .. autoexception:: InvalidPayload() diff --git a/docs/interactions.rst b/docs/interactions.rst index b3bf2444..7f25b53a 100644 --- a/docs/interactions.rst +++ b/docs/interactions.rst @@ -410,3 +410,68 @@ You can also dynamically set the selectable options. @select_menu async def select_menu(values: List[str]): return f"{values[0]} selected" + + +Subcommands and Subcommand Groups +--------------------------------- +To nest commands, Pincer organizes them into :class:`~pincer.commands.groups.Group` and +:class:`~pincer.commands.groups.Subgroup` objects. Group and Subgroup names must only consist of +lowercase letters and underscores. + + +This chart shows the organization of nested commands: + +.. code-block:: + + If you use a group: + + group name + command name + + If you use a group and sub group: + + group name + subgroup name + command name + + Organizing commands like this is also valid: + + group name + subgroup name + command name + command name + +:class:`~pincer.commands.groups.Group` and :class:`~pincer.commands.groups.Subgroup` are set to the parent in a @command +decorator to nest a command inside of them. They are not available for User Commands and Message Commands. + +.. code-block:: python + + from pincer.commands import Group, Subgroup + ... + + class Bot(Client): + + command_group = Group("command_group") + command_sub_group = Subgroup("command_sub_group", parent=a_command_group) + + @command(parent=command_group) + def command_group_command(): + pass + + @command(parent=command_sub_group) + def command_sub_group_command(): + pass + + # Creating these commands is valid because there is no top-level command or group + # with the same name. + @command + def command_group_command(): + pass + @command + def command_sub_group(): + pass + + # This command is not valid because there is a group with the same name. + @command + def a_command_group(): + pass diff --git a/pincer/commands/__init__.py b/pincer/commands/__init__.py index 6dee01e7..55403055 100644 --- a/pincer/commands/__init__.py +++ b/pincer/commands/__init__.py @@ -1,14 +1,7 @@ # Copyright Pincer 2021-Present # Full MIT License can be found in `LICENSE` at the project root. -from .commands import ( - command, - user_command, - message_command, - ChatCommandHandler, - hash_app_command, - hash_app_command_params, -) +from .commands import command, user_command, message_command, ChatCommandHandler from .arg_types import ( CommandArg, Description, @@ -23,12 +16,13 @@ ActionRow, Button, ButtonStyle, ComponentHandler, SelectMenu, SelectOption, component, button, select_menu, LinkButton ) +from .groups import Group, Subgroup __all__ = ( "ActionRow", "Button", "ButtonStyle", "ChannelTypes", "ChatCommandHandler", "Choice", "Choices", "CommandArg", "ComponentHandler", "Description", "LinkButton", "MaxValue", "MinValue", "Modifier", "SelectMenu", "SelectOption", "button", "command", "component", - "hash_app_command", "hash_app_command_params", "message_command", - "select_menu", "user_command" + "message_command", + "select_menu", "user_command", "Group", "Subgroup" ) diff --git a/pincer/commands/commands.py b/pincer/commands/commands.py index 9dbbac90..739fc38d 100644 --- a/pincer/commands/commands.py +++ b/pincer/commands/commands.py @@ -8,7 +8,8 @@ from asyncio import iscoroutinefunction, gather from functools import partial from inspect import Signature, isasyncgenfunction, _empty -from typing import TYPE_CHECKING, Union, List +from typing import TYPE_CHECKING, Union, List, ValuesView + from . import __package__ from ..commands.arg_types import ( @@ -19,6 +20,7 @@ MaxValue, MinValue, ) +from ..commands.groups import Group, Subgroup from ..utils.snowflake import Snowflake from ..exceptions import ( CommandIsNotCoroutine, @@ -83,8 +85,9 @@ def command( enable_default: Optional[bool] = True, guild: Union[Snowflake, int, str] = None, cooldown: Optional[int] = 0, - cooldown_scale: Optional[float] = 60, + cooldown_scale: Optional[float] = 60.0, cooldown_scope: Optional[ThrottleScope] = ThrottleScope.USER, + parent: Optional[Union[Group, Subgroup]] = None ): """A decorator to create a slash command to register and respond to with the discord API from a function. @@ -127,9 +130,9 @@ async def test_command( References from above: - :class:`~client.Client`, - :class:`~objects.message.message.Message`, - :class:`~objects.message.context.MessageContext`, + :class:`~pincer.client.Client`, + :class:`~pincer.objects.message.message.Message`, + :class:`~pincer.objects.message.context.MessageContext`, :class:`~pincer.objects.app.interaction_flags.InteractionFlags`, :class:`~pincer.commands.arg_types.Choices`, :class:`~pincer.commands.arg_types.Choice`, @@ -187,6 +190,7 @@ async def test_command( cooldown=cooldown, cooldown_scale=cooldown_scale, cooldown_scope=cooldown_scope, + parent=parent ) cmd = name or func.__name__ @@ -319,6 +323,7 @@ async def test_command( cooldown_scale=cooldown_scale, cooldown_scope=cooldown_scope, command_options=options, + parent=parent ) @@ -472,7 +477,7 @@ async def test_message_command( Annotation amount is max 25, Not a valid argument type, Annotations must consist of name and value - """ + """ # noqa: E501 return register_command( func=func, app_command_type=AppCommandType.MESSAGE, @@ -494,9 +499,10 @@ def register_command( enable_default: Optional[bool] = True, guild: Optional[Union[Snowflake, int, str]] = None, cooldown: Optional[int] = 0, - cooldown_scale: Optional[float] = 60, + cooldown_scale: Optional[float] = 60.0, cooldown_scope: Optional[ThrottleScope] = ThrottleScope.USER, command_options=MISSING, # Missing typehint? + parent: Optional[Union[Group, Subgroup]] = MISSING ): if func is None: return partial( @@ -509,6 +515,7 @@ def register_command( cooldown=cooldown, cooldown_scale=cooldown_scale, cooldown_scope=cooldown_scope, + parent=parent ) cmd = name or func.__name__ @@ -540,8 +547,17 @@ def register_command( "the 100 character limit." ) + group = MISSING + sub_group = MISSING + + if isinstance(parent, Group): + group = parent + if isinstance(parent, Subgroup): + group = parent.parent + sub_group = parent + if reg := ChatCommandHandler.register.get( - hash_app_command_params(cmd, guild, app_command_type) + _hash_app_command_params(cmd, guild, app_command_type, group, sub_group) ): raise CommandAlreadyRegistered( f"Command `{cmd}` (`{func.__name__}`) has already been " @@ -549,19 +565,21 @@ def register_command( ) ChatCommandHandler.register[ - hash_app_command_params(cmd, guild_id, app_command_type) + _hash_app_command_params(cmd, guild_id, app_command_type, group, sub_group) ] = ClientCommandStructure( call=func, cooldown=cooldown, cooldown_scale=cooldown_scale, cooldown_scope=cooldown_scope, + group=group, + sub_group=sub_group, app=AppCommand( name=cmd, description=description, type=app_command_type, default_permission=enable_default, options=command_options, - guild_id=guild_id, + guild_id=guild_id ), ) @@ -570,7 +588,25 @@ def register_command( class ChatCommandHandler(metaclass=Singleton): - """Metaclass containing methods used to handle various commands + """Singleton containing methods used to handle various commands + + The register and built_register + ------------------------------- + I found the way Discord expects commands to be registered to be very different than + how you want to think about command registration. i.e. Discord wants nesting but we + don't want any nesting. Nesting makes it hard to think about commands and also will + increase lookup time. + The way this problem is avoided is by storing a version of the commands that we can + deal with as library developers and a version of the command that Discord thinks we + should provide. That is where the register and the built_register help simplify the + design of the library. + The register is simply where the "Pincer version" of commands gets saved to memory. + The built_register is where the version of commands that Discord requires is saved. + The register allows for O(1) lookups by storing commands in a Python dictionary. It + does cost some memory to save two copies in the current iteration of the system but + we should be able to drop the built_register in runtime if we want to. I don't feel + that lost maintainability from this is optimal. We can index by in O(1) by checking + the register but can still use the built_register if we need to do a nested lookup. Attributes ---------- @@ -578,13 +614,17 @@ class ChatCommandHandler(metaclass=Singleton): The client object managers: Dict[:class:`str`, :class:`~typing.Any`] Dictionary of managers - register: Dict[:class:`str`, :class:`~objects.app.command.ClientCommandStructure`] + register: Dict[:class:`str`, :class:`~pincer.objects.app.command.ClientCommandStructure`] Dictionary of ``ClientCommandStructure`` - """ + built_register: Dict[:class:`str`, :class:`~pincer.objects.app.command.ClientCommandStructure`] + Dictionary of ``ClientCommandStructure`` where the commands are converted to + the format that Discord expects for sub commands and sub command groups. + """ # noqa: E501 has_been_initialized = False managers: Dict[str, Any] = {} register: Dict[str, ClientCommandStructure] = {} + built_register: Dict[str, AppCommand] = {} # Endpoints: __get = "/commands" @@ -613,7 +653,7 @@ def __init__(self, client: Client): async def get_commands(self) -> List[AppCommand]: """|coro| - Get a list of app commands + Get a list of app commands from Discord Returns ------- @@ -666,8 +706,6 @@ async def remove_command(self, cmd: AppCommand): self.__prefix + remove_endpoint.format(command=cmd) ) - ChatCommandHandler.register.pop(hash_app_command(cmd), None) - async def add_command(self, cmd: AppCommand): """|coro| @@ -685,14 +723,10 @@ async def add_command(self, cmd: AppCommand): if cmd.guild_id: add_endpoint = self.__add_guild.format(command=cmd) - res = await self.client.http.post( + await self.client.http.post( self.__prefix + add_endpoint, data=cmd.to_dict() ) - ChatCommandHandler.register[hash_app_command(cmd)].app.id = Snowflake( - res["id"] - ) - async def add_commands(self, commands: List[AppCommand]): """|coro| @@ -705,6 +739,130 @@ async def add_commands(self, commands: List[AppCommand]): """ await gather(*map(lambda cmd: self.add_command(cmd), commands)) + def __build_local_commands(self): + """Builds the commands into the format that Discord expects. See class info + for the reasoning. + """ + for cmd in ChatCommandHandler.register.values(): + + if cmd.sub_group: + # If a command has a sub_group, it must be nested to levels deep. + # + # command + # subcommand-group + # subcommand + # + # The children of the subcommand-group object are being set to include + # `cmd` If that subcommand-group object does not exist, it will be + # created here. The same goes for the top-level command. + # + # First make sure the command exists. This command will hold the + # subcommand-group for `cmd`. + + # `key` represents the hash value for the top-level command that will + # hold the subcommand. + key = _hash_app_command_params( + cmd.group.name, + cmd.app.guild_id, + AppCommandType.CHAT_INPUT, + None, + None, + ) + + if key not in ChatCommandHandler.built_register: + ChatCommandHandler.built_register[key] = AppCommand( + name=cmd.group.name, + description=cmd.group.description, + type=AppCommandType.CHAT_INPUT, + guild_id=cmd.app.guild_id, + options=[] + ) + + # The top-level command now exists. A subcommand group now if placed + # inside the top-level command. This subcommand group will hold `cmd`. + + children = ChatCommandHandler.built_register[key].options + + sub_command_group = AppCommandOption( + name=cmd.sub_group.name, + description=cmd.sub_group.description, + type=AppCommandOptionType.SUB_COMMAND_GROUP, + options=[] + ) + + # This for-else makes sure that sub_command_group will hold a reference + # to the subcommand group that we want to modify to hold `cmd` + + for cmd_in_children in children: + if ( + cmd_in_children.name == sub_command_group.name + and cmd_in_children.description == sub_command_group.description + and cmd_in_children.type == sub_command_group.type + ): + sub_command_group = cmd_in_children + break + else: + children.append(sub_command_group) + + sub_command_group.options.append(AppCommandOption( + name=cmd.app.name, + description=cmd.app.description, + type=AppCommandOptionType.SUB_COMMAND, + options=cmd.app.options, + )) + + continue + + if cmd.group: + # Any command at this point will only have one level of nesting. + # + # Command + # subcommand + # + # A subcommand object is what is being generated here. If there is no + # top level command, it will be created here. + + # `key` represents the hash value for the top-level command that will + # hold the subcommand. + + key = _hash_app_command_params( + cmd.group.name, + cmd.app.guild_id, + AppCommandOptionType.SUB_COMMAND, + None, + None + ) + + if key not in ChatCommandHandler.built_register: + ChatCommandHandler.built_register[key] = AppCommand( + name=cmd.group.name, + description=cmd.group.description, + type=AppCommandOptionType.SUB_COMMAND, + guild_id=cmd.app.guild_id, + options=[] + ) + + # No checking has to be done before appending `cmd` since it is the + # lowest level. + ChatCommandHandler.built_register[key].options.append( + AppCommandOption( + name=cmd.app.name, + description=cmd.app.description, + type=AppCommandType.CHAT_INPUT, + options=cmd.app.options + ) + ) + + continue + + # All single-level commands are registered here. + ChatCommandHandler.built_register[ + _hash_app_command(cmd.app, cmd.group, cmd.sub_group) + ] = cmd.app + + def get_local_registered_commands(self) -> ValuesView[AppCommand]: + return ChatCommandHandler.built_register.values() + async def __get_existing_commands(self): """|coro| @@ -717,21 +875,13 @@ async def __get_existing_commands(self): logging.error("Cannot retrieve slash commands, skipping...") return - for api_cmd in self._api_commands: - cmd = ChatCommandHandler.register.get(hash_app_command(api_cmd)) - if cmd and cmd.app == api_cmd: - cmd.app = api_cmd - async def __remove_unused_commands(self): """|coro| Remove commands that are registered by discord but not in use by the current client """ - local_registered_commands = [ - registered_cmd.app for registered_cmd - in ChatCommandHandler.register.values() - ] + local_registered_commands = self.get_local_registered_commands() def should_be_removed(target: AppCommand) -> bool: for reg_cmd in local_registered_commands: @@ -746,7 +896,7 @@ def should_be_removed(target: AppCommand) -> bool: return True # NOTE: Cannot be generator since it can't be consumed due to lines 743-745 - to_remove = list(filter(should_be_removed, self._api_commands)) + to_remove = [*filter(should_be_removed, self._api_commands)] await gather( *map( @@ -774,10 +924,7 @@ async def __add_commands(self): Therefore, we don't need to use a separate loop for updating and adding commands. """ - local_registered_commands = [ - registered_cmd.app for registered_cmd - in ChatCommandHandler.register.values() - ] + local_registered_commands = self.get_local_registered_commands() def should_be_updated_or_uploaded(target): for command in self._api_commands: @@ -802,16 +949,65 @@ async def initialize(self): return ChatCommandHandler.has_been_initialized = True + + self.__build_local_commands() await self.__get_existing_commands() await self.__remove_unused_commands() await self.__add_commands() -def hash_app_command(command: AppCommand) -> int: - return hash_app_command_params(command.name, command.guild_id, command.type) +def _hash_app_command( + command: AppCommand, + group: Optional[str], + sub_group: Optional[str] +) -> int: + """ + See :func:`~pincer.commands.commands._hash_app_command_params` for information. + """ + return _hash_app_command_params( + command.name, + command.guild_id, + command.type, + group, + sub_group + ) -def hash_app_command_params( - name: str, guild_id: Snowflake, app_command_type: AppCommandType +def _hash_app_command_params( + name: str, + guild_id: Union[Snowflake, None, MISSING], + app_command_type: AppCommandType, + group: Optional[str], + sub_group: Optional[str] ) -> int: - return hash((name, guild_id, app_command_type)) + """ + The group layout in Pincer is very different than what discord has on their docs. + You can think of the Pincer group layout like this: + + name: The name of the function that is being called. + + group: The :class:`~pincer.commands.groups.Group` object that this function is + using. + sub_option: The :class:`~pincer.commands.groups.Subgroup` object that this + functions is using. + + Abstracting away this part of the Discord API allows for a much cleaner + transformation between what users want to input and what commands Discord + expects. + + Parameters + ---------- + name : str + The name of the function for the command + guild_id : Union[:class:`~pincer.utils.snowflake.Snowflake`, None, MISSING] + The ID of a guild, None, or MISSING. + app_command_type : :class:`~pincer.objects.app.command_types.AppCommandType` + The app command type of the command. NOT THE OPTION TYPE. + group : str + The highest level of organization the command is it. This should always be the + name of the base command. :data:`None` or :data:`MISSING` if not there. + sub_option : str + The name of the group that holds the lowest level of options. :data:`None` or + :data:`MISSING` if not there. + """ + return hash((name, guild_id, app_command_type, group, sub_group)) diff --git a/pincer/commands/groups.py b/pincer/commands/groups.py new file mode 100644 index 00000000..a0e6d3dc --- /dev/null +++ b/pincer/commands/groups.py @@ -0,0 +1,77 @@ +# Copyright Pincer 2021-Present +# Full MIT License can be found in `LICENSE` at the project root. + +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class Group: + """ + The group object represents a group that commands can be in. This is always a top + level command. + + .. code-block:: python + + class Bot: + + group = Group("cool_commands") + + @command(parent=group) + async def a_very_cool_command(): + pass + + This code creates a command called ``cool_commands`` with the subcommand + ``a_very_cool_command`` + + Parameters + ---------- + name : str + The name of the command group. + description : Optional[:class:`str`] + The description of the command. This has to be sent to Discord but it does + nothing so it is optional. + """ + name: str + description: Optional[str] = None + + def __hash__(self) -> int: + return hash(self.name) + + +@dataclass +class Subgroup: + """ + A subgroup of commands. This allows you to create subcommands inside of a + subcommand-group. + + .. code-block:: python + + class Bot: + + group = Group("cool_commands") + sub_group = Subgroup("group_of_cool_commands") + + @command(parent=sub_group) + async def a_very_cool_command(): + pass + + This code creates a command called ``cool_commands`` with the subcommand-group + ``group_of_cool_commands`` that has the subcommand ``a_very_cool_command``. + + Parameters + ---------- + name : str + The name of the command sub-group. + parent : :class:`~pincer.commands.groups.Group` + The parent group of this command. + description : Optional[:class:`str`] + The description of the command. This has to be sent to Discord but it does + nothing so it is optional. + """ + name: str + parent: Group + description: Optional[str] = None + + def __hash__(self) -> int: + return hash(self.name) diff --git a/pincer/core/http.py b/pincer/core/http.py index c8c831cb..63c3bc47 100644 --- a/pincer/core/http.py +++ b/pincer/core/http.py @@ -115,8 +115,8 @@ async def __send( content_type: str = "application/json", data: Optional[Union[Dict, str, Payload]] = None, headers: Optional[Dict[str, Any]] = None, + _ttl: Optional[int] = None, params: Optional[Dict] = None, - __ttl: int = None ) -> Optional[Dict]: """ Send an api request to the Discord REST API. @@ -145,12 +145,12 @@ async def __send( The query parameters to add to the request. |default| :data:`None` - __ttl: Optional[:class:`int`] + _ttl: Optional[:class:`int`] Private param used for recursively setting the retry amount. (Eg set to 1 for 1 max retry) |default| :data:`None` """ - ttl = __ttl or self.max_ttl + ttl = _ttl or self.max_ttl if ttl == 0: logging.error( @@ -194,7 +194,7 @@ async def __handle_response( endpoint: str, content_type: str, data: Optional[str], - __ttl: int, + _ttl: int, ) -> Optional[Dict]: """ Handle responses from the discord API. @@ -220,7 +220,7 @@ async def __handle_response( data: Optional[:class:`str`] The data which was added to the request. - __ttl: :class:`int` + _ttl: :class:`int` Private param used for recursively setting the retry amount. (Eg set to 1 for 1 max retry) """ @@ -272,7 +272,7 @@ async def __handle_response( raise exception # status code is guaranteed to be 5xx - retry_in = 1 + (self.max_ttl - __ttl) * 2 + retry_in = 1 + (self.max_ttl - _ttl) * 2 _log.debug( "Server side error occurred with status code " @@ -286,7 +286,7 @@ async def __handle_response( method, endpoint, content_type=content_type, - __ttl=__ttl - 1, + _ttl=_ttl - 1, data=data ) diff --git a/pincer/middleware/interaction_create.py b/pincer/middleware/interaction_create.py index dcef2183..9b68be09 100644 --- a/pincer/middleware/interaction_create.py +++ b/pincer/middleware/interaction_create.py @@ -6,10 +6,10 @@ import logging from contextlib import suppress from inspect import isasyncgenfunction, _empty -from typing import Dict, Any from typing import TYPE_CHECKING -from ..commands import ChatCommandHandler, ComponentHandler, hash_app_command_params +from ..commands import ChatCommandHandler, ComponentHandler +from ..commands.commands import _hash_app_command_params from ..exceptions import InteractionDoesNotExist from ..objects import Interaction, MessageContext, AppCommandType, InteractionType from ..utils import MISSING, should_pass_cls, Coro, should_pass_ctx @@ -18,7 +18,7 @@ from ..utils.signature import get_signature_and_params if TYPE_CHECKING: - from typing import List, Tuple + from typing import Any, Dict, List, Tuple from ..client import Client from ..core.gateway import Gateway from ..core.gateway import GatewayDispatch @@ -28,7 +28,9 @@ def get_command_from_registry(interaction: Interaction): """ - Search for a command in ChatCommandHandler.register and return it if it exists + Search for a command in ChatCommandHandler.register and return it if it exists. + The naming of commands is converted from the Discord version to the Pincer version + before checking the cache. See ChatCommandHandler docs for more information. Parameters --------- @@ -41,23 +43,43 @@ def get_command_from_registry(interaction: Interaction): The command is not registered """ + name: str = interaction.data.name + group = None + sub_group = None + + options = interaction.data.options + + if interaction.data.options: + option = options[0] + if option.type == 1: + group = name + name = option.name + elif option.type == 2: + group = interaction.data.name + sub_group = option.name + name = option.options[0].name + with suppress(KeyError): - return ChatCommandHandler.register[hash_app_command_params( - interaction.data.name, + return ChatCommandHandler.register[_hash_app_command_params( + name, MISSING, - interaction.data.type + interaction.data.type, + group, + sub_group )] with suppress(KeyError): - return ChatCommandHandler.register[hash_app_command_params( - interaction.data.name, + return ChatCommandHandler.register[_hash_app_command_params( + name, interaction.guild_id, - interaction.data.type + interaction.data.type, + group, + sub_group )] raise InteractionDoesNotExist( f"No command is registered for {interaction.data.name} with type" - f"{interaction.data.type}" + f" {interaction.data.type}" ) @@ -145,8 +167,19 @@ async def interaction_handler( } params = {} - if interaction.data.options is not MISSING: - params = {opt.name: opt.value for opt in interaction.data.options} + def get_options_from_command(options): + if not options: + return options + if options[0].type == 1: + return options[0].options + if options[0].type == 2: + return get_options_from_command(options[0].options) + return options + + options = get_options_from_command(interaction.data.options) + + if options is not MISSING: + params = {opt.name: opt.value for opt in options} args = [] diff --git a/pincer/objects/app/command.py b/pincer/objects/app/command.py index 088d383f..c34128e8 100644 --- a/pincer/objects/app/command.py +++ b/pincer/objects/app/command.py @@ -6,6 +6,9 @@ from dataclasses import dataclass from typing import List, Union, TYPE_CHECKING + +from pincer.commands.groups import Group, Subgroup + from .command_types import AppCommandOptionType, AppCommandType from ...objects.guild.channel import ChannelType from ...utils.api_object import APIObject, GuildProperty @@ -204,3 +207,6 @@ class ClientCommandStructure: cooldown: int cooldown_scale: float cooldown_scope: ThrottleScope + + group: APINullable[Group] = MISSING + sub_group: APINullable[Subgroup] = MISSING diff --git a/pincer/utils/types.py b/pincer/utils/types.py index 96f36bb9..113111fe 100644 --- a/pincer/utils/types.py +++ b/pincer/utils/types.py @@ -19,7 +19,9 @@ def __eq__(self, __o: object) -> bool: return __o is MISSING def __hash__(self) -> int: - return 0 + # By returning the hash of None, when searching for a command it doesn't know + # what kind of "nothing" it is. + return hash(None) MISSING = MissingType() From 425a616e4ef873722089fb7a51c2abdc0ef88158 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Fri, 7 Jan 2022 02:35:40 +0000 Subject: [PATCH 2/6] :art: Automatic sorting --- pincer/commands/__init__.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/pincer/commands/__init__.py b/pincer/commands/__init__.py index 55403055..a8ea6d78 100644 --- a/pincer/commands/__init__.py +++ b/pincer/commands/__init__.py @@ -21,8 +21,7 @@ __all__ = ( "ActionRow", "Button", "ButtonStyle", "ChannelTypes", "ChatCommandHandler", "Choice", "Choices", "CommandArg", - "ComponentHandler", "Description", "LinkButton", "MaxValue", "MinValue", - "Modifier", "SelectMenu", "SelectOption", "button", "command", "component", - "message_command", - "select_menu", "user_command", "Group", "Subgroup" + "ComponentHandler", "Description", "Group", "LinkButton", "MaxValue", + "MinValue", "Modifier", "SelectMenu", "SelectOption", "Subgroup", "button", + "command", "component", "message_command", "select_menu", "user_command" ) From 9ba47f32440fcf62e8564938d59387c49c97cb63 Mon Sep 17 00:00:00 2001 From: Lunarmagpie <65521138+Lunarmagpie@users.noreply.github.com> Date: Fri, 7 Jan 2022 15:57:09 -0500 Subject: [PATCH 3/6] :bug: Make sort-alls work on all branches (#355) * Make sort-alls work on all branches * Update sort_alls.yaml * :test_tube: messing up stuff as a test * :art: Automatic sorting Co-authored-by: GitHub Actions --- .github/workflows/sort_alls.yaml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/sort_alls.yaml b/.github/workflows/sort_alls.yaml index fb3597ef..404edaae 100644 --- a/.github/workflows/sort_alls.yaml +++ b/.github/workflows/sort_alls.yaml @@ -1,9 +1,6 @@ name: Sort Alls -on: - push: - branches: - - main +on: push jobs: build: @@ -32,4 +29,4 @@ jobs: git pull git add . git diff-index --quiet HEAD || git commit -m ":art: Automatic `__all__` sorting" - git push origin main + git push From 515d4a8e6b7db5c9c0919a64f04d10e0415935ae Mon Sep 17 00:00:00 2001 From: Lunarmagpie <65521138+Lunarmagpie@users.noreply.github.com> Date: Sat, 8 Jan 2022 14:28:14 -0500 Subject: [PATCH 4/6] :bug: gateway no longer starts multiple instances (#354) * :bug: gateway no longer starts multiple instances * :bug: gateway now relogs correctly after invalid session * :art: small code cleanup * :recycle: shorten logging message * :bug: close socket on invalid session * :art: add bool to GatewayDispatch typehint --- pincer/core/dispatch.py | 4 ++-- pincer/core/gateway.py | 36 ++++++++++++++++++++---------------- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/pincer/core/dispatch.py b/pincer/core/dispatch.py index 3183a2eb..c1fe1bce 100644 --- a/pincer/core/dispatch.py +++ b/pincer/core/dispatch.py @@ -30,12 +30,12 @@ class GatewayDispatch: def __init__( self, op: int, - data: Optional[Union[int, Dict[str, Any]]] = None, + data: Optional[Union[int, bool, Dict[str, Any]]] = None, seq: Optional[int] = None, name: Optional[str] = None ): self.op: int = op - self.data: Optional[Union[int, Dict[str, Any]]] = data + self.data: Optional[Union[int, bool, Dict[str, Any]]] = data self.seq: Optional[int] = seq self.event_name: Optional[str] = name diff --git a/pincer/core/gateway.py b/pincer/core/gateway.py index 0addb7e8..7a86f6fd 100644 --- a/pincer/core/gateway.py +++ b/pincer/core/gateway.py @@ -110,7 +110,8 @@ def __init__( } # 4000 and 4009 are not included. The client will reconnect when receiving - # either. + # either. Code 4000 is also used for internal disconnects that will lead to a + # reconnect. self.__close_codes: Dict[int, GatewayError] = { 4001: GatewayError("Invalid opcode was sent"), 4002: GatewayError("Invalid payload was sent."), @@ -140,7 +141,7 @@ def __init__( # The gateway can be disconnected from Discord. This variable stores if the # gateway should send a hello or reconnect. - self.__should_reconnect: bool = False + self.__should_resume: bool = False # The sequence number for the last received payload. This is used reconnecting. self.__sequence_number: int = 0 @@ -258,8 +259,8 @@ async def event_loop(self): "%s Disconnected from Gateway due without any errors. Reconnecting.", self.shard_key ) - self.__should_reconnect = True - await self.start_loop() + # ensure_future prevents a stack overflow + ensure_future(self.start_loop()) async def handle_data(self, data: Dict[Any]): """|coro| @@ -310,9 +311,8 @@ async def handle_reconnect(self, payload: GatewayDispatch): self.shard_key ) + self.__should_resume = True await self.__socket.close() - self.__should_reconnect = True - await self.start_loop() async def handle_invalid_session(self, payload: GatewayDispatch): """|coro| @@ -320,9 +320,16 @@ async def handle_invalid_session(self, payload: GatewayDispatch): Attempt to relog. This is probably because the session was already invalidated when we tried to reconnect. """ - _log.debug("%s Invalid session, attempting to relog...", self.shard_key) - self.__should_reconnect = False - await self.start_loop() + + _log.debug( + "%s Invalid session, attempting to %s...", + self.shard_key, + "reconnect" if payload.data else "relog" + ) + + self.__should_resume = payload.data + self.stop_heartbeat() + await self.__socket.close() async def identify_and_handle_hello(self, payload: GatewayDispatch): """|coro| @@ -334,7 +341,7 @@ async def identify_and_handle_hello(self, payload: GatewayDispatch): Successful reconnects are handled in the `resumed` middleware. """ - if self.__should_reconnect: + if self.__should_resume: _log.debug("%s Resuming connection with Discord", self.shard_key) await self.send(str(GatewayDispatch( @@ -363,8 +370,6 @@ async def identify_and_handle_hello(self, payload: GatewayDispatch): )) self.__heartbeat_interval = payload.data["heartbeat_interval"] - # This process should already be forked to the background so there is no need to - # `ensure_future()` here. self.start_heartbeat() async def handle_heartbeat(self, payload: GatewayDispatch): @@ -452,14 +457,13 @@ async def __heartbeat_loop(self): # Close code is specified to be anything that is not 1000 in the docs. _log.debug( "%s %s ack not received. Attempting to reconnect." - " Closing socket with close code 1001. \U0001f480", + " Closing socket with close code 4000. \U0001f480", datetime.now(), self.shard_key ) - await self.__socket.close(code=1001) - self.__should_reconnect = True + await self.__socket.close(code=4000) + self.__should_resume = True # A new loop is started in the background while this one is stopped. - ensure_future(self.start_loop()) self.stop_heartbeat() return From 894c7d08112b47c968abf0ba6734d87c8d177e15 Mon Sep 17 00:00:00 2001 From: Yohann Boniface Date: Sat, 8 Jan 2022 20:39:33 +0100 Subject: [PATCH 5/6] =?UTF-8?q?=F0=9F=94=96=200.15.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pincer/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pincer/__init__.py b/pincer/__init__.py index 5955eae4..5220b530 100644 --- a/pincer/__init__.py +++ b/pincer/__init__.py @@ -55,7 +55,7 @@ def __repr__(self) -> str: ) -version_info = VersionInfo(0, 15, 1) +version_info = VersionInfo(0, 15, 2) __version__ = repr(version_info) __all__ = ( From 83e17bd114c00e736e759f844544ec90d2063d51 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Sat, 8 Jan 2022 19:39:57 +0000 Subject: [PATCH 6/6] :hammer: Automatic update of setup.cfg --- VERSION | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 8076af51..a12760eb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.15.1 \ No newline at end of file +0.15.2 \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 3d29b370..d9963064 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = pincer -version = 0.15.1 +version = 0.15.2 description = Discord API wrapper rebuild from scratch. long_description = file: docs/PYPI.md long_description_content_type = text/markdown