From 44eb9a0c92c406c38ac06d46583391c76c611bf6 Mon Sep 17 00:00:00 2001 From: Krittick Date: Sun, 1 May 2022 22:19:10 -0700 Subject: [PATCH 01/17] critical hotfix to enforce typing for component limits --- discord/ui/button.py | 6 +++--- discord/ui/input_text.py | 12 ++++++------ discord/ui/modal.py | 2 +- discord/ui/select.py | 4 ++-- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/discord/ui/button.py b/discord/ui/button.py index ec4cfa589e..6f12a4d7da 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -96,9 +96,9 @@ def __init__( row: Optional[int] = None, ): super().__init__() - if label and len(label) > 80: + if label and len(str(label)) > 80: raise ValueError("label must be 80 characters or fewer") - if custom_id is not None and len(custom_id) > 100: + if custom_id is not None and len(str(custom_id)) > 100: raise ValueError("custom_id must be 100 characters or fewer") if custom_id is not None and url is not None: raise TypeError("cannot mix both url and custom_id with Button") @@ -184,7 +184,7 @@ def label(self) -> Optional[str]: @label.setter def label(self, value: Optional[str]): - if value and len(value) > 80: + if value and len(str(value)) > 80: raise ValueError("label must be 80 characters or fewer") self._underlying.label = str(value) if value is not None else value diff --git a/discord/ui/input_text.py b/discord/ui/input_text.py index 0fe04679da..9cbae77dd3 100644 --- a/discord/ui/input_text.py +++ b/discord/ui/input_text.py @@ -63,15 +63,15 @@ def __init__( row: Optional[int] = None, ): super().__init__() - if len(label) > 45: + if len(str(label)) > 45: raise ValueError("label must be 45 characters or fewer") if min_length and (min_length < 0 or min_length > 4000): raise ValueError("min_length must be between 0 and 4000") if max_length and (max_length < 0 or max_length > 4000): raise ValueError("max_length must be between 1 and 4000") - if value and len(value) > 4000: + if value and len(str(value)) > 4000: raise ValueError("value must be 4000 characters or fewer") - if placeholder and len(placeholder) > 100: + if placeholder and len(str(placeholder)) > 100: raise ValueError("placeholder must be 100 characters or fewer") if not isinstance(custom_id, str) and custom_id is not None: raise TypeError(f"expected custom_id to be str, not {custom_id.__class__.__name__}") @@ -125,10 +125,10 @@ def label(self) -> str: @label.setter def label(self, value: str): - if len(value) > 45: - raise ValueError("label must be 45 characters or fewer") if not isinstance(value, str): raise TypeError(f"label should be str not {value.__class__.__name__}") + if len(value) > 45: + raise ValueError("label must be 45 characters or fewer") self._underlying.label = value @property @@ -193,7 +193,7 @@ def value(self) -> Optional[str]: def value(self, value: Optional[str]): if value and not isinstance(value, str): raise TypeError(f"value must be None or str not {value.__class__.__name__}") # type: ignore - if value and len(value) > 4000: + if value and len(str(value)) > 4000: raise ValueError("value must be 4000 characters or fewer") self._underlying.value = value diff --git a/discord/ui/modal.py b/discord/ui/modal.py index 4e4f4201f9..0cb7d64cff 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -41,7 +41,7 @@ def __init__(self, title: str, custom_id: Optional[str] = None) -> None: if not isinstance(custom_id, str) and custom_id is not None: raise TypeError(f"expected custom_id to be str, not {custom_id.__class__.__name__}") self._custom_id: Optional[str] = custom_id or os.urandom(16).hex() - if len(title) > 45: + if len(str(title)) > 45: raise ValueError("title must be 45 characters or fewer") self._title = title self.children: List[InputText] = [] diff --git a/discord/ui/select.py b/discord/ui/select.py index 95b100fadd..a861108c38 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -149,10 +149,10 @@ def placeholder(self) -> Optional[str]: @placeholder.setter def placeholder(self, value: Optional[str]): - if value and len(value) > 150: - raise ValueError("placeholder must be 150 characters or fewer") if value is not None and not isinstance(value, str): raise TypeError("placeholder must be None or str") + if value and len(value) > 150: + raise ValueError("placeholder must be 150 characters or fewer") self._underlying.placeholder = value From fe1721830c61ea57b40cc88b916777641b42bc40 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 May 2022 22:18:12 +0000 Subject: [PATCH 02/17] Update pylint requirement from ~=2.13.7 to ~=2.13.8 Updates the requirements on [pylint](https://github.com/PyCQA/pylint) to permit the latest version. - [Release notes](https://github.com/PyCQA/pylint/releases) - [Changelog](https://github.com/PyCQA/pylint/blob/main/ChangeLog) - [Commits](https://github.com/PyCQA/pylint/compare/v2.13.7...v2.13.8) --- updated-dependencies: - dependency-name: pylint dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 350c9dcff4..7a3e85b834 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,5 @@ -r requirements.txt -pylint~=2.13.7 +pylint~=2.13.8 pytest~=7.1.2 pytest-asyncio~=0.18.3 # pytest-order~=1.0.1 From ea6f59dfdd3ea20b879a5ee75fc9fab10948cb3d Mon Sep 17 00:00:00 2001 From: IlluminatiFish <45714340+IlluminatiFish@users.noreply.github.com> Date: Wed, 4 May 2022 18:29:04 +0100 Subject: [PATCH 03/17] Update validation regex for command names & options (#1309) --- discord/commands/core.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/discord/commands/core.py b/discord/commands/core.py index 3dd2185206..dfc3924d99 100644 --- a/discord/commands/core.py +++ b/discord/commands/core.py @@ -1616,7 +1616,7 @@ def command(**kwargs): # Validation def validate_chat_input_name(name: Any, locale: Optional[str] = None): - # Must meet the regex ^[\w-]{1,32}$ + # Must meet the regex ^[-_\w\d\u0901-\u097D\u0E00-\u0E7F]{1,32}$ if locale is not None and locale not in valid_locales: raise ValidationError( f"Locale '{locale}' is not a valid locale, " @@ -1625,9 +1625,9 @@ def validate_chat_input_name(name: Any, locale: Optional[str] = None): error = None if not isinstance(name, str): error = TypeError(f"Command names and options must be of type str. Received \"{name}\"") - elif not re.match(r"^[\w-]{1,32}$", name): + elif not re.match(r"^[-_\w\d\u0901-\u097D\u0E00-\u0E7F]{1,32}$", name): error = ValidationError( - r"Command names and options must follow the regex \"^[\w-]{1,32}$\". For more information, see " + r"Command names and options must follow the regex \"^[-_\w\d\u0901-\u097D\u0E00-\u0E7F]{1,32}$\". For more information, see " f"{docs}/interactions/application-commands#application-command-object-application-command-naming. " f"Received \"{name}\"" ) From 8d5eda05ea4938b32bf9f5f8434ea1e03910bfa8 Mon Sep 17 00:00:00 2001 From: Omkaar <79257339+Pysics@users.noreply.github.com> Date: Thu, 5 May 2022 18:49:38 +0530 Subject: [PATCH 04/17] Update view.py (#1319) --- discord/ui/view.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/discord/ui/view.py b/discord/ui/view.py index 70e7c675dc..220da5102b 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -477,6 +477,19 @@ def disable_all_items(self, *, exclusions: Optional[List[Item]] = None) -> None: for child in self.children: if exclusions is None or child not in exclusions: child.disabled = True + + def enable_all_items(self, *, exclusions: Optional[List[Item]] = None) -> None: + """ + Enables all items in the view. + + Parameters + ----------- + exclusions: Optional[List[:class:`ui.Item`]] + A list of items in `self.children` to not enable from the view. + """ + for child in self.children: + if exclusions is None or child not in exclusions: + child.disabled = False class ViewStore: From fb3065dc95a9c096899ff7e03c65371f1b6aeb02 Mon Sep 17 00:00:00 2001 From: Ken Spencer Date: Thu, 5 May 2022 02:18:58 -0400 Subject: [PATCH 05/17] Update guild.py fetch_members() type hints Actually type hint for None OR int using Union[int, None] --- discord/guild.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/guild.py b/discord/guild.py index 8f20658210..92c40d13e6 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -1731,7 +1731,7 @@ async def active_threads(self) -> List[Thread]: return threads # TODO: Remove Optional typing here when async iterators are refactored - def fetch_members(self, *, limit: int = 1000, after: Optional[SnowflakeTime] = None) -> MemberIterator: + def fetch_members(self, *, limit: Union[int, None] = 1000, after: Optional[SnowflakeTime] = None) -> MemberIterator: """Retrieves an :class:`.AsyncIterator` that enables receiving the guild's members. In order to use this, :meth:`Intents.members` must be enabled. From 04145b0ba13866cd457fe8eca5d7ea0259dbdda3 Mon Sep 17 00:00:00 2001 From: Middledot <78228142+Middledot@users.noreply.github.com> Date: Thu, 5 May 2022 11:36:04 -0400 Subject: [PATCH 06/17] Add is_nsfw to voice channels (#1317) * Add is_nsfw to voice channels * fix typing --- discord/channel.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/discord/channel.py b/discord/channel.py index 67ce857661..776da84709 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -923,7 +923,11 @@ class VoiceChannel(discord.abc.Messageable, VocalGuildChannel): .. versionadded:: 2.0 """ - __slots__ = () + __slots__ = ('nsfw') + + def _update(self, guild: Guild, data: VoiceChannelPayload): + super()._update(guild, data) + self.nsfw: bool = data.get("nsfw", False) def __repr__(self) -> str: attrs = [ @@ -942,6 +946,10 @@ def __repr__(self) -> str: async def _get_channel(self): return self + def is_nsfw(self) -> bool: + """:class:`bool`: Checks if the channel is NSFW.""" + return self.nsfw + @property def last_message(self) -> Optional[Message]: """Fetches the last message from this channel in cache. From 217da1054964e5f052a5ed40a6fba9bb5be6c45b Mon Sep 17 00:00:00 2001 From: Dorukyum <53639936+Dorukyum@users.noreply.github.com> Date: Thu, 5 May 2022 22:38:53 +0300 Subject: [PATCH 07/17] Use Optional[X] instead of Union[X, None] --- discord/guild.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/guild.py b/discord/guild.py index 92c40d13e6..d1cbdb5350 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -1731,7 +1731,7 @@ async def active_threads(self) -> List[Thread]: return threads # TODO: Remove Optional typing here when async iterators are refactored - def fetch_members(self, *, limit: Union[int, None] = 1000, after: Optional[SnowflakeTime] = None) -> MemberIterator: + def fetch_members(self, *, limit: Optional[int] = 1000, after: Optional[SnowflakeTime] = None) -> MemberIterator: """Retrieves an :class:`.AsyncIterator` that enables receiving the guild's members. In order to use this, :meth:`Intents.members` must be enabled. From 6a21ede6ff3277501afcc0affe82a0b4ddf69290 Mon Sep 17 00:00:00 2001 From: Dorukyum <53639936+Dorukyum@users.noreply.github.com> Date: Fri, 6 May 2022 00:21:02 +0300 Subject: [PATCH 08/17] Rewrite options (#1251) --- discord/commands/core.py | 69 ++++++++++++++++++------------------- discord/commands/options.py | 14 +++++--- discord/enums.py | 5 +-- 3 files changed, 46 insertions(+), 42 deletions(-) diff --git a/discord/commands/core.py b/discord/commands/core.py index dfc3924d99..14065bed9a 100644 --- a/discord/commands/core.py +++ b/discord/commands/core.py @@ -655,45 +655,49 @@ def __init__(self, func: Callable, *args, **kwargs) -> None: if self.permissions and self.default_permission: self.default_permission = False - def _parse_options(self, params) -> List[Option]: - if list(params.items())[0][0] == "self": - temp = list(params.items()) - temp.pop(0) - params = dict(temp) + def _check_required_params(self, params): params = iter(params.items()) + required_params = ["self", "context"] if self.attached_to_group or self.cog else ["context"] + for p in required_params: + try: + next(params) + except StopIteration: + raise ClientException(f'Callback for {self.name} command is missing "{p}" parameter.') - # next we have the 'ctx' as the next parameter - try: - next(params) - except StopIteration: - raise ClientException(f'Callback for {self.name} command is missing "ctx" parameter.') + return params + + def _parse_options(self, params, *, check_params: bool = True) -> List[Option]: + if check_params: + params = self._check_required_params(params) final_options = [] for p_name, p_obj in params: - option = p_obj.annotation if option == inspect.Parameter.empty: option = str if self._is_typing_union(option): if self._is_typing_optional(option): - option = Option(option.__args__[0], "No description provided", required=False) + option = Option(option.__args__[0], "No description provided", required=False) # type: ignore # union type else: - option = Option(option.__args__, "No description provided") + option = Option(option.__args__, "No description provided") # type: ignore # union type if not isinstance(option, Option): - option = Option(option, "No description provided") + if isinstance(p_obj.default, Option): # arg: type = Option(...) + p_obj.default.input_type = SlashCommandOptionType.from_datatype(option) + option = p_obj.default + else: # arg: Option(...) = default + option = Option(option, "No description provided") if option.default is None: - if p_obj.default == inspect.Parameter.empty: - option.default = None - else: + if not p_obj.default == inspect.Parameter.empty and not isinstance(p_obj.default, Option): option.default = p_obj.default option.required = False if option.name is None: option.name = p_name - option._parameter_name = p_name + if option.name != p_name or option._parameter_name is None: + option._parameter_name = p_name _validate_names(option) _validate_descriptions(option) @@ -703,25 +707,15 @@ def _parse_options(self, params) -> List[Option]: return final_options def _match_option_param_names(self, params, options): - if list(params.items())[0][0] == "self": - temp = list(params.items()) - temp.pop(0) - params = dict(temp) - params = iter(params.items()) + params = self._check_required_params(params) - # next we have the 'ctx' as the next parameter - try: - next(params) - except StopIteration: - raise ClientException(f'Callback for {self.name} command is missing "ctx" parameter.') - - check_annotations = [ + check_annotations: List[Callable[[Option, Type], bool]] = [ lambda o, a: o.input_type == SlashCommandOptionType.string and o.converter is not None, # pass on converters lambda o, a: isinstance(o.input_type, SlashCommandOptionType), # pass on slash cmd option type enums lambda o, a: isinstance(o._raw_type, tuple) and a == Union[o._raw_type], # type: ignore # union types lambda o, a: self._is_typing_optional(a) and not o.required and o._raw_type in a.__args__, # optional - lambda o, a: inspect.isclass(a) and issubclass(a, o._raw_type), # 'normal' types + lambda o, a: isinstance(a, type) and issubclass(a, o._raw_type), # 'normal' types ] for o in options: _validate_names(o) @@ -732,15 +726,14 @@ def _match_option_param_names(self, params, options): raise ClientException(f"Too many arguments passed to the options kwarg.") p_obj = p_obj.annotation - if not any(c(o, p_obj) for c in check_annotations): + if not any(check(o, p_obj) for check in check_annotations): raise TypeError(f"Parameter {p_name} does not match input type of {o.name}.") o._parameter_name = p_name left_out_params = OrderedDict() - left_out_params[""] = "" # bypass first iter (ctx) for k, v in params: left_out_params[k] = v - options.extend(self._parse_options(left_out_params)) + options.extend(self._parse_options(left_out_params, check_params=False)) return options @@ -752,6 +745,12 @@ def _is_typing_union(self, annotation): def _is_typing_optional(self, annotation): return self._is_typing_union(annotation) and type(None) in annotation.__args__ # type: ignore + def _set_cog(self, cog): + prev = self.cog + super()._set_cog(cog) + if (prev is None and cog is not None) or (prev is not None and cog is None): + self.options = self._parse_options(self._get_signature_parameters()) # parse again to leave out self + @property def is_subcommand(self) -> bool: return self.parent is not None @@ -1162,7 +1161,7 @@ def _update_copy(self, kwargs: Dict[str, Any]): return self.copy() def _set_cog(self, cog): - self.cog = cog + super()._set_cog(cog) for subcommand in self.subcommands: subcommand._set_cog(cog) diff --git a/discord/commands/options.py b/discord/commands/options.py index 1cd0260e57..e60c53c440 100644 --- a/discord/commands/options.py +++ b/discord/commands/options.py @@ -80,12 +80,12 @@ async def hello( ---------- input_type: :class:`Any` The type of input that is expected for this option. - description: :class:`str` - The description of this option. - Must be 100 characters or fewer. name: :class:`str` The name of this option visible in the UI. Inherits from the variable name if not provided as a parameter. + description: Optional[:class:`str`] + The description of this option. + Must be 100 characters or fewer. choices: Optional[List[Union[:class:`Any`, :class:`OptionChoice`]]] The list of available choices for this option. Can be a list of values or :class:`OptionChoice` objects (which represent a name:value pair). @@ -115,10 +115,11 @@ async def hello( See `here `_ for a list of valid locales. """ - def __init__(self, input_type: Any, /, description: str = None, **kwargs) -> None: + def __init__(self, input_type: Any = str, /, description: Optional[str] = None, **kwargs) -> None: self.name: Optional[str] = kwargs.pop("name", None) if self.name is not None: self.name = str(self.name) + self._parameter_name = self.name # default self.description = description or "No description provided" self.converter = None self._raw_type = input_type @@ -140,7 +141,10 @@ def __init__(self, input_type: Any, /, description: str = None, **kwargs) -> Non else: if _type == SlashCommandOptionType.channel: if not isinstance(input_type, tuple): - input_type = (input_type,) + if hasattr(input_type, "__args__"): # Union + input_type = input_type.__args__ + else: + input_type = (input_type,) for i in input_type: if i.__name__ == "GuildChannel": continue diff --git a/discord/enums.py b/discord/enums.py index 4d9805e02e..7a9230de11 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -695,8 +695,9 @@ def from_datatype(cls, datatype): if issubclass(datatype, float): return cls.number - # TODO: Improve the error message - raise TypeError(f"Invalid class {datatype} used as an input type for an Option") + from .commands.context import ApplicationContext + if not issubclass(datatype, ApplicationContext): # TODO: prevent ctx being passed here in cog commands + raise TypeError(f"Invalid class {datatype} used as an input type for an Option") # TODO: Improve the error message class EmbeddedActivity(Enum): From 68752f149e60b02220a1c3fc1de50239bbb3fbfe Mon Sep 17 00:00:00 2001 From: krittick Date: Thu, 5 May 2022 16:26:22 -0700 Subject: [PATCH 09/17] Permissions v2 - Redo for merge conflicts (#1328) * Remove v1 from application commands * Remove permissions v1 from documentation * Add v2 attributes to commands * Fix permissions for groups * Use v2 in as_dict methods * Add new permission decorator * Add missing return * Allow both kwargs and decorators for permissions * Add guild_only decorator * Allow decorating a command * Remove permissions v1 from docs * Fix to_check in get_desynced_commands * merge master into permissions-v2 * Rename has_permissions to default_permissions * Document command permissions * Fix command permissions doc note * Remove note * Rename dm_permission to guild_only for parity * Fix imports * Fix kwarg name * Move permission attributes to parent class * Fix guild_only decorator * [Perms v2] Additional implementations (#1303) * Update discord/commands/core.py Co-authored-by: Jay Turner * Reduce need for Permissions TypeVar * Update api.rst * merge permissionsv2 into master branch copy * apply code style fixes * apply code style fixes Co-authored-by: Dorukyum Co-authored-by: Dorukyum <53639936+Dorukyum@users.noreply.github.com> Co-authored-by: BobDotCom <71356958+BobDotCom@users.noreply.github.com> Co-authored-by: Lala Sabathil Co-authored-by: plun1331 <49261529+plun1331@users.noreply.github.com> Co-authored-by: Jay Turner --- discord/audit_logs.py | 1 + discord/bot.py | 114 +-------------- discord/commands/core.py | 127 ++++++++-------- discord/commands/permissions.py | 250 +++++++------------------------- discord/enums.py | 9 +- discord/http.py | 25 +++- discord/state.py | 5 +- discord/types/interactions.py | 2 +- docs/api.rst | 68 +++++---- 9 files changed, 193 insertions(+), 408 deletions(-) diff --git a/discord/audit_logs.py b/discord/audit_logs.py index b50f496af1..ee8a4c47e9 100644 --- a/discord/audit_logs.py +++ b/discord/audit_logs.py @@ -238,6 +238,7 @@ class AuditLogChanges: "location_type", _enum_transformer(enums.ScheduledEventLocationType), ), + "command_id": ("command_id", _transform_snowflake), } def __init__( diff --git a/discord/bot.py b/discord/bot.py index e9ec44f777..97f0b78640 100644 --- a/discord/bot.py +++ b/discord/bot.py @@ -256,7 +256,8 @@ def _check_command(cmd: ApplicationCommand, match: Dict) -> bool: else: as_dict = cmd.to_dict() to_check = { - "default_permission": None, + "dm_permission": None, + "default_member_permissions": None, "name": None, "description": None, "name_localizations": None, @@ -572,7 +573,6 @@ async def sync_commands( register_guild_commands: bool = True, check_guilds: Optional[List[int]] = [], delete_exiting: bool = True, - _register_permissions: bool = True, # TODO: Remove for perms v2 ) -> None: """|coro| @@ -645,12 +645,6 @@ async def sync_commands( guild_commands, guild_id=guild_id, method=method, force=force, delete_existing=delete_exiting ) - # TODO: 2.1: Remove this and favor permissions v2 - # Global Command Permissions - - if not _register_permissions: - return - global_permissions: List = [] for i in registered_commands: @@ -664,12 +658,7 @@ async def sync_commands( cmd.id = i["id"] self._application_commands[cmd.id] = cmd - # Permissions (Roles will be converted to IDs just before Upsert for Global Commands) - global_permissions.append({"id": i["id"], "permissions": cmd.permissions}) - for guild_id, commands in registered_guild_commands.items(): - guild_permissions: List = [] - for i in commands: cmd = find( lambda cmd: cmd.name == i["name"] @@ -684,105 +673,6 @@ async def sync_commands( cmd.id = i["id"] self._application_commands[cmd.id] = cmd - # Permissions - permissions = [ - perm.to_dict() - for perm in cmd.permissions - if perm.guild_id is None - or (perm.guild_id == guild_id and cmd.guild_ids is not None and perm.guild_id in cmd.guild_ids) - ] - guild_permissions.append({"id": i["id"], "permissions": permissions}) - - for global_command in global_permissions: - permissions = [ - perm.to_dict() - for perm in global_command["permissions"] - if perm.guild_id is None - or (perm.guild_id == guild_id and cmd.guild_ids is not None and perm.guild_id in cmd.guild_ids) - ] - guild_permissions.append({"id": global_command["id"], "permissions": permissions}) - - # Collect & Upsert Permissions for Each Guild - # Command Permissions for this Guild - guild_cmd_perms: List = [] - - # Loop through Commands Permissions available for this Guild - for item in guild_permissions: - new_cmd_perm = {"id": item["id"], "permissions": []} - - # Replace Role / Owner Names with IDs - for permission in item["permissions"]: - if isinstance(permission["id"], str): - # Replace Role Names - if permission["type"] == 1: - role = get( - self._bot.get_guild(guild_id).roles, - name=permission["id"], - ) - - # If not missing - if role is not None: - new_cmd_perm["permissions"].append( - { - "id": role.id, - "type": 1, - "permission": permission["permission"], - } - ) - else: - raise RuntimeError( - "No Role ID found in Guild ({guild_id}) for Role ({role})".format( - guild_id=guild_id, role=permission["id"] - ) - ) - # Add owner IDs - elif permission["type"] == 2 and permission["id"] == "owner": - app = await self.application_info() # type: ignore - if app.team: - for m in app.team.members: - new_cmd_perm["permissions"].append( - { - "id": m.id, - "type": 2, - "permission": permission["permission"], - } - ) - else: - new_cmd_perm["permissions"].append( - { - "id": app.owner.id, - "type": 2, - "permission": permission["permission"], - } - ) - # Add the rest - else: - new_cmd_perm["permissions"].append(permission) - - # Make sure we don't have over 10 overwrites - if len(new_cmd_perm["permissions"]) > 10: - raise RuntimeError( - "Command '{name}' has more than 10 permission overrides in guild ({guild_id}).".format( - name=self._application_commands[new_cmd_perm["id"]].name, - guild_id=guild_id, - ) - ) - # Append to guild_cmd_perms - guild_cmd_perms.append(new_cmd_perm) - - # Upsert - try: - await self._bot.http.bulk_upsert_command_permissions(self._bot.user.id, guild_id, guild_cmd_perms) - except Forbidden: - raise RuntimeError( - f"Failed to add command permissions to guild {guild_id}", - file=sys.stderr, - ) - except HTTPException: - _log.warning( - "Command Permissions V2 not yet implemented, permissions will not be set for your commands." - ) - async def process_application_commands(self, interaction: Interaction, auto_sync: bool = None) -> None: """|coro| diff --git a/discord/commands/core.py b/discord/commands/core.py index 14065bed9a..4711282e43 100644 --- a/discord/commands/core.py +++ b/discord/commands/core.py @@ -67,7 +67,6 @@ from ..utils import async_all, find, get_or_fetch, utcnow from .context import ApplicationContext, AutocompleteContext from .options import Option, OptionChoice -from .permissions import CommandPermission __all__ = ( "_BaseCommand", @@ -87,6 +86,7 @@ if TYPE_CHECKING: from typing_extensions import Concatenate, ParamSpec + from .. import Permissions from ..cog import Cog T = TypeVar("T") @@ -201,6 +201,12 @@ def __init__(self, func: Callable, **kwargs) -> None: self.guild_ids: Optional[List[int]] = kwargs.get("guild_ids", None) self.parent = kwargs.get("parent") + # Permissions + self.default_member_permissions: Optional["Permissions"] = getattr( + func, "__default_member_permissions__", kwargs.get("default_member_permissions", None) + ) + self.guild_only: Optional[bool] = getattr(func, "__guild_only__", kwargs.get("guild_only", None)) + def __repr__(self) -> str: return f"" @@ -573,16 +579,11 @@ class SlashCommand(ApplicationCommand): parent: Optional[:class:`SlashCommandGroup`] The parent group that this command belongs to. ``None`` if there isn't one. - default_permission: :class:`bool` - Whether the command is enabled by default when it is added to a guild. - permissions: List[:class:`CommandPermission`] - The permissions for this command. - - .. note:: - - If this is not empty then default_permissions will be set to False. - - cog: Optional[:class:`.Cog`] + guild_only: :class:`bool` + Whether the command should only be usable inside a guild. + default_member_permissions: :class:`~discord.Permissions` + The default permissions a member needs to be able to run the command. + cog: Optional[:class:`Cog`] The cog that this command belongs to. ``None`` if there isn't one. checks: List[Callable[[:class:`.ApplicationContext`], :class:`bool`]] A list of predicates that verifies if the command could be executed @@ -647,14 +648,6 @@ def __init__(self, func: Callable, *args, **kwargs) -> None: self._before_invoke = None self._after_invoke = None - # Permissions - self.default_permission = kwargs.get("default_permission", True) - self.permissions: List[CommandPermission] = getattr(func, "__app_cmd_perms__", []) + kwargs.get( - "permissions", [] - ) - if self.permissions and self.default_permission: - self.default_permission = False - def _check_required_params(self, params): params = iter(params.items()) required_params = ["self", "context"] if self.attached_to_group or self.cog else ["context"] @@ -686,13 +679,16 @@ def _parse_options(self, params, *, check_params: bool = True) -> List[Option]: if isinstance(p_obj.default, Option): # arg: type = Option(...) p_obj.default.input_type = SlashCommandOptionType.from_datatype(option) option = p_obj.default - else: # arg: Option(...) = default + else: # arg: Option(...) = default option = Option(option, "No description provided") - if option.default is None: - if not p_obj.default == inspect.Parameter.empty and not isinstance(p_obj.default, Option): - option.default = p_obj.default - option.required = False + if ( + option.default is None + and p_obj.default != inspect.Parameter.empty + and not isinstance(p_obj.default, Option) + ): + option.default = p_obj.default + option.required = False if option.name is None: option.name = p_name @@ -723,7 +719,7 @@ def _match_option_param_names(self, params, options): try: p_name, p_obj = next(params) except StopIteration: # not enough params for all the options - raise ClientException(f"Too many arguments passed to the options kwarg.") + raise ClientException("Too many arguments passed to the options kwarg.") p_obj = p_obj.annotation if not any(check(o, p_obj) for check in check_annotations): @@ -760,7 +756,6 @@ def to_dict(self) -> Dict: "name": self.name, "description": self.description, "options": [o.to_dict() for o in self.options], - "default_permission": self.default_permission, } if self.name_localizations is not None: as_dict["name_localizations"] = self.name_localizations @@ -769,6 +764,12 @@ def to_dict(self) -> Dict: if self.is_subcommand: as_dict["type"] = SlashCommandOptionType.sub_command.value + if self.guild_only is not None: + as_dict["guild_only"] = self.guild_only + + if self.default_member_permissions is not None: + as_dict["default_member_permissions"] = self.default_member_permissions.value + return as_dict async def _invoke(self, ctx: ApplicationContext) -> None: @@ -916,6 +917,10 @@ class SlashCommandGroup(ApplicationCommand): parent: Optional[:class:`SlashCommandGroup`] The parent group that this group belongs to. ``None`` if there isn't one. + guild_only: :class:`bool` + Whether the command should only be usable inside a guild. + default_member_permissions: :class:`~discord.Permissions` + The default permissions a member needs to be able to run the command. subcommands: List[Union[:class:`SlashCommand`, :class:`SlashCommandGroup`]] The list of all subcommands under this group. cog: Optional[:class:`.Cog`] @@ -978,10 +983,9 @@ def __init__( self.id = None # Permissions - self.default_permission = kwargs.get("default_permission", True) - self.permissions: List[CommandPermission] = kwargs.get("permissions", []) - if self.permissions and self.default_permission: - self.default_permission = False + self.default_member_permissions: Optional["Permissions"] = kwargs.get("default_member_permissions", None) + self.guild_only: Optional[bool] = kwargs.get("guild_only", None) + self.name_localizations: Optional[Dict[str, str]] = kwargs.get("name_localizations", None) self.description_localizations: Optional[Dict[str, str]] = kwargs.get("description_localizations", None) @@ -994,7 +998,6 @@ def to_dict(self) -> Dict: "name": self.name, "description": self.description, "options": [c.to_dict() for c in self.subcommands], - "default_permission": self.default_permission, } if self.name_localizations is not None: as_dict["name_localizations"] = self.name_localizations @@ -1004,6 +1007,12 @@ def to_dict(self) -> Dict: if self.parent is not None: as_dict["type"] = self.input_type.value + if self.guild_only is not None: + as_dict["guild_only"] = self.guild_only + + if self.default_member_permissions is not None: + as_dict["default_member_permissions"] = self.default_member_permissions.value + return as_dict def command(self, **kwargs) -> Callable[[Callable], SlashCommand]: @@ -1182,15 +1191,11 @@ class ContextMenuCommand(ApplicationCommand): The coroutine that is executed when the command is called. guild_ids: Optional[List[:class:`int`]] The ids of the guilds where this command will be registered. - default_permission: :class:`bool` - Whether the command is enabled by default when it is added to a guild. - permissions: List[:class:`.CommandPermission`] - The permissions for this command. - - .. note:: - If this is not empty then default_permissions will be set to ``False``. - - cog: Optional[:class:`.Cog`] + guild_only: :class:`bool` + Whether the command should only be usable inside a guild. + default_member_permissions: :class:`~discord.Permissions` + The default permissions a member needs to be able to run the command. + cog: Optional[:class:`Cog`] The cog that this command belongs to. ``None`` if there isn't one. checks: List[Callable[[:class:`.ApplicationContext`], :class:`bool`]] A list of predicates that verifies if the command could be executed @@ -1235,13 +1240,6 @@ def __init__(self, func: Callable, *args, **kwargs) -> None: self.validate_parameters() - self.default_permission = kwargs.get("default_permission", True) - self.permissions: List[CommandPermission] = getattr(func, "__app_cmd_perms__", []) + kwargs.get( - "permissions", [] - ) - if self.permissions and self.default_permission: - self.default_permission = False - # Context Menu commands can't have parents self.parent = None @@ -1282,9 +1280,14 @@ def to_dict(self) -> Dict[str, Union[str, int]]: "name": self.name, "description": self.description, "type": self.type, - "default_permission": self.default_permission, } + if self.guild_only is not None: + as_dict["guild_only"] = self.guild_only + + if self.default_member_permissions is not None: + as_dict["default_member_permissions"] = self.default_member_permissions.value + if self.name_localizations is not None: as_dict["name_localizations"] = self.name_localizations @@ -1618,42 +1621,42 @@ def validate_chat_input_name(name: Any, locale: Optional[str] = None): # Must meet the regex ^[-_\w\d\u0901-\u097D\u0E00-\u0E7F]{1,32}$ if locale is not None and locale not in valid_locales: raise ValidationError( - f"Locale '{locale}' is not a valid locale, " - f"see {docs}/reference#locales for list of supported locales." + f"Locale '{locale}' is not a valid locale, " f"see {docs}/reference#locales for list of supported locales." ) error = None - if not isinstance(name, str): - error = TypeError(f"Command names and options must be of type str. Received \"{name}\"") + if not isinstance(name, str) or not re.match(r"^[\w-]{1,32}$", name): + error = TypeError(f'Command names and options must be of type str. Received "{name}"') elif not re.match(r"^[-_\w\d\u0901-\u097D\u0E00-\u0E7F]{1,32}$", name): error = ValidationError( r"Command names and options must follow the regex \"^[-_\w\d\u0901-\u097D\u0E00-\u0E7F]{1,32}$\". For more information, see " f"{docs}/interactions/application-commands#application-command-object-application-command-naming. " - f"Received \"{name}\"" + f'Received "{name}"' ) elif not 1 <= len(name) <= 32: - error = ValidationError(f"Command names and options must be 1-32 characters long. Received \"{name}\"") - elif not name.lower() == name: # Can't use islower() as it fails if none of the chars can be lower. See #512. - error = ValidationError(f"Command names and options must be lowercase. Received \"{name}\"") + error = ValidationError(f'Command names and options must be 1-32 characters long. Received "{name}"') + elif name.lower() != name: # Can't use islower() as it fails if none of the chars can be lower. See #512. + error = ValidationError(f'Command names and options must be lowercase. Received "{name}"') if error: if locale: - error.args = (error.args[0]+f" in locale {locale}",) + error.args = (f"{error.args[0]} in locale {locale}",) raise error def validate_chat_input_description(description: Any, locale: Optional[str] = None): if locale is not None and locale not in valid_locales: raise ValidationError( - f"Locale '{locale}' is not a valid locale, " - f"see {docs}/reference#locales for list of supported locales." + f"Locale '{locale}' is not a valid locale, " f"see {docs}/reference#locales for list of supported locales." ) error = None if not isinstance(description, str): - error = TypeError(f"Command and option description must be of type str. Received \"{description}\"") + error = TypeError(f'Command and option description must be of type str. Received "{description}"') elif not 1 <= len(description) <= 100: - error = ValidationError(f"Command and option description must be 1-100 characters long. Received \"{description}\"") + error = ValidationError( + f'Command and option description must be 1-100 characters long. Received "{description}"' + ) if error: if locale: - error.args = (error.args[0]+f" in locale {locale}",) + error.args = (f"{error.args[0]} in locale {locale}",) raise error diff --git a/discord/commands/permissions.py b/discord/commands/permissions.py index c8a6c3480d..dc5b34a2d4 100644 --- a/discord/commands/permissions.py +++ b/discord/commands/permissions.py @@ -25,227 +25,83 @@ from typing import Callable, Dict, Union +from ..permissions import Permissions +from .core import ApplicationCommand + __all__ = ( - "CommandPermission", - "has_role", - "has_any_role", - "is_user", - "is_owner", - "permission", + "default_permissions", + "guild_only", ) -class CommandPermission: - """The class used in the application command decorators - to hash permission data into a dictionary using the - :meth:`~to_dict` method to be sent to the discord API later on. - - .. versionadded:: 2.0 - - Attributes - ----------- - id: Union[:class:`int`, :class:`str`] - A string or integer that represents or helps get - the id of the user or role that the permission is tied to. - type: :class:`int` - An integer representing the type of the permission. - permission: :class:`bool` - A boolean representing the permission's value. - guild_id: :class:`int` - The integer which represents the id of the guild that the - permission may be tied to. - """ - - def __init__( - self, - id: Union[int, str], - type: int, - permission: bool = True, - guild_id: int = None, - ): - self.id = id - self.type = type - self.permission = permission - self.guild_id = guild_id - - def to_dict(self) -> Dict[str, Union[int, bool]]: - return {"id": self.id, "type": self.type, "permission": self.permission} - - -def permission( - role_id: int = None, - user_id: int = None, - permission: bool = True, - guild_id: int = None, -): - """The method used to specify application command permissions - for specific users or roles using their id. - - This method is meant to be used as a decorator. - - .. versionadded:: 2.0 - - Parameters - ----------- - role_id: :class:`int` - An integer which represents the id of the role that the - permission may be tied to. - user_id: :class:`int` - An integer which represents the id of the user that the - permission may be tied to. - permission: :class:`bool` - A boolean representing the permission's value. - guild_id: :class:`int` - The integer which represents the id of the guild that the - permission may be tied to. - """ - - def decorator(func: Callable): - if not role_id is None: - app_cmd_perm = CommandPermission(role_id, 1, permission, guild_id) - elif not user_id is None: - app_cmd_perm = CommandPermission(user_id, 2, permission, guild_id) - else: - raise ValueError("role_id or user_id must be specified!") - - # Create __app_cmd_perms__ - if not hasattr(func, "__app_cmd_perms__"): - func.__app_cmd_perms__ = [] - - # Append - func.__app_cmd_perms__.append(app_cmd_perm) - - return func - - return decorator - - -def has_role(item: Union[int, str], guild_id: int = None): - """The method used to specify application command role restrictions. - - This method is meant to be used as a decorator. - - .. versionadded:: 2.0 - - Parameters - ----------- - item: Union[:class:`int`, :class:`str`] - An integer or string that represent the id or name of the role - that the permission is tied to. - guild_id: :class:`int` - The integer which represents the id of the guild that the - permission may be tied to. - """ - - def decorator(func: Callable): - # Create __app_cmd_perms__ - if not hasattr(func, "__app_cmd_perms__"): - func.__app_cmd_perms__ = [] - - # Permissions (Will Convert ID later in register_commands if needed) - app_cmd_perm = CommandPermission(item, 1, True, guild_id) # {"id": item, "type": 1, "permission": True} - - # Append - func.__app_cmd_perms__.append(app_cmd_perm) - - return func +def default_permissions(**perms: bool) -> Callable: + """A decorator that limits the usage of a slash command to members with certain + permissions. - return decorator + The permissions passed in must be exactly like the properties shown under + :class:`.discord.Permissions`. - -def has_any_role(*items: Union[int, str], guild_id: int = None): - """The method used to specify multiple application command role restrictions, - The application command runs if the invoker has **any** of the specified roles. - - This method is meant to be used as a decorator. - - .. versionadded:: 2.0 + .. note:: + These permissions can be updated by server administrators per-guild. As such, these are only "defaults", as the + name suggests. If you want to make sure that a user **always** has the specified permissions regardless, you + should use an internal check such as :func:`~.ext.commands.has_permissions`. Parameters - ----------- - *items: Union[:class:`int`, :class:`str`] - The integers or strings that represent the ids or names of the roles - that the permission is tied to. - guild_id: :class:`int` - The integer which represents the id of the guild that the - permission may be tied to. - """ - - def decorator(func: Callable): - # Create __app_cmd_perms__ - if not hasattr(func, "__app_cmd_perms__"): - func.__app_cmd_perms__ = [] + ------------ + perms + An argument list of permissions to check for. - # Permissions (Will Convert ID later in register_commands if needed) - for item in items: - app_cmd_perm = CommandPermission(item, 1, True, guild_id) # {"id": item, "type": 1, "permission": True} + Example + --------- - # Append - func.__app_cmd_perms__.append(app_cmd_perm) + .. code-block:: python3 - return func + from discord import has_permissions - return decorator + @bot.slash_command() + @has_permissions(manage_messages=True) + async def test(ctx): + await ctx.respond('You can manage messages.') - -def is_user(user: int, guild_id: int = None): - """The method used to specify application command user restrictions. - - This method is meant to be used as a decorator. - - .. versionadded:: 2.0 - - Parameters - ----------- - user: :class:`int` - An integer that represent the id of the user that the permission is tied to. - guild_id: :class:`int` - The integer which represents the id of the guild that the - permission may be tied to. """ - def decorator(func: Callable): - # Create __app_cmd_perms__ - if not hasattr(func, "__app_cmd_perms__"): - func.__app_cmd_perms__ = [] + invalid = set(perms) - set(Permissions.VALID_FLAGS) + if invalid: + raise TypeError(f"Invalid permission(s): {', '.join(invalid)}") - # Permissions (Will Convert ID later in register_commands if needed) - app_cmd_perm = CommandPermission(user, 2, True, guild_id) # {"id": user, "type": 2, "permission": True} + def inner(command: Callable): + if isinstance(command, ApplicationCommand): + command.default_member_permissions = Permissions(**perms) + else: + command.__default_member_permissions__ = Permissions(**perms) + return command - # Append - func.__app_cmd_perms__.append(app_cmd_perm) + return inner - return func - return decorator +def guild_only() -> Callable: + """A decorator that limits the usage of a slash command to guild contexts. + The command won't be able to be used in private message channels. + Example + --------- -def is_owner(guild_id: int = None): - """The method used to limit application commands exclusively - to the owner of the bot. + .. code-block:: python3 - This method is meant to be used as a decorator. + from discord import guild_only - .. versionadded:: 2.0 + @bot.slash_command() + @guild_only() + async def test(ctx): + await ctx.respond('You\'re in a guild.') - Parameters - ----------- - guild_id: :class:`int` - The integer which represents the id of the guild that the - permission may be tied to. """ - def decorator(func: Callable): - # Create __app_cmd_perms__ - if not hasattr(func, "__app_cmd_perms__"): - func.__app_cmd_perms__ = [] - - # Permissions (Will Convert ID later in register_commands if needed) - app_cmd_perm = CommandPermission("owner", 2, True, guild_id) # {"id": "owner", "type": 2, "permission": True} - - # Append - func.__app_cmd_perms__.append(app_cmd_perm) - - return func + def inner(command: Callable): + if isinstance(command, ApplicationCommand): + command.guild_only = True + else: + command.__guild_only__ = True + return command - return decorator + return inner diff --git a/discord/enums.py b/discord/enums.py index 7a9230de11..9920d70a86 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -380,6 +380,7 @@ class AuditLogAction(Enum): thread_create = 110 thread_update = 111 thread_delete = 112 + application_command_permission_update = 121 @property def category(self) -> Optional[AuditLogActionCategory]: @@ -431,6 +432,7 @@ def category(self) -> Optional[AuditLogActionCategory]: AuditLogAction.thread_create: AuditLogActionCategory.create, AuditLogAction.thread_update: AuditLogActionCategory.update, AuditLogAction.thread_delete: AuditLogActionCategory.delete, + AuditLogAction.application_command_permission_update: AuditLogActionCategory.update, } return lookup[self] @@ -467,6 +469,8 @@ def target_type(self) -> Optional[str]: return "scheduled_event" elif v < 113: return "thread" + elif v < 121: + return "application_command_permission" class UserFlags(Enum): @@ -696,8 +700,11 @@ def from_datatype(cls, datatype): return cls.number from .commands.context import ApplicationContext + if not issubclass(datatype, ApplicationContext): # TODO: prevent ctx being passed here in cog commands - raise TypeError(f"Invalid class {datatype} used as an input type for an Option") # TODO: Improve the error message + raise TypeError( + f"Invalid class {datatype} used as an input type for an Option" + ) # TODO: Improve the error message class EmbeddedActivity(Enum): diff --git a/discord/http.py b/discord/http.py index 5ce4222e22..e0b5352c09 100644 --- a/discord/http.py +++ b/discord/http.py @@ -2239,19 +2239,34 @@ def bulk_upsert_guild_commands( ) return self.request(r, json=payload) - def bulk_upsert_command_permissions( + # Application commands (permissions) + + def get_command_permissions( self, application_id: Snowflake, guild_id: Snowflake, - payload: List[interactions.EditApplicationCommand], - ) -> Response[List[interactions.ApplicationCommand]]: + command_id: Snowflake, + ) -> Response[interactions.GuildApplicationCommandPermissions]: r = Route( - "PUT", + "GET", + "/applications/{application_id}/guilds/{guild_id}/commands/{command_id}/permissions", + application_id=application_id, + guild_id=guild_id, + ) + return self.request(r) + + def get_guild_command_permissions( + self, + application_id: Snowflake, + guild_id: Snowflake, + ) -> Response[List[interactions.GuildApplicationCommandPermissions]]: + r = Route( + "GET", "/applications/{application_id}/guilds/{guild_id}/commands/permissions", application_id=application_id, guild_id=guild_id, ) - return self.request(r, json=payload) + return self.request(r) # Interaction responses diff --git a/discord/state.py b/discord/state.py index f115f0193f..c1571d9255 100644 --- a/discord/state.py +++ b/discord/state.py @@ -613,6 +613,10 @@ def parse_ready(self, data) -> None: def parse_resumed(self, data) -> None: self.dispatch("resumed") + def parse_application_command_permissions_update(self, data) -> None: + # unsure what the implementation would be like + pass + def parse_message_create(self, data) -> None: channel, _ = self._get_guild_channel(data) # channel would be the correct type here @@ -1572,7 +1576,6 @@ def parse_voice_state_update(self, data) -> None: coro = voice.on_voice_state_update(data) asyncio.create_task(logging_coroutine(coro, info="Voice Protocol voice state update handler")) - def parse_voice_server_update(self, data) -> None: try: key_id = int(data["guild_id"]) diff --git a/discord/types/interactions.py b/discord/types/interactions.py index 8ce26f4a76..56cb9c1f5e 100644 --- a/discord/types/interactions.py +++ b/discord/types/interactions.py @@ -85,7 +85,7 @@ class ApplicationCommandOptionChoice(_ApplicationCommandOptionChoiceOptional): value: Union[str, int] -ApplicationCommandPermissionType = Literal[1, 2] +ApplicationCommandPermissionType = Literal[1, 2, 3] class ApplicationCommandPermissions(TypedDict): diff --git a/docs/api.rst b/docs/api.rst index 7be1558187..7c6648a8b3 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -96,6 +96,18 @@ AutoShardedBot Application Commands --------------------- + +Command Permission Decorators +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + +.. autofunction:: discord.commands.default_permissions + :decorator: + +.. autofunction:: discord.commands.guild_only + :decorator: + + ApplicationCommand ~~~~~~~~~~~~~~~~~~~ @@ -104,10 +116,10 @@ ApplicationCommand .. autoclass:: ApplicationCommand :members: -.. autofunction:: discord.commands.core.application_command +.. autofunction:: discord.commands.application_command :decorator: -.. autofunction:: discord.commands.core.command +.. autofunction:: discord.commands.command :decorator: SlashCommand @@ -118,7 +130,7 @@ SlashCommand .. autoclass:: SlashCommand :members: -.. autofunction:: discord.commands.core.slash_command +.. autofunction:: discord.commands.slash_command :decorator: SlashCommandGroup @@ -137,7 +149,7 @@ Option .. autoclass:: Option :members: -.. autofunction:: discord.commands.core.Option +.. autofunction:: discord.commands.Option :decorator: OptionChoice @@ -156,7 +168,7 @@ UserCommand .. autoclass:: UserCommand :members: -.. autofunction:: discord.commands.core.user_command +.. autofunction:: discord.commands.user_command :decorator: MessageCommand @@ -167,7 +179,7 @@ MessageCommand .. autoclass:: MessageCommand :members: -.. autofunction:: discord.commands.core.message_command +.. autofunction:: discord.commands.message_command :decorator: ApplicationContext @@ -186,29 +198,6 @@ AutocompleteContext .. autoclass:: AutocompleteContext :members: -CommandPermission -~~~~~~~~~~~~~~~~~ - -.. attributetable:: CommandPermission - -.. autoclass:: CommandPermission - :members: - -.. autofunction:: discord.commands.permissions.permission - :decorator: - -.. autofunction:: discord.commands.permissions.has_role - :decorator: - -.. autofunction:: discord.commands.permissions.has_any_role - :decorator: - -.. autofunction:: discord.commands.permissions.is_user - :decorator: - -.. autofunction:: discord.commands.permissions.is_owner - :decorator: - Cogs ----- @@ -2865,6 +2854,21 @@ of :class:`enum.Enum`. - :attr:`~AuditLogDiff.invitable` .. versionadded:: 2.0 + + .. attribute:: application_command_permission_update + + An application command's permissions were updated. + + When this is the action, the type of :attr:`~AuditLogEntry.target` is + an :class:`Object` with the ID of the command that + had it's permissions edited. + + Possible attributes for :class:`AuditLogDiff`: + + - :attr:`~AuditLogDiff.command_id` + + .. versionadded:: 2.0 + .. class:: AuditLogActionCategory @@ -3927,6 +3931,12 @@ AuditLogDiff Non-moderators can now add other non-moderators to this thread. :type: :class:`bool` + + .. attribute:: command_id + + This command's permissions were updated. + + :type: :class:`int` .. this is currently missing the following keys: reason and application_id I'm not sure how to about porting these From 610cbbe6464126a7d1312cf8696f96aa3369a685 Mon Sep 17 00:00:00 2001 From: argo0n <67531837+argo0n@users.noreply.github.com> Date: Fri, 6 May 2022 10:45:39 +0800 Subject: [PATCH 10/17] Make description an optional arg in a PageGroup (#1330) --- discord/ext/pages/pagination.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ext/pages/pagination.py b/discord/ext/pages/pagination.py index fcfdba6560..8afa1cd721 100644 --- a/discord/ext/pages/pagination.py +++ b/discord/ext/pages/pagination.py @@ -264,7 +264,7 @@ def __init__( self, pages: Union[List[str], List[Page], List[Union[List[discord.Embed], discord.Embed]]], label: str, - description: str, + description: Optional[str] = None, emoji: Union[str, discord.Emoji, discord.PartialEmoji] = None, show_disabled: Optional[bool] = None, show_indicator: Optional[bool] = None, From 18e21bbe4b03d208e5e5a82b9cca9f5a9e8df587 Mon Sep 17 00:00:00 2001 From: Krittick Date: Thu, 5 May 2022 19:49:06 -0700 Subject: [PATCH 11/17] [ext.pages] add docstring for change to optional description param in PageGroup --- discord/ext/pages/pagination.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/ext/pages/pagination.py b/discord/ext/pages/pagination.py index 8afa1cd721..82fbc6f3a8 100644 --- a/discord/ext/pages/pagination.py +++ b/discord/ext/pages/pagination.py @@ -230,7 +230,7 @@ class PageGroup: label: :class:`str` The label shown on the corresponding PaginatorMenu dropdown option. Also used as the SelectOption value. - description: :class:`str` + description: Optional[:class:`str`] The description shown on the corresponding PaginatorMenu dropdown option. emoji: Union[:class:`str`, :class:`discord.Emoji`, :class:`discord.PartialEmoji`] The emoji shown on the corresponding PaginatorMenu dropdown option. @@ -279,7 +279,7 @@ def __init__( trigger_on_display: Optional[bool] = None, ): self.label = label - self.description = description + self.description: Optional[str] = description self.emoji: Union[str, discord.Emoji, discord.PartialEmoji] = emoji self.pages: Union[List[str], List[Union[List[discord.Embed], discord.Embed]]] = pages self.show_disabled = show_disabled From 5acbc49764f73241a28e82fbd0fbafe6f2c657ab Mon Sep 17 00:00:00 2001 From: Krittick Date: Thu, 5 May 2022 20:00:00 -0700 Subject: [PATCH 12/17] apply linting / style changes --- discord/channel.py | 2 +- discord/ext/bridge/core.py | 3 +-- discord/interactions.py | 2 +- discord/ui/view.py | 6 ++--- examples/app_commands/slash_autocomplete.py | 12 ++++----- examples/app_commands/slash_options.py | 18 ++++++------- examples/audio_recording.py | 28 ++++++++++----------- 7 files changed, 35 insertions(+), 36 deletions(-) diff --git a/discord/channel.py b/discord/channel.py index 776da84709..84eb9b3ad6 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -923,7 +923,7 @@ class VoiceChannel(discord.abc.Messageable, VocalGuildChannel): .. versionadded:: 2.0 """ - __slots__ = ('nsfw') + __slots__ = "nsfw" def _update(self, guild: Guild, data: VoiceChannelPayload): super()._update(guild, data) diff --git a/discord/ext/bridge/core.py b/discord/ext/bridge/core.py index f0af1ff9da..f9aa6f7548 100644 --- a/discord/ext/bridge/core.py +++ b/discord/ext/bridge/core.py @@ -41,9 +41,8 @@ __all__ = ("BridgeCommand", "bridge_command", "BridgeExtCommand", "BridgeSlashCommand") -from ..commands.converter import _convert_to_bool - from ...utils import get +from ..commands.converter import _convert_to_bool class BridgeSlashCommand(SlashCommand): diff --git a/discord/interactions.py b/discord/interactions.py index c964e5ca4f..0ab2f6c7c6 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -28,7 +28,7 @@ from __future__ import annotations import asyncio -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union, Coroutine +from typing import TYPE_CHECKING, Any, Coroutine, Dict, List, Optional, Tuple, Union from . import utils from .channel import ChannelType, PartialMessageable diff --git a/discord/ui/view.py b/discord/ui/view.py index 220da5102b..6b3fec5a5a 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -470,20 +470,20 @@ def disable_all_items(self, *, exclusions: Optional[List[Item]] = None) -> None: Disables all items in the view. Parameters - ----------- + ----------- exclusions: Optional[List[:class:`ui.Item`]] A list of items in `self.children` to not disable from the view. """ for child in self.children: if exclusions is None or child not in exclusions: child.disabled = True - + def enable_all_items(self, *, exclusions: Optional[List[Item]] = None) -> None: """ Enables all items in the view. Parameters - ----------- + ----------- exclusions: Optional[List[:class:`ui.Item`]] A list of items in `self.children` to not enable from the view. """ diff --git a/examples/app_commands/slash_autocomplete.py b/examples/app_commands/slash_autocomplete.py index 84327766ce..2703a2ada0 100644 --- a/examples/app_commands/slash_autocomplete.py +++ b/examples/app_commands/slash_autocomplete.py @@ -127,9 +127,9 @@ async def get_animals(ctx: discord.AutocompleteContext): @option("color", description="Pick a color!", autocomplete=get_colors) @option("animal", description="Pick an animal!", autocomplete=get_animals) async def autocomplete_example( - ctx: discord.ApplicationContext, - color: str, - animal: str, + ctx: discord.ApplicationContext, + color: str, + animal: str, ): """Demonstrates using ctx.options to create options that are dependent on the values of other options. For the `color` option, a callback is passed, where additional logic can be added to determine which values are returned. @@ -151,9 +151,9 @@ async def autocomplete_example( # Demonstrates passing a static iterable discord.utils.basic_autocomplete ) async def autocomplete_basic_example( - ctx: discord.ApplicationContext, - color: str, - animal: str, + ctx: discord.ApplicationContext, + color: str, + animal: str, ): """This demonstrates using the discord.utils.basic_autocomplete helper function. For the `color` option, a callback is passed, where additional logic can be added to determine which values are returned. diff --git a/examples/app_commands/slash_options.py b/examples/app_commands/slash_options.py index 56f6bf923a..469fefadaa 100644 --- a/examples/app_commands/slash_options.py +++ b/examples/app_commands/slash_options.py @@ -25,10 +25,10 @@ # age: Option(int, "Enter your age") = 18 ) async def hello( - ctx: discord.ApplicationContext, - name: str, - gender: str, - age: str, + ctx: discord.ApplicationContext, + name: str, + gender: str, + age: str, ): await ctx.respond(f"Hello {name}! Your gender is {gender} and you are {age} years old.") @@ -38,11 +38,11 @@ async def hello( "channel", [discord.TextChannel, discord.VoiceChannel], # you can specify allowed channel types by passing a list of them like this - description="Select a channel" + description="Select a channel", ) async def channel( - ctx: discord.ApplicationContext, - channel: Union[discord.TextChannel, discord.VoiceChannel], + ctx: discord.ApplicationContext, + channel: Union[discord.TextChannel, discord.VoiceChannel], ): await ctx.respond(f"Hi! You selected {channel.mention} channel.") @@ -50,8 +50,8 @@ async def channel( @bot.slash_command(name="attach_file") @option("attachment", discord.Attachment, description="A file to attach to the message", required=False) async def say( - ctx: discord.ApplicationContext, - attachment: discord.Attachment, + ctx: discord.ApplicationContext, + attachment: discord.Attachment, ): """This demonstrates how to attach a file with a slash command.""" file = await attachment.to_file() diff --git a/examples/audio_recording.py b/examples/audio_recording.py index 5de421a1a8..54e65e1eb2 100644 --- a/examples/audio_recording.py +++ b/examples/audio_recording.py @@ -8,20 +8,20 @@ @bot.command() -@option("encoding", choices=[ - "mp3", - "wav", - "pcm", - "ogg", - "mka", - "mkv", - "mp4", - "m4a", -]) -async def start( - ctx: ApplicationContext, - encoding: str -): +@option( + "encoding", + choices=[ + "mp3", + "wav", + "pcm", + "ogg", + "mka", + "mkv", + "mp4", + "m4a", + ], +) +async def start(ctx: ApplicationContext, encoding: str): """ Record your voice! """ From de11ce520e4eafe86e9fb907906f65ec51518282 Mon Sep 17 00:00:00 2001 From: Dorukyum Date: Fri, 6 May 2022 22:38:09 +0300 Subject: [PATCH 13/17] Raise `commands.CommandError`s without wrapping --- discord/commands/core.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/discord/commands/core.py b/discord/commands/core.py index 4711282e43..fe3390a85e 100644 --- a/discord/commands/core.py +++ b/discord/commands/core.py @@ -100,12 +100,16 @@ def wrap_callback(coro): + from ..ext.commands.errors import CommandError + @functools.wraps(coro) async def wrapped(*args, **kwargs): try: ret = await coro(*args, **kwargs) except ApplicationCommandError: raise + except CommandError: + raise except asyncio.CancelledError: return except Exception as exc: @@ -116,12 +120,16 @@ async def wrapped(*args, **kwargs): def hooked_wrapped_callback(command, ctx, coro): + from ..ext.commands.errors import CommandError + @functools.wraps(coro) async def wrapped(arg): try: ret = await coro(arg) except ApplicationCommandError: raise + except CommandError: + raise except asyncio.CancelledError: return except Exception as exc: From 24a522dfc2d8a2f7301abce21af617d077f0c616 Mon Sep 17 00:00:00 2001 From: Dorukyum Date: Sat, 7 May 2022 13:44:54 +0300 Subject: [PATCH 14/17] Update default_permissions docstring --- discord/commands/permissions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/commands/permissions.py b/discord/commands/permissions.py index dc5b34a2d4..ce85665040 100644 --- a/discord/commands/permissions.py +++ b/discord/commands/permissions.py @@ -56,10 +56,10 @@ def default_permissions(**perms: bool) -> Callable: .. code-block:: python3 - from discord import has_permissions + from discord import default_permissions @bot.slash_command() - @has_permissions(manage_messages=True) + @default_permissions(manage_messages=True) async def test(ctx): await ctx.respond('You can manage messages.') From 8953f7a017708d622242e6b59566e90435548f75 Mon Sep 17 00:00:00 2001 From: Dorukyum Date: Sat, 7 May 2022 13:54:53 +0300 Subject: [PATCH 15/17] Remove unused imports --- discord/bot.py | 2 +- discord/channel.py | 2 -- discord/commands/core.py | 7 ++----- discord/commands/permissions.py | 2 +- discord/ext/commands/bot.py | 18 +----------------- discord/ext/commands/help.py | 3 +-- discord/threads.py | 2 -- 7 files changed, 6 insertions(+), 30 deletions(-) diff --git a/discord/bot.py b/discord/bot.py index 97f0b78640..48e2f55e2e 100644 --- a/discord/bot.py +++ b/discord/bot.py @@ -60,7 +60,7 @@ command, ) from .enums import InteractionType -from .errors import CheckFailure, DiscordException, Forbidden, HTTPException +from .errors import CheckFailure, DiscordException from .interactions import Interaction from .shard import AutoShardedClient from .types import interactions diff --git a/discord/channel.py b/discord/channel.py index 84eb9b3ad6..a48b3065c3 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -25,9 +25,7 @@ from __future__ import annotations -import asyncio import datetime -import time from typing import ( TYPE_CHECKING, Any, diff --git a/discord/commands/core.py b/discord/commands/core.py index fe3390a85e..fd909db0ef 100644 --- a/discord/commands/core.py +++ b/discord/commands/core.py @@ -35,7 +35,6 @@ from typing import ( TYPE_CHECKING, Any, - Awaitable, Callable, Coroutine, Dict, @@ -46,17 +45,15 @@ Type, TypeVar, Union, - overload, ) from ..channel import _guild_channel_factory -from ..enums import ChannelType, MessageType, SlashCommandOptionType, try_enum +from ..enums import MessageType, SlashCommandOptionType, try_enum from ..errors import ( ApplicationCommandError, ApplicationCommandInvokeError, CheckFailure, ClientException, - NotFound, ValidationError, ) from ..member import Member @@ -64,7 +61,7 @@ from ..object import Object from ..role import Role from ..user import User -from ..utils import async_all, find, get_or_fetch, utcnow +from ..utils import async_all, find, utcnow from .context import ApplicationContext, AutocompleteContext from .options import Option, OptionChoice diff --git a/discord/commands/permissions.py b/discord/commands/permissions.py index ce85665040..71d9132c5c 100644 --- a/discord/commands/permissions.py +++ b/discord/commands/permissions.py @@ -23,7 +23,7 @@ DEALINGS IN THE SOFTWARE. """ -from typing import Callable, Dict, Union +from typing import Callable from ..permissions import Permissions from .core import ApplicationCommand diff --git a/discord/ext/commands/bot.py b/discord/ext/commands/bot.py index b7c3a4ef5f..121ebdf438 100644 --- a/discord/ext/commands/bot.py +++ b/discord/ext/commands/bot.py @@ -25,31 +25,15 @@ from __future__ import annotations -import asyncio import collections import collections.abc -import importlib.util -import inspect import sys import traceback -import types -from typing import ( - TYPE_CHECKING, - Any, - Callable, - Dict, - List, - Mapping, - Optional, - Type, - TypeVar, - Union, -) +from typing import TYPE_CHECKING, Any, Callable, List, Optional, Type, TypeVar, Union import discord from . import errors -from .cog import Cog from .context import Context from .core import GroupMixin from .help import DefaultHelpCommand, HelpCommand diff --git a/discord/ext/commands/help.py b/discord/ext/commands/help.py index 27d219ccfa..200abd2452 100644 --- a/discord/ext/commands/help.py +++ b/discord/ext/commands/help.py @@ -25,10 +25,9 @@ import copy import functools -import inspect import itertools import re -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING import discord.utils diff --git a/discord/threads.py b/discord/threads.py index e90828e946..a76e6366fb 100644 --- a/discord/threads.py +++ b/discord/threads.py @@ -25,8 +25,6 @@ from __future__ import annotations -import asyncio -import time from typing import TYPE_CHECKING, Callable, Dict, Iterable, List, Optional, Union from .abc import Messageable, _purge_messages_helper From 6913833c1e076b9c14e7a51ceeb1ff4c156e9164 Mon Sep 17 00:00:00 2001 From: Dorukyum Date: Sat, 7 May 2022 21:38:56 +0300 Subject: [PATCH 16/17] Ignore `self` if command is inside a class --- discord/commands/core.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/discord/commands/core.py b/discord/commands/core.py index fd909db0ef..4ec6982321 100644 --- a/discord/commands/core.py +++ b/discord/commands/core.py @@ -655,7 +655,13 @@ def __init__(self, func: Callable, *args, **kwargs) -> None: def _check_required_params(self, params): params = iter(params.items()) - required_params = ["self", "context"] if self.attached_to_group or self.cog else ["context"] + required_params = ( + ["self", "context"] + if self.attached_to_group + or self.cog + or len(self.callback.__qualname__.split(".")) > 1 + else ["context"] + ) for p in required_params: try: next(params) @@ -746,12 +752,6 @@ def _is_typing_union(self, annotation): def _is_typing_optional(self, annotation): return self._is_typing_union(annotation) and type(None) in annotation.__args__ # type: ignore - def _set_cog(self, cog): - prev = self.cog - super()._set_cog(cog) - if (prev is None and cog is not None) or (prev is not None and cog is None): - self.options = self._parse_options(self._get_signature_parameters()) # parse again to leave out self - @property def is_subcommand(self) -> bool: return self.parent is not None From 7ad078fc778b1b86210cf7879b12dd7ce448e188 Mon Sep 17 00:00:00 2001 From: Middledot <78228142+Middledot@users.noreply.github.com> Date: Sat, 7 May 2022 17:08:47 -0400 Subject: [PATCH 17/17] Enum options (#1292) --- discord/commands/core.py | 34 +++++++++++++++++++--------------- discord/commands/options.py | 21 +++++++++++++++++++-- 2 files changed, 38 insertions(+), 17 deletions(-) diff --git a/discord/commands/core.py b/discord/commands/core.py index 4ec6982321..7e5aa728e3 100644 --- a/discord/commands/core.py +++ b/discord/commands/core.py @@ -32,6 +32,7 @@ import re import types from collections import OrderedDict +from enum import Enum from typing import ( TYPE_CHECKING, Any, @@ -682,25 +683,19 @@ def _parse_options(self, params, *, check_params: bool = True) -> List[Option]: if self._is_typing_union(option): if self._is_typing_optional(option): - option = Option(option.__args__[0], "No description provided", required=False) # type: ignore # union type + option = Option(option.__args__[0], required=False) else: - option = Option(option.__args__, "No description provided") # type: ignore # union type + option = Option(option.__args__) if not isinstance(option, Option): - if isinstance(p_obj.default, Option): # arg: type = Option(...) - p_obj.default.input_type = SlashCommandOptionType.from_datatype(option) - option = p_obj.default - else: # arg: Option(...) = default - option = Option(option, "No description provided") - - if ( - option.default is None - and p_obj.default != inspect.Parameter.empty - and not isinstance(p_obj.default, Option) - ): - option.default = p_obj.default - option.required = False + option = Option(option) + if option.default is None: + if p_obj.default == inspect.Parameter.empty: + option.default = None + else: + option.default = p_obj.default + option.required = False if option.name is None: option.name = p_name if option.name != p_name or option._parameter_name is None: @@ -830,6 +825,15 @@ async def _invoke(self, ctx: ApplicationContext) -> None: elif op.input_type == SlashCommandOptionType.string and (converter := op.converter) is not None: arg = await converter.convert(converter, ctx, arg) + elif issubclass(op._raw_type, Enum): + if isinstance(arg, str) and arg.isdigit(): + try: + arg = op._raw_type(int(arg)) + except ValueError: + arg = op._raw_type(arg) + else: + arg = op._raw_type(arg) + kwargs[op._parameter_name] = arg for o in self.options: diff --git a/discord/commands/options.py b/discord/commands/options.py index e60c53c440..13156325fc 100644 --- a/discord/commands/options.py +++ b/discord/commands/options.py @@ -22,9 +22,11 @@ DEALINGS IN THE SOFTWARE. """ +import inspect from typing import Any, Dict, List, Literal, Optional, Union +from enum import Enum -from ..enums import ChannelType, SlashCommandOptionType +from ..enums import ChannelType, SlashCommandOptionType, Enum as DiscordEnum __all__ = ( "ThreadOption", @@ -124,10 +126,18 @@ def __init__(self, input_type: Any = str, /, description: Optional[str] = None, self.converter = None self._raw_type = input_type self.channel_types: List[ChannelType] = kwargs.pop("channel_types", []) + enum_choices = [] if not isinstance(input_type, SlashCommandOptionType): if hasattr(input_type, "convert"): self.converter = input_type input_type = SlashCommandOptionType.string + elif issubclass(input_type, (Enum, DiscordEnum)): + enum_choices = [OptionChoice(e.name, e.value) for e in input_type] + if len(enum_choices) != len([elem for elem in enum_choices if elem.value.__class__ == enum_choices[0].value.__class__]): + enum_choices = [OptionChoice(e.name, str(e.value)) for e in input_type] + input_type = SlashCommandOptionType.string + else: + input_type = SlashCommandOptionType.from_datatype(enum_choices[0].value.__class__) else: try: _type = SlashCommandOptionType.from_datatype(input_type) @@ -158,10 +168,17 @@ def __init__(self, input_type: Any = str, /, description: Optional[str] = None, self.input_type = input_type self.required: bool = kwargs.pop("required", True) if "default" not in kwargs else False self.default = kwargs.pop("default", None) - self.choices: List[OptionChoice] = [ + self.choices: List[OptionChoice] = enum_choices or [ o if isinstance(o, OptionChoice) else OptionChoice(o) for o in kwargs.pop("choices", list()) ] + if description is not None: + self.description = description + elif issubclass(self._raw_type, Enum) and (doc := inspect.getdoc(self._raw_type)) is not None: + self.description = doc + else: + self.description = "No description provided" + if self.input_type == SlashCommandOptionType.integer: minmax_types = (int, type(None)) elif self.input_type == SlashCommandOptionType.number: