diff --git a/.github/ISSUE_TEMPLATE/vulnerability_report.yml b/.github/ISSUE_TEMPLATE/vulnerability_report.yml deleted file mode 100644 index efd9492cd9..0000000000 --- a/.github/ISSUE_TEMPLATE/vulnerability_report.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Vulnerability Report -description: Report vulnerabilities -labels: unconfirmed vulnerability -assignees: BobDotCom,Lulalaby,CodeWithSwastik -body: - - type: markdown - attributes: - value: > - Thanks for taking the time to fill out a vulnerability report. - If you want real-time support, consider joining our Discord at https://pycord.dev/discord instead. - - Please note that this form is for vulnerability reports only! - - type: textarea - attributes: - label: Summary - description: A simple summary of your vulnerability report - validations: - required: true - - type: textarea - attributes: - label: Reproduction Steps - description: > - What you did to make it happen. - validations: - required: true - - type: textarea - attributes: - label: Minimal Reproducible Code - description: > - A short snippet of code that showcases the vulnerability. - render: python - - type: textarea - attributes: - label: System Information - description: > - Run `python -m discord -v` and paste this information below. - - This command required v1.1.0 or higher of the library. If this errors out then show some basic - information involving your system such as operating system and Python version. - validations: - required: true - - type: checkboxes - attributes: - label: Checklist - description: > - Let's make sure you've properly done due dilligence when reporting this issue! - options: - - label: I have searched the open issues for duplicates. - required: true - - label: I have shown the entire steps to reproduce the vulnerability. - required: true - - label: I have removed my token from display, if visible. - required: true - - type: textarea - attributes: - label: Additional Context - description: If there is anything else to say, please do so here. diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 20b172ac0b..d950e6b795 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -10,5 +10,5 @@ ## Reporting a Vulnerability If you find a vulnerability you have two ways to report it: -- Write us on https://pycord.dev/discord -- Open an [issue](https://github.com/Pycord-Development/pycord/issues/new/choose) +- Write a dm to one of our core developers on https://pycord.dev/discord +- Write an email to admin@pycord.dev diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml index 7bfcea752d..6321a51781 100644 --- a/.github/workflows/codespell.yml +++ b/.github/workflows/codespell.yml @@ -7,5 +7,5 @@ jobs: steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 - - run: pip install codespell - - run: codespell --ignore-words-list="groupt,nd,ot" + - run: pip install codespell==2.1.0 + - run: codespell --ignore-words-list="groupt,nd,ot,ro,falsy,BU" --exclude-file=".github/workflows/codespell.yml" diff --git a/discord/__init__.py b/discord/__init__.py index 226b65b39e..14d53273b6 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -13,7 +13,8 @@ __author__ = "Pycord Development" __license__ = "MIT" __copyright__ = "Copyright 2015-2021 Rapptz & Copyright 2021-present Pycord Development" -__version__ = "2.0.1" +__version__ = "2.1.0" + __path__ = __import__("pkgutil").extend_path(__path__, __name__) @@ -25,6 +26,7 @@ from .appinfo import * from .asset import * from .audit_logs import * +from .automod import * from .bot import * from .channel import * from .client import * @@ -75,6 +77,7 @@ class VersionInfo(NamedTuple): serial: int -version_info: VersionInfo = VersionInfo(major=2, minor=0, micro=1, releaselevel="final", serial=0) +version_info: VersionInfo = VersionInfo(major=2, minor=1, micro=0, releaselevel="final", serial=0) + logging.getLogger(__name__).addHandler(logging.NullHandler()) diff --git a/discord/abc.py b/discord/abc.py index 90a7256c8e..00a6a13c10 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -175,14 +175,6 @@ async def _purge_messages_helper( return ret -class _Undefined: - def __repr__(self) -> str: - return "see-below" - - -_undefined: Any = _Undefined() - - @runtime_checkable class Snowflake(Protocol): """An ABC that details the common operations on a Discord model. @@ -382,7 +374,7 @@ async def _move( payload = [] for index, c in enumerate(channels): d: Dict[str, Any] = {"id": c.id, "position": index} - if parent_id is not _undefined and c.id == self.id: + if parent_id is not MISSING and c.id == self.id: d.update(parent_id=parent_id, lock_permissions=lock_permissions) payload.append(d) @@ -392,7 +384,7 @@ async def _edit(self, options: Dict[str, Any], reason: Optional[str]) -> Optiona try: parent = options.pop("category") except KeyError: - parent_id = _undefined + parent_id = MISSING else: parent_id = parent and parent.id @@ -420,7 +412,7 @@ async def _edit(self, options: Dict[str, Any], reason: Optional[str]) -> Optiona try: position = options.pop("position") except KeyError: - if parent_id is not _undefined: + if parent_id is not MISSING: if lock_permissions: category = self.guild.get_channel(parent_id) if category: @@ -603,7 +595,7 @@ def category(self) -> Optional[CategoryChannel]: @property def permissions_synced(self) -> bool: - """:class:`bool`: Whether or not the permissions for this channel are synced with the + """:class:`bool`: Whether the permissions for this channel are synced with the category it belongs to. If there is no category then this is ``False``. @@ -658,7 +650,7 @@ def permissions_for(self, obj: Union[Member, Role], /) -> Permissions: # (or otherwise) are then OR'd together. # After the role permissions are resolved, the member permissions # have to take into effect. - # After all that is done.. you have to do the following: + # After all that is done, you have to do the following: # If manage permissions is True, then all permissions are set to True. @@ -781,7 +773,7 @@ async def set_permissions( self, target: Union[Member, Role], *, - overwrite: Optional[Union[PermissionOverwrite, _Undefined]] = ..., + overwrite: Optional[PermissionOverwrite] = ..., reason: Optional[str] = ..., ) -> None: ... @@ -796,7 +788,7 @@ async def set_permissions( ) -> None: ... - async def set_permissions(self, target, *, overwrite=_undefined, reason=None, **permissions): + async def set_permissions(self, target, *, overwrite=MISSING, reason=None, **permissions): r"""|coro| Sets the channel specific permission overwrites for a target in the @@ -874,7 +866,7 @@ async def set_permissions(self, target, *, overwrite=_undefined, reason=None, ** else: raise InvalidArgument("target parameter must be either Member or Role") - if overwrite is _undefined: + if overwrite is MISSING: if len(permissions) == 0: raise InvalidArgument("No overwrite provided.") try: @@ -1046,7 +1038,7 @@ async def move(self, **kwargs) -> None: Raises ------- InvalidArgument - An invalid position was given or a bad mix of arguments were passed. + An invalid position was given or a bad mix of arguments was passed. Forbidden You do not have permissions to move the channel. HTTPException @@ -1152,20 +1144,22 @@ async def create_invite( .. versionadded:: 2.0 target_user: Optional[:class:`User`] - The user whose stream to display for this invite, required if `target_type` is `TargetType.stream`. The user must be streaming in the channel. + The user whose stream to display for this invite, required if `target_type` is `TargetType.stream`. + The user must be streaming in the channel. .. versionadded:: 2.0 target_application_id: Optional[:class:`int`] - The id of the embedded application for the invite, required if `target_type` is `TargetType.embedded_application`. + The id of the embedded application for the invite, required if `target_type` is + `TargetType.embedded_application`. .. versionadded:: 2.0 - target_event: Optional[:class:`ScheduledEvent`] + target_event: Optional[:class:`.ScheduledEvent`] The scheduled event object to link to the event. - Shortcut to :meth:`Invite.set_scheduled_event` + Shortcut to :meth:`.Invite.set_scheduled_event` - See :meth:`Invite.set_scheduled_event` for more + See :meth:`.Invite.set_scheduled_event` for more info on event invite linking. .. versionadded:: 2.0 @@ -1383,11 +1377,13 @@ async def send( .. versionadded:: 1.4 - reference: Union[:class:`~discord.Message`, :class:`~discord.MessageReference`, :class:`~discord.PartialMessage`] + reference: Union[:class:`~discord.Message`, :class:`~discord.MessageReference`, + :class:`~discord.PartialMessage`] A reference to the :class:`~discord.Message` to which you are replying, this can be created using :meth:`~discord.Message.to_reference` or passed directly as a :class:`~discord.Message`. You can control - whether this mentions the author of the referenced message using the :attr:`~discord.AllowedMentions.replied_user` - attribute of ``allowed_mentions`` or by setting ``mention_author``. + whether this mentions the author of the referenced message using the + :attr:`~discord.AllowedMentions.replied_user` attribute of ``allowed_mentions`` or by + setting ``mention_author``. .. versionadded:: 1.6 @@ -1732,7 +1728,7 @@ def history( If a datetime is provided, it is recommended to use a UTC aware datetime. If the datetime is naive, it is assumed to be local time. When using this argument, the maximum limit is 101. Note that if the limit is an - even number then this will return at most limit + 1 messages. + even number, then this will return at most limit + 1 messages. oldest_first: Optional[:class:`bool`] If set to ``True``, return messages in oldest->newest order. Defaults to ``True`` if ``after`` is specified, otherwise ``False``. diff --git a/discord/activity.py b/discord/activity.py index 2aa6a1739b..0ad4482c8f 100644 --- a/discord/activity.py +++ b/discord/activity.py @@ -97,7 +97,6 @@ from .types.activity import Activity as ActivityPayload from .types.activity import ( ActivityAssets, - ActivityButton, ActivityParty, ActivityTimestamps, ) @@ -166,7 +165,7 @@ class Activity(BaseActivity): The user's current state. For example, "In Game". details: Optional[:class:`str`] The detail of the user's current activity. - timestamps: :class:`dict` + timestamps: Dict[:class:`str`, :class:`int`] A dictionary of timestamps. It contains the following optional keys: - ``start``: Corresponds to when the user started doing the @@ -174,7 +173,7 @@ class Activity(BaseActivity): - ``end``: Corresponds to when the user will finish doing the activity in milliseconds since Unix epoch. - assets: :class:`dict` + assets: Dict[:class:`str`, :class:`str`] A dictionary representing the images and their hover text of an activity. It contains the following optional keys: @@ -183,12 +182,12 @@ class Activity(BaseActivity): - ``small_image``: A string representing the ID for the small image asset. - ``small_text``: A string representing the text when hovering over the small image asset. - party: :class:`dict` + party: Dict[:class:`str`, Union[:class:`str`, List[:class:`int`]]] A dictionary representing the activity party. It contains the following optional keys: - ``id``: A string representing the party ID. - ``size``: A list of up to two integer elements denoting (current_size, maximum_size). - buttons: Union[List[:class:`dict`], List[:class:`str`]] + buttons: Union[List[Dict[:class:`str`, :class:`str`]], List[:class:`str`]] A list of dictionaries representing custom buttons shown in a rich presence. Each dictionary contains the following keys: @@ -197,7 +196,7 @@ class Activity(BaseActivity): .. note:: - Bots cannot access a user's activity button URLs. Therefore the type of this attribute + Bots cannot access a user's activity button URLs. Therefore, the type of this attribute will be List[:class:`str`] when received through the gateway. .. versionadded:: 2.0 @@ -475,8 +474,8 @@ class Streaming(BaseActivity): url: :class:`str` The stream's URL. - assets: :class:`dict` - A dictionary comprising of similar keys than those in :attr:`Activity.assets`. + assets: Dict[:class:`str`, :class:`str`] + A dictionary comprised of similar keys than those in :attr:`Activity.assets`. """ __slots__ = ("platform", "name", "game", "url", "details", "assets") @@ -509,7 +508,7 @@ def twitch_name(self): """Optional[:class:`str`]: If provided, the twitch name of the user streaming. This corresponds to the ``large_image`` key of the :attr:`Streaming.assets` - dictionary if it starts with ``twitch:``. Typically set by the Discord client. + dictionary if it starts with ``twitch:``. Typically this is set by the Discord client. """ try: diff --git a/discord/appinfo.py b/discord/appinfo.py index 11565deec7..174ad773a0 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -67,7 +67,7 @@ class AppInfo: Whether the bot can be invited by anyone or if it is locked to the application owner. bot_require_code_grant: :class:`bool` - Whether the bot requires the completion of the full oauth2 code + Whether the bot requires the completion of the full OAuth2 code grant flow to join. rpc_origins: Optional[List[:class:`str`]] A list of RPC origin URLs, if RPC is enabled. @@ -188,7 +188,7 @@ def cover_image(self) -> Optional[Asset]: @property def guild(self) -> Optional[Guild]: """Optional[:class:`Guild`]: If this application is a game sold on Discord, - this field will be the guild to which it has been linked + this field will be the guild to which it has been linked. .. versionadded:: 1.3 """ diff --git a/discord/audit_logs.py b/discord/audit_logs.py index 34263abbe7..6868f99a8d 100644 --- a/discord/audit_logs.py +++ b/discord/audit_logs.py @@ -412,7 +412,7 @@ class AuditLogEntry(Hashable): The reason this action was done. extra: Any Extra information that this entry has that might be useful. - For most actions, this is ``None``. However in some cases it + For most actions, this is ``None``. However, in some cases it contains extra information. See :class:`AuditLogAction` for which actions have this field filled out. """ diff --git a/discord/automod.py b/discord/automod.py index 4eeee9a46e..b002189c6b 100644 --- a/discord/automod.py +++ b/discord/automod.py @@ -70,9 +70,11 @@ class AutoModActionMetadata: Attributes ----------- channel_id: :class:`int` - The ID of the channel to send the message to. Only for actions of type :attr:`AutoModActionType.send_alert_message`. + The ID of the channel to send the message to. + Only for actions of type :attr:`AutoModActionType.send_alert_message`. timeout_duration: :class:`datetime.timedelta` - How long the member that triggered the action should be timed out for. Only for actions of type :attr:`AutoModActionType.timeout`. + How long the member that triggered the action should be timed out for. + Only for actions of type :attr:`AutoModActionType.timeout`. """ # maybe add a table of action types and attributes? @@ -124,7 +126,6 @@ def __repr__(self) -> str: return f"" - class AutoModAction: """Represents an action for a guild's auto moderation rule. @@ -200,7 +201,7 @@ def to_dict(self) -> Dict: return data @classmethod - def from_dict(cls, data: AutoModActionMetadataPayload): + def from_dict(cls, data: AutoModTriggerMetadataPayload): kwargs = {} if (keyword_filter := data.get("keyword_filter")) is not None: @@ -339,8 +340,8 @@ def exempt_roles(self) -> List[Union[Role, Object]]: @cached_property def exempt_channels(self) -> List[Union[Union[TextChannel, ForumChannel, VoiceChannel], Object]]: - """List[Union[Union[:class:`TextChannel`, :class:`ForumChannel`, :class:`VoiceChannel`], :class:`Object`]]: The channels - that are exempt from this rule. + """List[Union[Union[:class:`TextChannel`, :class:`ForumChannel`, :class:`VoiceChannel`], :class:`Object`]]: + The channels that are exempt from this rule. If a channel is not found in the guild's cache, then it will be returned as an :class:`Object`. diff --git a/discord/bot.py b/discord/bot.py index 912bc2d510..89f4ec7b2e 100644 --- a/discord/bot.py +++ b/discord/bot.py @@ -132,6 +132,9 @@ def add_application_command(self, command: ApplicationCommand) -> None: if isinstance(command, SlashCommand) and command.is_subcommand: raise TypeError("The provided command is a sub-command of group") + if command.cog is MISSING: + command.cog = None + if self._bot.debug_guilds and command.guild_ids is None: command.guild_ids = self._bot.debug_guilds @@ -211,7 +214,11 @@ def get_application_command( return return command - async def get_desynced_commands(self, guild_id: Optional[int] = None, prefetched=None) -> List[Dict[str, Any]]: + async def get_desynced_commands( + self, + guild_id: Optional[int] = None, + prefetched: Optional[List[ApplicationCommand]] = None + ) -> List[Dict[str, Any]]: """|coro| Gets the list of commands that are desynced from discord. If ``guild_id`` is specified, it will only return @@ -228,12 +235,12 @@ async def get_desynced_commands(self, guild_id: Optional[int] = None, prefetched ---------- guild_id: Optional[:class:`int`] The guild id to get the desynced commands for, else global commands if unspecified. - prefetched + prefetched: Optional[List[:class:`.ApplicationCommand`]] If you already fetched the commands, you can pass them here to be used. Not recommended for typical usage. Returns ------- - List[Dict[str, Any]] + List[Dict[:class:`str`, Any]] A list of the desynced commands. Each will come with at least the ``cmd`` and ``action`` keys, which respectively contain the command and the action to perform. Other keys may also be present depending on the action, including ``id``. @@ -355,8 +362,8 @@ async def register_command( ) -> None: """|coro| - Registers a command. If the command has ``guild_ids`` set, or if the ``guild_ids`` parameter is passed, the command will - be registered as a guild command for those guilds. + Registers a command. If the command has ``guild_ids`` set, or if the ``guild_ids`` parameter is passed, + the command will be registered as a guild command for those guilds. Parameters ---------- @@ -394,7 +401,7 @@ async def register_commands( Parameters ---------- commands: Optional[List[:class:`~.ApplicationCommand`]] - A list of commands to register. If this is not set (None), then all commands will be registered. + A list of commands to register. If this is not set (``None``), then all commands will be registered. guild_id: Optional[int] If this is set, the commands will be registered as a guild command for the respective guild. If it is not set, the commands will be registered according to their :attr:`ApplicationCommand.guild_ids` attribute. @@ -644,8 +651,6 @@ async def sync_commands( guild_commands, guild_id=guild_id, method=method, force=force, delete_existing=delete_existing ) - global_permissions: List = [] - for i in registered_commands: cmd = get( self.pending_application_commands, @@ -684,8 +689,8 @@ async def process_application_commands(self, interaction: Interaction, auto_sync you should invoke this coroutine as well. This function finds a registered command matching the interaction id from - :attr:`.ApplicationCommandMixin.application_commands` and runs :meth:`ApplicationCommand.invoke` on it. If no - matching command was found, it replies to the interaction with a default message. + application commands and invokes it. If no matching command was + found, it replies to the interaction with a default message. .. versionadded:: 2.0 @@ -709,7 +714,7 @@ async def process_application_commands(self, interaction: Interaction, auto_sync try: command = self._application_commands[interaction.data["id"]] except KeyError: - for cmd in self.application_commands: + for cmd in self.application_commands + self.pending_application_commands: guild_id = interaction.data.get("guild_id") if guild_id: guild_id = int(guild_id) @@ -836,7 +841,7 @@ def create_group( self, name: str, description: Optional[str] = None, guild_ids: Optional[List[int]] = None, **kwargs ) -> SlashCommandGroup: """A shortcut method that creates a slash command group with no subcommands and adds it to the internal - command list via :meth:`~.ApplicationCommandMixin.add_application_command`. + command list via :meth:`add_application_command`. .. versionadded:: 2.0 @@ -869,7 +874,7 @@ def group( guild_ids: Optional[List[int]] = None, ) -> Callable[[Type[SlashCommandGroup]], SlashCommandGroup]: """A shortcut decorator that initializes the provided subclass of :class:`.SlashCommandGroup` - and adds it to the internal command list via :meth:`~.ApplicationCommandMixin.add_application_command`. + and adds it to the internal command list via :meth:`add_application_command`. .. versionadded:: 2.0 @@ -925,7 +930,7 @@ async def get_application_context(self, interaction: Interaction, cls=None) -> A Returns the invocation context from the interaction. This is a more low-level counter-part for :meth:`.process_application_commands` - to allow users more fine grained control over the processing. + to allow users more fine-grained control over the processing. Parameters ----------- @@ -953,7 +958,7 @@ async def get_autocomplete_context(self, interaction: Interaction, cls=None) -> Returns the autocomplete context from the interaction. This is a more low-level counter-part for :meth:`.process_application_commands` - to allow users more fine grained control over the processing. + to allow users more fine-grained control over the processing. Parameters ----------- @@ -1006,10 +1011,7 @@ def _bot(self) -> Union["Bot", "AutoShardedBot"]: class BotBase(ApplicationCommandMixin, CogMixin, ABC): _supports_prefixed_commands = False - # TODO I think def __init__(self, description=None, *args, **options): - # super(Client, self).__init__(*args, **kwargs) - # I replaced ^ with v and it worked super().__init__(*args, **options) self.extra_events = {} # TYPE: Dict[str, List[CoroFunc]] self.__cogs = {} # TYPE: Dict[str, Cog] @@ -1048,7 +1050,7 @@ async def on_application_command_error(self, context: ApplicationContext, except The default command error handler provided by the bot. - By default this prints to :data:`sys.stderr` however it could be + By default, this prints to :data:`sys.stderr` however it could be overridden to have a different implementation. This only fires if you do not specify any listeners for command error. @@ -1072,7 +1074,7 @@ async def on_application_command_error(self, context: ApplicationContext, except def check(self, func): """A decorator that adds a global check to the bot. A global check is similar to a :func:`.check` that is - applied on a per command basis except it is run before any command checks have been verified and applies to + applied on a per-command basis except it is run before any command checks have been verified and applies to every command the bot has. .. note:: @@ -1126,10 +1128,10 @@ def remove_check(self, func, *, call_once: bool = False) -> None: the :meth:`.Bot.add_check` call or using :meth:`.check_once`. """ - l = self._check_once if call_once else self._checks + checks = self._check_once if call_once else self._checks try: - l.remove(func) + checks.remove(func) except ValueError: pass @@ -1374,7 +1376,7 @@ class Bot(BotBase, Client): anything that you can do with a :class:`discord.Client` you can do with this bot. - This class also subclasses :class:`.ApplicationCommandMixin` to provide the functionality + This class also subclasses ``ApplicationCommandMixin`` to provide the functionality to manage commands. .. versionadded:: 2.0 @@ -1401,7 +1403,7 @@ class Bot(BotBase, Client): .. versionadded:: 2.0 auto_sync_commands: :class:`bool` - Whether or not to automatically sync slash commands. This will call sync_commands in on_connect, and in + Whether to automatically sync slash commands. This will call sync_commands in on_connect, and in :attr:`.process_application_commands` if the command is not found. Defaults to ``True``. .. versionadded:: 2.0 diff --git a/discord/channel.py b/discord/channel.py index 10ebb42f90..b1fac739cb 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -100,65 +100,6 @@ class _TextChannel(discord.abc.GuildChannel, Hashable): - """Represents a Discord text channel. - - .. container:: operations - - .. describe:: x == y - - Checks if two channels are equal. - - .. describe:: x != y - - Checks if two channels are not equal. - - .. describe:: hash(x) - - Returns the channel's hash. - - .. describe:: str(x) - - Returns the channel's name. - - Attributes - ----------- - name: :class:`str` - The channel name. - guild: :class:`Guild` - The guild the channel belongs to. - id: :class:`int` - The channel ID. - category_id: Optional[:class:`int`] - The category channel ID this channel belongs to, if applicable. - topic: Optional[:class:`str`] - The channel's topic. ``None`` if it doesn't exist. - position: Optional[:class:`int`] - The position in the channel list. This is a number that starts at 0. e.g. the - top channel is position 0. Can be ``None`` if the channel was received in an interaction. - last_message_id: Optional[:class:`int`] - The last message ID of the message sent to this channel. It may - *not* point to an existing or valid message. - slowmode_delay: :class:`int` - The number of seconds a member must wait between sending messages - in this channel. A value of `0` denotes that it is disabled. - Bots and users with :attr:`~Permissions.manage_channels` or - :attr:`~Permissions.manage_messages` bypass slowmode. - nsfw: :class:`bool` - If the channel is marked as "not safe for work". - - .. note:: - - To check if the channel or the guild of that channel are marked as NSFW, consider :meth:`is_nsfw` instead. - default_auto_archive_duration: :class:`int` - The default auto archive duration in minutes for threads created in this channel. - - .. versionadded:: 2.0 - flags: :class:`ChannelFlags` - Extra features of the channel. - - .. versionadded:: 2.0 - """ - __slots__ = ( "name", "id", @@ -329,9 +270,8 @@ async def edit(self, *, reason=None, **options): is only available to guilds that contain ``NEWS`` in :attr:`Guild.features`. reason: Optional[:class:`str`] The reason for editing this channel. Shows up on the audit log. - overwrites: :class:`Mapping` - A :class:`Mapping` of target (either a role or a member) to - :class:`PermissionOverwrite` to apply to the channel. + overwrites: Dict[Union[:class:`Role`, :class:`Member`, :class:`Snowflake`], :class:`PermissionOverwrite`] + The overwrites to apply to channel permissions. Useful for creating secret channels. default_auto_archive_duration: :class:`int` The new default auto archive duration in minutes for threads created in this channel. Must be one of ``60``, ``1440``, ``4320``, or ``10080``. @@ -706,6 +646,65 @@ def archived_threads( class TextChannel(discord.abc.Messageable, _TextChannel): + """Represents a Discord text channel. + + .. container:: operations + + .. describe:: x == y + + Checks if two channels are equal. + + .. describe:: x != y + + Checks if two channels are not equal. + + .. describe:: hash(x) + + Returns the channel's hash. + + .. describe:: str(x) + + Returns the channel's name. + + Attributes + ----------- + name: :class:`str` + The channel name. + guild: :class:`Guild` + The guild the channel belongs to. + id: :class:`int` + The channel ID. + category_id: Optional[:class:`int`] + The category channel ID this channel belongs to, if applicable. + topic: Optional[:class:`str`] + The channel's topic. ``None`` if it doesn't exist. + position: Optional[:class:`int`] + The position in the channel list. This is a number that starts at 0. e.g. the + top channel is position 0. Can be ``None`` if the channel was received in an interaction. + last_message_id: Optional[:class:`int`] + The last message ID of the message sent to this channel. It may + *not* point to an existing or valid message. + slowmode_delay: :class:`int` + The number of seconds a member must wait between sending messages + in this channel. A value of `0` denotes that it is disabled. + Bots and users with :attr:`~Permissions.manage_channels` or + :attr:`~Permissions.manage_messages` bypass slowmode. + nsfw: :class:`bool` + If the channel is marked as "not safe for work". + + .. note:: + + To check if the channel or the guild of that channel are marked as NSFW, consider :meth:`is_nsfw` instead. + default_auto_archive_duration: :class:`int` + The default auto archive duration in minutes for threads created in this channel. + + .. versionadded:: 2.0 + flags: :class:`ChannelFlags` + Extra features of the channel. + + .. versionadded:: 2.0 + """ + def __init__(self, *, state: ConnectionState, guild: Guild, data: TextChannelPayload): super().__init__(state=state, guild=guild, data=data) @@ -720,11 +719,12 @@ async def _get_channel(self) -> "TextChannel": return self def is_news(self) -> bool: - """:class:`bool`: Checks if the channel is a news/anouncements channel.""" + """:class:`bool`: Checks if the channel is a news/announcements channel.""" return self._type == ChannelType.news.value @property def news(self) -> bool: + """Equivalent to :meth:`is_news`.""" return self.is_news() async def create_thread( @@ -759,7 +759,7 @@ async def create_thread( type: Optional[:class:`ChannelType`] The type of thread to create. If a ``message`` is passed then this parameter is ignored, as a thread created with a message is always a public thread. - By default this creates a private thread if this is ``None``. + By default, this creates a private thread if this is ``None``. reason: :class:`str` The reason for creating a new thread. Shows up on the audit log. @@ -800,6 +800,69 @@ async def create_thread( class ForumChannel(_TextChannel): + """Represents a Discord forum channel. + + .. container:: operations + + .. describe:: x == y + + Checks if two channels are equal. + + .. describe:: x != y + + Checks if two channels are not equal. + + .. describe:: hash(x) + + Returns the channel's hash. + + .. describe:: str(x) + + Returns the channel's name. + + Attributes + ----------- + name: :class:`str` + The channel name. + guild: :class:`Guild` + The guild the channel belongs to. + id: :class:`int` + The channel ID. + category_id: Optional[:class:`int`] + The category channel ID this channel belongs to, if applicable. + topic: Optional[:class:`str`] + The channel's topic. ``None`` if it doesn't exist. + + .. note:: + + :attr:`guidelines` exists as an alternative to this attribute. + position: Optional[:class:`int`] + The position in the channel list. This is a number that starts at 0. e.g. the + top channel is position 0. Can be ``None`` if the channel was received in an interaction. + last_message_id: Optional[:class:`int`] + The last message ID of the message sent to this channel. It may + *not* point to an existing or valid message. + slowmode_delay: :class:`int` + The number of seconds a member must wait between sending messages + in this channel. A value of `0` denotes that it is disabled. + Bots and users with :attr:`~Permissions.manage_channels` or + :attr:`~Permissions.manage_messages` bypass slowmode. + nsfw: :class:`bool` + If the channel is marked as "not safe for work". + + .. note:: + + To check if the channel or the guild of that channel are marked as NSFW, consider :meth:`is_nsfw` instead. + default_auto_archive_duration: :class:`int` + The default auto archive duration in minutes for threads created in this channel. + + .. versionadded:: 2.0 + flags: :class:`ChannelFlags` + Extra features of the channel. + + .. versionadded:: 2.0 + """ + def __init__(self, *, state: ConnectionState, guild: Guild, data: ForumChannelPayload): super().__init__(state=state, guild=guild, data=data) @@ -846,10 +909,16 @@ async def create_thread( The content of the message to send. embed: :class:`~discord.Embed` The rich embed for the content. + embeds: List[:class:`~discord.Embed`] + A list of embeds to upload. Must be a maximum of 10. file: :class:`~discord.File` The file to upload. files: List[:class:`~discord.File`] A list of files to upload. Must be a maximum of 10. + stickers: Sequence[Union[:class:`~discord.GuildSticker`, :class:`~discord.StickerItem`]] + A list of stickers to upload. Must be a maximum of 3. + delete_message_after: :class:`int` + The time to wait before deleting the thread. nonce: :class:`int` The nonce to use for sending this message. If the message was successfully sent, then the message will have a nonce with this value. @@ -862,10 +931,6 @@ async def create_thread( are used instead. view: :class:`discord.ui.View` A Discord UI View to add to the message. - embeds: List[:class:`~discord.Embed`] - A list of embeds to upload. Must be a maximum of 10. - stickers: Sequence[Union[:class:`~discord.GuildSticker`, :class:`~discord.StickerItem`]] - A list of stickers to upload. Must be a maximum of 3. auto_archive_duration: :class:`int` The duration in minutes before a thread is automatically archived for inactivity. If not provided, the channel's default auto archive duration is used. @@ -894,7 +959,7 @@ async def create_thread( message_content = str(content) if content is not None else None if embed is not None and embeds is not None: - raise InvalidArgument("cannot pass both embed and embeds parameter to create_post()") + raise InvalidArgument("cannot pass both embed and embeds parameter to create_thread()") if embed is not None: embed = embed.to_dict() @@ -1025,18 +1090,22 @@ def _get_voice_state_pair(self) -> Tuple[int, int]: return self.guild.id, self.id def _update(self, guild: Guild, data: Union[VoiceChannelPayload, StageChannelPayload]) -> None: + # This data will always exist self.guild = guild self.name: str = data["name"] - rtc = data.get("rtc_region") - self.rtc_region: Optional[VoiceRegion] = try_enum(VoiceRegion, rtc) if rtc is not None else None - self.video_quality_mode: VideoQualityMode = try_enum(VideoQualityMode, data.get("video_quality_mode", 1)) self.category_id: Optional[int] = utils._get_as_snowflake(data, "parent_id") - self.last_message_id: Optional[int] = utils._get_as_snowflake(data, "last_message_id") - self.position: int = data.get("position") - self.bitrate: int = data.get("bitrate") - self.user_limit: int = data.get("user_limit") - self.flags: ChannelFlags = ChannelFlags._from_value(data.get("flags", 0)) - self._fill_overwrites(data) + + # This data may be missing depending on how this object is being created/updated + if not data.pop("_invoke_flag", False): + rtc = data.get("rtc_region") + self.rtc_region: Optional[VoiceRegion] = try_enum(VoiceRegion, rtc) if rtc is not None else None + self.video_quality_mode: VideoQualityMode = try_enum(VideoQualityMode, data.get("video_quality_mode", 1)) + self.last_message_id: Optional[int] = utils._get_as_snowflake(data, "last_message_id") + self.position: int = data.get("position") + self.bitrate: int = data.get("bitrate") + self.user_limit: int = data.get("user_limit") + self.flags: ChannelFlags = ChannelFlags._from_value(data.get("flags", 0)) + self._fill_overwrites(data) @property def _sorting_bucket(self) -> int: @@ -1079,7 +1148,7 @@ def voice_states(self) -> Dict[int, VoiceState]: def permissions_for(self, obj: Union[Member, Role], /) -> Permissions: base = super().permissions_for(obj) - # voice channels cannot be edited by people who can't connect to them + # Voice channels cannot be edited by people who can't connect to them. # It also implicitly denies all other voice perms if not base.connect: denied = Permissions.voice() @@ -1480,9 +1549,8 @@ async def edit(self, *, reason=None, **options): category. reason: Optional[:class:`str`] The reason for editing this channel. Shows up on the audit log. - overwrites: :class:`Mapping` - A :class:`Mapping` of target (either a role or a member) to - :class:`PermissionOverwrite` to apply to the channel. + overwrites: Dict[Union[:class:`Role`, :class:`Member`, :class:`Snowflake`], :class:`PermissionOverwrite`] + The overwrites to apply to channel permissions. Useful for creating secret channels. rtc_region: Optional[:class:`VoiceRegion`] The new region for the voice channel's voice communication. A value of ``None`` indicates automatic voice region detection. @@ -1760,9 +1828,9 @@ async def fetch_instance(self) -> StageInstance: Raises ------- - :exc:`.NotFound` + NotFound The stage instance or channel could not be found. - :exc:`.HTTPException` + HTTPException Getting the stage instance failed. Returns @@ -1821,9 +1889,8 @@ async def edit(self, *, reason=None, **options): category. reason: Optional[:class:`str`] The reason for editing this channel. Shows up on the audit log. - overwrites: :class:`Mapping` - A :class:`Mapping` of target (either a role or a member) to - :class:`PermissionOverwrite` to apply to the channel. + overwrites: Dict[Union[:class:`Role`, :class:`Member`, :class:`Snowflake`], :class:`PermissionOverwrite`] + The overwrites to apply to channel permissions. Useful for creating secret channels. rtc_region: Optional[:class:`VoiceRegion`] The new region for the stage channel's voice communication. A value of ``None`` indicates automatic voice region detection. @@ -1921,13 +1988,17 @@ def __repr__(self) -> str: return f"" def _update(self, guild: Guild, data: CategoryChannelPayload) -> None: + # This data will always exist self.guild: Guild = guild self.name: str = data["name"] self.category_id: Optional[int] = utils._get_as_snowflake(data, "parent_id") - self.nsfw: bool = data.get("nsfw", False) - self.position: int = data.get("position") - self.flags: ChannelFlags = ChannelFlags._from_value(data.get("flags", 0)) - self._fill_overwrites(data) + + # This data may be missing depending on how this object is being created/updated + if not data.pop("_invoke_flag", False): + self.nsfw: bool = data.get("nsfw", False) + self.position: int = data.get("position") + self.flags: ChannelFlags = ChannelFlags._from_value(data.get("flags", 0)) + self._fill_overwrites(data) @property def _sorting_bucket(self) -> int: @@ -1986,9 +2057,8 @@ async def edit(self, *, reason=None, **options): To mark the category as NSFW or not. reason: Optional[:class:`str`] The reason for editing this category. Shows up on the audit log. - overwrites: :class:`Mapping` - A :class:`Mapping` of target (either a role or a member) to - :class:`PermissionOverwrite` to apply to the channel. + overwrites: Dict[Union[:class:`Role`, :class:`Member`, :class:`Snowflake`], :class:`PermissionOverwrite`] + The overwrites to apply to channel permissions. Useful for creating secret channels. Raises ------ @@ -2024,7 +2094,7 @@ def channels(self) -> List[GuildChannelType]: """ def comparator(channel): - return (not isinstance(channel, _TextChannel), channel.position) + return not isinstance(channel, _TextChannel), (channel.position or -1) ret = [c for c in self.guild.channels if c.category_id == self.id] ret.sort(key=comparator) @@ -2034,14 +2104,14 @@ def comparator(channel): def text_channels(self) -> List[TextChannel]: """List[:class:`TextChannel`]: Returns the text channels that are under this category.""" ret = [c for c in self.guild.channels if c.category_id == self.id and isinstance(c, TextChannel)] - ret.sort(key=lambda c: (c.position, c.id)) + ret.sort(key=lambda c: (c.position or -1, c.id)) return ret @property def voice_channels(self) -> List[VoiceChannel]: """List[:class:`VoiceChannel`]: Returns the voice channels that are under this category.""" ret = [c for c in self.guild.channels if c.category_id == self.id and isinstance(c, VoiceChannel)] - ret.sort(key=lambda c: (c.position, c.id)) + ret.sort(key=lambda c: (c.position or -1, c.id)) return ret @property @@ -2051,7 +2121,7 @@ def stage_channels(self) -> List[StageChannel]: .. versionadded:: 1.7 """ ret = [c for c in self.guild.channels if c.category_id == self.id and isinstance(c, StageChannel)] - ret.sort(key=lambda c: (c.position, c.id)) + ret.sort(key=lambda c: (c.position or -1, c.id)) return ret @property @@ -2061,7 +2131,7 @@ def forum_channels(self) -> List[ForumChannel]: .. versionadded:: 2.0 """ ret = [c for c in self.guild.channels if c.category_id == self.id and isinstance(c, ForumChannel)] - ret.sort(key=lambda c: (c.position, c.id)) + ret.sort(key=lambda c: (c.position or -1, c.id)) return ret async def create_text_channel(self, name: str, **options: Any) -> TextChannel: diff --git a/discord/client.py b/discord/client.py index 584fa3549e..71a43d56bf 100644 --- a/discord/client.py +++ b/discord/client.py @@ -49,7 +49,7 @@ from . import utils from .activity import ActivityTypes, BaseActivity, create_activity -from .appinfo import AppInfo +from .appinfo import AppInfo, PartialAppInfo from .backoff import ExponentialBackoff from .channel import PartialMessageable, _threaded_channel_factory from .emoji import Emoji @@ -212,7 +212,7 @@ class Client: Attributes ----------- ws - The websocket gateway the client is currently connected to. Could be ``None``. + The WebSocket gateway the client is currently connected to. Could be ``None``. loop: :class:`asyncio.AbstractEventLoop` The event loop that the client uses for asynchronous operations. """ @@ -286,7 +286,7 @@ def latency(self) -> float: return float("nan") if not ws else ws.latency def is_ws_ratelimited(self) -> bool: - """:class:`bool`: Whether the websocket is currently rate limited. + """:class:`bool`: Whether the WebSocket is currently rate limited. This can be useful to know when deciding whether you should query members using HTTP or via the gateway. @@ -444,7 +444,7 @@ async def on_error(self, event_method: str, *args: Any, **kwargs: Any) -> None: The default error handler provided by the client. - By default this prints to :data:`sys.stderr` however it could be + By default, this prints to :data:`sys.stderr` however it could be overridden to have a different implementation. Check :func:`~discord.on_error` for more details. """ @@ -517,7 +517,7 @@ async def login(self, token: str) -> None: async def connect(self, *, reconnect: bool = True) -> None: """|coro| - Creates a websocket connection and lets the websocket listen + Creates a WebSocket connection and lets the WebSocket listen to messages from Discord. This is a loop that runs the entire event system and miscellaneous aspects of the library. Control is not resumed until the WebSocket connection is terminated. @@ -533,10 +533,10 @@ async def connect(self, *, reconnect: bool = True) -> None: Raises ------- :exc:`GatewayNotFound` - If the gateway to connect to Discord is not found. Usually if this + The gateway to connect to Discord is not found. Usually if this is thrown then there is a Discord API outage. :exc:`ConnectionClosed` - The websocket connection has been terminated. + The WebSocket connection has been terminated. """ backoff = ExponentialBackoff() @@ -592,7 +592,7 @@ async def connect(self, *, reconnect: bool = True) -> None: # We should only get this when an unhandled close code happens, # such as a clean disconnect (1000) or a bad state (bad token, no sharding, etc) - # sometimes, discord sends us 1000 for unknown reasons so we should reconnect + # sometimes, discord sends us 1000 for unknown reasons, so we should reconnect # regardless and rely on is_closed instead if isinstance(exc, ConnectionClosed): if exc.code == 4014: @@ -720,7 +720,7 @@ def stop_loop_on_completion(f): # properties def is_closed(self) -> bool: - """:class:`bool`: Indicates if the websocket connection is closed.""" + """:class:`bool`: Indicates if the WebSocket connection is closed.""" return self._closed @property @@ -790,6 +790,30 @@ def users(self) -> List[User]: """List[:class:`~discord.User`]: Returns a list of all the users the bot can see.""" return list(self._connection._users.values()) + async def fetch_application(self, application_id: int, /) -> PartialAppInfo: + """|coro| + Retrieves a :class:`.PartialAppInfo` from an application ID. + + Parameters + ----------- + application_id: :class:`int` + The application ID to retrieve information from. + + Raises + ------- + NotFound + An application with this ID does not exist. + HTTPException + Retrieving the application failed. + + Returns + -------- + :class:`.PartialAppInfo` + The application information. + """ + data = await self.http.get_application(application_id) + return PartialAppInfo(state=self._connection, data=data) + def get_channel(self, id: int, /) -> Optional[Union[GuildChannel, Thread, PrivateChannel]]: """Returns a channel or thread with the given ID. @@ -1073,7 +1097,7 @@ def check(reaction, user): Raises ------- asyncio.TimeoutError - If a timeout is provided and it was reached. + Raised if a timeout is provided and reached. Returns -------- @@ -1542,7 +1566,8 @@ async def fetch_user(self, user_id: int, /) -> User: .. note:: - This method is an API call. If you have :attr:`discord.Intents.members` and member cache enabled, consider :meth:`get_user` instead. + This method is an API call. If you have :attr:`discord.Intents.members` and member cache enabled, + consider :meth:`get_user` instead. Parameters ----------- @@ -1722,7 +1747,7 @@ def add_view(self, view: View, *, message_id: Optional[int] = None) -> None: A view was not passed. ValueError The view is not persistent. A persistent view has no timeout - and all their components have an explicitly provided custom_id. + and all their components have an explicitly provided ``custom_id``. """ if not isinstance(view, View): diff --git a/discord/cog.py b/discord/cog.py index 5844c46197..f91ab5db78 100644 --- a/discord/cog.py +++ b/discord/cog.py @@ -43,6 +43,7 @@ Type, TypeVar, Union, + overload, ) import discord.utils @@ -131,8 +132,8 @@ async def bar(self, ctx): pass # hidden -> False guild_ids: Optional[List[:class:`int`]] - A shortcut to command_attrs, what guild_ids should all application commands have - in the cog. You can override this by setting guild_ids per command. + A shortcut to :attr:`.command_attrs`, what ``guild_ids`` should all application commands have + in the cog. You can override this by setting ``guild_ids`` per command. .. versionadded:: 2.0 """ @@ -224,7 +225,7 @@ def __new__(cls: Type[CogMeta], *args: Any, **kwargs: Any) -> CogMeta: listeners_as_list = [] for listener in listeners.values(): for listener_name in listener.__cog_listener_names__: - # I use __name__ instead of just storing the value so I can inject + # I use __name__ instead of just storing the value, so I can inject # the self attribute when the time comes to add them to the bot listeners_as_list.append((listener_name, listener.__name__)) @@ -240,12 +241,9 @@ def __new__(cls: Type[CogMeta], *args: Any, **kwargs: Any) -> CogMeta: # Update the Command instances dynamically as well for command in new_cls.__cog_commands__: - if ( - isinstance(command, ApplicationCommand) - and command.guild_ids is None - and len(new_cls.__cog_guild_ids__) != 0 - ): + if isinstance(command, ApplicationCommand) and not command.guild_ids and new_cls.__cog_guild_ids__: command.guild_ids = new_cls.__cog_guild_ids__ + if not isinstance(command, SlashCommandGroup): setattr(new_cls, command.callback.__name__, command) parent = command.parent @@ -347,7 +345,7 @@ def get_listeners(self) -> List[Tuple[str, Callable[..., Any]]]: @classmethod def _get_overridden_method(cls, method: FuncT) -> Optional[FuncT]: - """Return None if the method is not overridden. Otherwise returns the overridden method.""" + """Return None if the method is not overridden. Otherwise, returns the overridden method.""" return getattr(getattr(method, "__func__", method), "__cog_special_method__", method) @classmethod @@ -416,7 +414,12 @@ def bot_check_once(self, ctx: ApplicationContext) -> bool: check. This function **can** be a coroutine and must take a sole parameter, - ``ctx``, to represent the :class:`.Context`. + ``ctx``, to represent the :class:`.Context` or :class:`.ApplicationContext`. + + Parameters + ----------- + ctx: :class:`.Context` + The invocation context. """ return True @@ -426,7 +429,12 @@ def bot_check(self, ctx: ApplicationContext) -> bool: check. This function **can** be a coroutine and must take a sole parameter, - ``ctx``, to represent the :class:`.Context`. + ``ctx``, to represent the :class:`.Context` or :class:`.ApplicationContext`. + + Parameters + ----------- + ctx: :class:`.Context` + The invocation context. """ return True @@ -436,7 +444,12 @@ def cog_check(self, ctx: ApplicationContext) -> bool: for every command and subcommand in this cog. This function **can** be a coroutine and must take a sole parameter, - ``ctx``, to represent the :class:`.Context`. + ``ctx``, to represent the :class:`.Context` or :class:`.ApplicationContext`. + + Parameters + ----------- + ctx: :class:`.Context` + The invocation context. """ return True @@ -452,7 +465,7 @@ async def cog_command_error(self, ctx: ApplicationContext, error: Exception) -> Parameters ----------- - ctx: :class:`.Context` + ctx: :class:`.ApplicationContext` The invocation context where the error happened. error: :class:`ApplicationCommandError` The error that happened. @@ -469,7 +482,7 @@ async def cog_before_invoke(self, ctx: ApplicationContext) -> None: Parameters ----------- - ctx: :class:`.Context` + ctx: :class:`.ApplicationContext` The invocation context. """ pass @@ -484,7 +497,7 @@ async def cog_after_invoke(self, ctx: ApplicationContext) -> None: Parameters ----------- - ctx: :class:`.Context` + ctx: :class:`.ApplicationContext` The invocation context. """ pass @@ -664,9 +677,9 @@ def cogs(self) -> Mapping[str, Cog]: def _remove_module_references(self, name: str) -> None: # find all references to the module # remove the cogs registered from the module - for cogname, cog in self.__cogs.copy().items(): + for cog_name, cog in self.__cogs.copy().items(): if _is_submodule(name, cog.__module__): - self.remove_cog(cogname) + self.remove_cog(cog_name) # remove all the commands from the module if self._supports_prefixed_commands: @@ -742,13 +755,29 @@ def _resolve_name(self, name: str, package: Optional[str]) -> str: except ImportError: raise errors.ExtensionNotFound(name) + @overload + def load_extension( + self, + name: str, + *, + package: Optional[str] = None, + recursive: bool = False, + ) -> List[str]: + ... + + @overload def load_extension( self, name: str, *, package: Optional[str] = None, recursive: bool = False, - store: bool = True, + store: bool = False, + ) -> Optional[Union[Dict[str, Union[Exception, bool]], List[str]]]: + ... + + def load_extension( + self, name, *, package = None, recursive = False, store = False ) -> Optional[Union[Dict[str, Union[Exception, bool]], List[str]]]: """Loads an extension. @@ -766,7 +795,7 @@ def load_extension( ----------- name: :class:`str` The extension or folder name to load. It must be dot separated - like regular Python imports if accessing a sub-module. e.g. + like regular Python imports if accessing a submodule. e.g. ``foo.test`` if you want to import ``foo/test.py``. package: Optional[:class:`str`] The package name to resolve relative imports with. @@ -788,7 +817,7 @@ def load_extension( encountered they will be raised and the bot will be closed. If no exceptions are encountered, a list of loaded extension names will be returned. - Defaults to ``True``. + Defaults to ``False``. .. versionadded:: 2.0 @@ -850,7 +879,7 @@ def load_extension( parts = list(ext_file.parts[:-1]) # Gets the file name without the extension parts.append(ext_file.stem) - loaded = self.load_extension(".".join(parts)) + loaded = self.load_extension(".".join(parts), package=package, recursive=recursive, store=store) final_out.update(loaded) if store else final_out.extend(loaded) if isinstance(final_out, Exception): @@ -858,12 +887,27 @@ def load_extension( else: return final_out + @overload def load_extensions( self, *names: str, package: Optional[str] = None, recursive: bool = False, - store: bool = True, + ) -> List[str]: + ... + + @overload + def load_extensions( + self, + *names: str, + package: Optional[str] = None, + recursive: bool = False, + store: bool = False, + ) -> Optional[Union[Dict[str, Union[Exception, bool]], List[str]]]: + ... + + def load_extensions( + self, *names, package = None, recursive = False, store = False ) -> Optional[Union[Dict[str, Union[Exception, bool]], List[str]]]: """Loads multiple extensions at once. @@ -874,7 +918,7 @@ def load_extensions( ----------- names: :class:`str` The extension or folder names to load. It must be dot separated - like regular Python imports if accessing a sub-module. e.g. + like regular Python imports if accessing a submodule. e.g. ``foo.test`` if you want to import ``foo/test.py``. package: Optional[:class:`str`] The package name to resolve relative imports with. @@ -896,7 +940,7 @@ def load_extensions( encountered they will be raised and the bot will be closed. If no exceptions are encountered, a list of loaded extension names will be returned. - Defaults to ``True``. + Defaults to ``False``. .. versionadded:: 2.0 @@ -947,7 +991,7 @@ def unload_extension(self, name: str, *, package: Optional[str] = None) -> None: ------------ name: :class:`str` The extension name to unload. It must be dot separated like - regular Python imports if accessing a sub-module. e.g. + regular Python imports if accessing a submodule. e.g. ``foo.test`` if you want to import ``foo/test.py``. package: Optional[:class:`str`] The package name to resolve relative imports with. @@ -979,13 +1023,13 @@ def reload_extension(self, name: str, *, package: Optional[str] = None) -> None: This replaces the extension with the same extension, only refreshed. This is equivalent to a :meth:`unload_extension` followed by a :meth:`load_extension` except done in an atomic way. That is, if an operation fails mid-reload then - the bot will roll-back to the prior working state. + the bot will roll back to the prior working state. Parameters ------------ name: :class:`str` The extension name to reload. It must be dot separated like - regular Python imports if accessing a sub-module. e.g. + regular Python imports if accessing a submodule. e.g. ``foo.test`` if you want to import ``foo/test.py``. package: Optional[:class:`str`] The package name to resolve relative imports with. diff --git a/discord/colour.py b/discord/colour.py index 010c8cfa63..2c5a9f267f 100644 --- a/discord/colour.py +++ b/discord/colour.py @@ -115,7 +115,7 @@ def b(self) -> int: def to_rgb(self) -> Tuple[int, int, int]: """Tuple[:class:`int`, :class:`int`, :class:`int`]: Returns an (r, g, b) tuple representing the colour.""" - return (self.r, self.g, self.b) + return self.r, self.g, self.b @classmethod def from_rgb(cls: Type[CT], r: int, g: int, b: int) -> CT: @@ -332,10 +332,12 @@ def nitro_pink(cls: Type[CT]) -> CT: @classmethod def embed_background(cls: Type[CT], theme: str = "dark") -> CT: - """A factory method that returns a :class:`Color` corresponding to the embed colors on discord clients, with a value of - ``0x2F3136`` (dark) - ``0xf2f3f5`` (light) - ``0x000000`` (amoled). + """A factory method that returns a :class:`Color` corresponding to the + embed colors on discord clients, with a value of: + + - ``0x2F3136`` (dark) + - ``0xf2f3f5`` (light) + - ``0x000000`` (amoled). .. versionadded:: 2.0 diff --git a/discord/commands/context.py b/discord/commands/context.py index b3576c32d7..ec2991a75c 100644 --- a/discord/commands/context.py +++ b/discord/commands/context.py @@ -24,7 +24,7 @@ """ from __future__ import annotations -from typing import TYPE_CHECKING, Dict, List, Optional, TypeVar, Union +from typing import TYPE_CHECKING, Any, Dict, List, Optional, TypeVar, Union import discord.abc from discord.interactions import InteractionMessage, InteractionResponse, Interaction @@ -144,27 +144,37 @@ def channel(self) -> Optional[InteractionChannel]: @cached_property def channel_id(self) -> Optional[int]: - """:class:`int`: Returns the ID of the channel associated with this context's command. Shorthand for :attr:`.Interaction.channel.id`.""" + """:class:`int`: Returns the ID of the channel associated with this context's command. + Shorthand for :attr:`.Interaction.channel_id`. + """ return self.interaction.channel_id @cached_property def guild(self) -> Optional[Guild]: - """Optional[:class:`.Guild`]: Returns the guild associated with this context's command. Shorthand for :attr:`.Interaction.guild`.""" + """Optional[:class:`.Guild`]: Returns the guild associated with this context's command. + Shorthand for :attr:`.Interaction.guild`. + """ return self.interaction.guild @cached_property def guild_id(self) -> Optional[int]: - """:class:`int`: Returns the ID of the guild associated with this context's command. Shorthand for :attr:`.Interaction.guild.id`.""" + """:class:`int`: Returns the ID of the guild associated with this context's command. + Shorthand for :attr:`.Interaction.guild_id`. + """ return self.interaction.guild_id @cached_property def locale(self) -> Optional[str]: - """:class:`str`: Returns the locale of the guild associated with this context's command. Shorthand for :attr:`.Interaction.locale`.""" + """:class:`str`: Returns the locale of the guild associated with this context's command. + Shorthand for :attr:`.Interaction.locale`. + """ return self.interaction.locale @cached_property def guild_locale(self) -> Optional[str]: - """:class:`str`: Returns the locale of the guild associated with this context's command. Shorthand for :attr:`.Interaction.guild_locale`.""" + """:class:`str`: Returns the locale of the guild associated with this context's command. + Shorthand for :attr:`.Interaction.guild_locale`. + """ return self.interaction.guild_locale @cached_property @@ -181,19 +191,25 @@ def me(self) -> Optional[Union[Member, ClientUser]]: @cached_property def message(self) -> Optional[Message]: - """Optional[:class:`.Message`]: Returns the message sent with this context's command. Shorthand for :attr:`.Interaction.message`, if applicable.""" + """Optional[:class:`.Message`]: Returns the message sent with this context's command. + Shorthand for :attr:`.Interaction.message`, if applicable. + """ return self.interaction.message @cached_property def user(self) -> Optional[Union[Member, User]]: - """Union[:class:`.Member`, :class:`.User`]: Returns the user that sent this context's command. Shorthand for :attr:`.Interaction.user`.""" + """Union[:class:`.Member`, :class:`.User`]: Returns the user that sent this context's command. + Shorthand for :attr:`.Interaction.user`. + """ return self.interaction.user author: Optional[Union[Member, User]] = user @property def voice_client(self) -> Optional[VoiceProtocol]: - """Optional[:class:`.VoiceProtocol`]: Returns the voice client associated with this context's command. Shorthand for :attr:`.Interaction.guild.voice_client`, if applicable.""" + """Optional[:class:`.VoiceProtocol`]: Returns the voice client associated with this context's command. + Shorthand for :attr:`Interaction.guild.voice_client<~discord.Guild.voice_client>`, if applicable. + """ if self.interaction.guild is None: return None @@ -201,18 +217,20 @@ def voice_client(self) -> Optional[VoiceProtocol]: @cached_property def response(self) -> InteractionResponse: - """:class:`.InteractionResponse`: Returns the response object associated with this context's command. Shorthand for :attr:`.Interaction.response`.""" + """:class:`.InteractionResponse`: Returns the response object associated with this context's command. + Shorthand for :attr:`.Interaction.response`.""" return self.interaction.response @property - def selected_options(self) -> Optional[List[Dict]]: + def selected_options(self) -> Optional[List[Dict[str, Any]]]: """The options and values that were selected by the user when sending the command. Returns ------- - Optional[List[Dict]] - A dictionary containing the options and values that were selected by the user when the command was processed, if applicable. - Returns ``None`` if the command has not yet been invoked, or if there are no options defined for that command. + Optional[List[Dict[:class:`str`, Any]]] + A dictionary containing the options and values that were selected by the user when the command + was processed, if applicable. Returns ``None`` if the command has not yet been invoked, + or if there are no options defined for that command. """ return self.interaction.data.get("options", None) @@ -245,7 +263,7 @@ def send_modal(self) -> Callable[..., Awaitable[Interaction]]: async def respond(self, *args, **kwargs) -> Union[Interaction, WebhookMessage]: """|coro| - Sends either a response or a message using the followup webhook depending determined by whether the interaction + Sends either a response or a message using the followup webhook determined by whether the interaction has been responded to or not. Returns @@ -288,7 +306,7 @@ def defer(self) -> Callable[..., Awaitable[None]]: @property def followup(self) -> Webhook: - """:class:`Webhook`: Returns the follow up webhook for follow up interactions.""" + """:class:`Webhook`: Returns the followup webhook for followup interactions.""" return self.interaction.followup async def delete(self, *, delay: Optional[float] = None) -> None: @@ -322,7 +340,9 @@ def edit(self) -> Callable[..., Awaitable[InteractionMessage]]: @property def cog(self) -> Optional[Cog]: - """Optional[:class:`.Cog`]: Returns the cog associated with this context's command. ``None`` if it does not exist.""" + """Optional[:class:`.Cog`]: Returns the cog associated with this context's command. + ``None`` if it does not exist. + """ if self.command is None: return None @@ -332,7 +352,7 @@ def cog(self) -> Optional[Cog]: class AutocompleteContext: """Represents context for a slash command's option autocomplete. - This class is not created manually and is instead passed to an Option's autocomplete callback. + This class is not created manually and is instead passed to an :class:`.Option`'s autocomplete callback. .. versionadded:: 2.0 @@ -348,7 +368,7 @@ class AutocompleteContext: The option the user is currently typing. value: :class:`.str` The content of the focused option. - options: :class:`.dict` + options: Dict[:class:`str`, Any] A name to value mapping of the options that the user has selected before this option. """ @@ -365,7 +385,9 @@ def __init__(self, bot: Bot, interaction: Interaction): @property def cog(self) -> Optional[Cog]: - """Optional[:class:`.Cog`]: Returns the cog associated with this context's command. ``None`` if it does not exist.""" + """Optional[:class:`.Cog`]: Returns the cog associated with this context's command. + ``None`` if it does not exist. + """ if self.command is None: return None diff --git a/discord/commands/core.py b/discord/commands/core.py index 04e148817d..71398223f6 100644 --- a/discord/commands/core.py +++ b/discord/commands/core.py @@ -63,7 +63,7 @@ from ..role import Role from ..threads import Thread from ..user import User -from ..utils import async_all, find, utcnow +from ..utils import async_all, find, utcnow, maybe_coroutine, MISSING from .context import ApplicationContext, AutocompleteContext from .options import Option, OptionChoice @@ -186,6 +186,7 @@ def __init__(self, func: Callable, **kwargs) -> None: buckets = cooldown else: raise TypeError("Cooldown must be a an instance of CooldownMapping or None.") + self._buckets: CooldownMapping = buckets max_concurrency = getattr(func, "__commands_max_concurrency__", kwargs.get("max_concurrency")) @@ -221,7 +222,7 @@ def __eq__(self, other) -> bool: if getattr(self, "id", None) is not None and getattr(other, "id", None) is not None: check = self.id == other.id else: - check = self.name == other.name and self.guild_ids == self.guild_ids + check = self.name == other.name and self.guild_ids == other.guild_ids return isinstance(other, self.__class__) and self.parent == other.parent and check async def __call__(self, ctx, *args, **kwargs): @@ -300,7 +301,7 @@ def is_on_cooldown(self, ctx: ApplicationContext) -> bool: Parameters ----------- ctx: :class:`.ApplicationContext` - The invocation context to use when checking the commands cooldown status. + The invocation context to use when checking the command's cooldown status. Returns -------- @@ -364,9 +365,17 @@ async def can_run(self, ctx: ApplicationContext) -> bool: predicates = self.checks if self.parent is not None: - # parent checks should be ran first + # parent checks should be run first predicates = self.parent.checks + predicates + cog = self.cog + if cog is not None: + local_check = cog._get_overridden_method(cog.cog_check) + if local_check is not None: + ret = await maybe_coroutine(local_check, ctx) + if not ret: + return False + if not predicates: # since we have no checks, then we just return True. return True @@ -586,6 +595,8 @@ class SlashCommand(ApplicationCommand): parent: Optional[:class:`SlashCommandGroup`] The parent group that this command belongs to. ``None`` if there isn't one. + mention: :class:`str` + Returns a string that allows you to mention the slash command. guild_only: :class:`bool` Whether the command should only be usable inside a guild. default_member_permissions: :class:`~discord.Permissions` @@ -636,13 +647,7 @@ def __init__(self, func: Callable, *args, **kwargs) -> None: self.attached_to_group: bool = False - self.cog = None - - params = self._get_signature_parameters() - if kwop := kwargs.get("options", None): - self.options: List[Option] = self._match_option_param_names(params, kwop) - else: - self.options: List[Option] = self._parse_options(params) + self.options: List[Option] = kwargs.get("options", []) try: checks = func.__commands_checks__ @@ -655,13 +660,21 @@ def __init__(self, func: Callable, *args, **kwargs) -> None: self._before_invoke = None self._after_invoke = None + self._cog = MISSING + + def _validate_parameters(self): + params = self._get_signature_parameters() + if kwop := self.options: + self.options: List[Option] = self._match_option_param_names(params, kwop) + else: + self.options: List[Option] = self._parse_options(params) + def _check_required_params(self, params): params = iter(params.items()) 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: @@ -684,7 +697,7 @@ 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], required=False) + option = Option(option.__args__[0], default=None) else: option = Option(option.__args__) @@ -754,10 +767,23 @@ 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 + @property + def cog(self): + return self._cog + + @cog.setter + def cog(self, val): + self._cog = val + self._validate_parameters() + @property def is_subcommand(self) -> bool: return self.parent is not None - + + @property + def mention(self) -> str: + return f"" + def to_dict(self) -> Dict: as_dict = { "name": self.name, @@ -848,9 +874,10 @@ async def _invoke(self, ctx: ApplicationContext) -> None: elif op.input_type == SlashCommandOptionType.string and (converter := op.converter) is not None: from discord.ext.commands import Converter if isinstance(converter, Converter): - arg = await converter.convert(ctx, arg) - elif isinstance(converter, type) and hasattr(converter, "convert"): - arg = await converter().convert(ctx, arg) + if isinstance(converter, type): + arg = await converter().convert(ctx, arg) + else: + arg = await converter.convert(ctx, arg) elif op._raw_type in (SlashCommandOptionType.integer, SlashCommandOptionType.number, @@ -941,6 +968,10 @@ def _update_copy(self, kwargs: Dict[str, Any]): else: return self.copy() + def _set_cog(self, cog): + super()._set_cog(cog) + self._validate_parameters() + class SlashCommandGroup(ApplicationCommand): r"""A class that implements the protocol for a slash command group. @@ -1010,10 +1041,10 @@ def __init__( parent: Optional[SlashCommandGroup] = None, **kwargs, ) -> None: - validate_chat_input_name(name) - validate_chat_input_description(description) self.name = str(name) - self.description = description + self.description = description or "No description provided" + validate_chat_input_name(self.name) + validate_chat_input_description(self.description) self.input_type = SlashCommandOptionType.sub_command_group self.subcommands: List[Union[SlashCommand, SlashCommandGroup]] = self.__initial_commands__ self.guild_ids = guild_ids @@ -1059,9 +1090,9 @@ def to_dict(self) -> Dict: return as_dict - def command(self, **kwargs) -> Callable[[Callable], SlashCommand]: - def wrap(func) -> SlashCommand: - command = SlashCommand(func, parent=self, **kwargs) + def command(self, cls: Type[T] = SlashCommand, **kwargs) -> Callable[[Callable], SlashCommand]: + def wrap(func) -> T: + command = cls(func, parent=self, **kwargs) self.subcommands.append(command) return command @@ -1288,8 +1319,7 @@ def __init__(self, func: Callable, *args, **kwargs) -> None: self.name_localizations: Optional[Dict[str, str]] = kwargs.get("name_localizations", None) - # Discord API doesn't support setting descriptions for context menu commands - # so it must be empty + # Discord API doesn't support setting descriptions for context menu commands, so it must be empty self.description = "" if not isinstance(self.name, str): raise TypeError("Name of a command must be a string.") @@ -1558,7 +1588,7 @@ def slash_command(**kwargs): Returns -------- - Callable[..., :class:`SlashCommand`] + Callable[..., :class:`.SlashCommand`] A decorator that converts the provided method into a :class:`.SlashCommand`. """ return application_command(cls=SlashCommand, **kwargs) @@ -1571,7 +1601,7 @@ def user_command(**kwargs): Returns -------- - Callable[..., :class:`UserCommand`] + Callable[..., :class:`.UserCommand`] A decorator that converts the provided method into a :class:`.UserCommand`. """ return application_command(cls=UserCommand, **kwargs) @@ -1584,7 +1614,7 @@ def message_command(**kwargs): Returns -------- - Callable[..., :class:`MessageCommand`] + Callable[..., :class:`.MessageCommand`] A decorator that converts the provided method into a :class:`.MessageCommand`. """ return application_command(cls=MessageCommand, **kwargs) @@ -1594,7 +1624,7 @@ def application_command(cls=SlashCommand, **attrs): """A decorator that transforms a function into an :class:`.ApplicationCommand`. More specifically, usually one of :class:`.SlashCommand`, :class:`.UserCommand`, or :class:`.MessageCommand`. The exact class depends on the ``cls`` parameter. - By default the ``description`` attribute is received automatically from the + By default, the ``description`` attribute is received automatically from the docstring of the function and is cleaned up with the use of ``inspect.cleandoc``. If the docstring is ``bytes``, then it is decoded into :class:`str` using utf-8 encoding. @@ -1605,7 +1635,7 @@ def application_command(cls=SlashCommand, **attrs): Parameters ----------- cls: :class:`.ApplicationCommand` - The class to construct with. By default this is :class:`.SlashCommand`. + The class to construct with. By default, this is :class:`.SlashCommand`. You usually do not change this. attrs Keyword arguments to pass into the construction of the class denoted @@ -1615,6 +1645,11 @@ def application_command(cls=SlashCommand, **attrs): ------- TypeError If the function is not a coroutine or is already a command. + + Returns + -------- + Callable[..., :class:`.ApplicationCommand`] + A decorator that converts the provided method into an :class:`.ApplicationCommand`, or subclass of it. """ def decorator(func: Callable) -> cls: @@ -1631,13 +1666,13 @@ def command(**kwargs): """An alias for :meth:`application_command`. .. note:: - This decorator is overridden by :func:`commands.command`. + This decorator is overridden by :func:`ext.commands.command`. .. versionadded:: 2.0 Returns -------- - Callable[..., :class:`ApplicationCommand`] + Callable[..., :class:`.ApplicationCommand`] A decorator that converts the provided method into an :class:`.ApplicationCommand`. """ return application_command(**kwargs) diff --git a/discord/commands/options.py b/discord/commands/options.py index d183371b1b..b5d0fab3a5 100644 --- a/discord/commands/options.py +++ b/discord/commands/options.py @@ -22,14 +22,42 @@ DEALINGS IN THE SOFTWARE. """ +from __future__ import annotations + import inspect -from typing import Any, Dict, List, Literal, Optional, Union +from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Type, Union from enum import Enum -from ..abc import GuildChannel +from ..abc import GuildChannel, Mentionable from ..channel import TextChannel, VoiceChannel, StageChannel, CategoryChannel, Thread from ..enums import ChannelType, SlashCommandOptionType, Enum as DiscordEnum +if TYPE_CHECKING: + from ..ext.commands import Converter + from ..user import User + from ..member import Member + from ..message import Attachment + from ..role import Role + + InputType = Union[ + Type[str], + Type[bool], + Type[int], + Type[float], + Type[GuildChannel], + Type[Thread], + Type[Member], + Type[User], + Type[Attachment], + Type[Role], + Type[Mentionable], + SlashCommandOptionType, + Converter, + Type[Converter], + Type[Enum], + Type[DiscordEnum], + ] + __all__ = ( "ThreadOption", "Option", @@ -47,7 +75,9 @@ class ThreadOption: - """Represents a class that can be passed as the input_type for an Option class. + """Represents a class that can be passed as the ``input_type`` for an :class:`Option` class. + + .. versionadded:: 2.0 Parameters ----------- @@ -86,8 +116,9 @@ async def hello( Attributes ---------- - input_type: :class:`Any` - The type of input that is expected for this option. + input_type: Union[Type[:class:`str`], Type[:class:`bool`], Type[:class:`int`], Type[:class:`float`], Type[:class:`.abc.GuildChannel`], Type[:class:`Thread`], Type[:class:`Member`], Type[:class:`User`], Type[:class:`Attachment`], Type[:class:`Role`], Type[:class:`.abc.Mentionable`], :class:`SlashCommandOptionType`, Type[:class:`.ext.commands.Converter`], Type[:class:`enums.Enum`], Type[:class:`Enum`]] + The type of input that is expected for this option. This can be a :class:`SlashCommandOptionType`, + an associated class, a channel type, a :class:`Converter`, a converter class or an :class:`enum.Enum`. name: :class:`str` The name of this option visible in the UI. Inherits from the variable name if not provided as a parameter. @@ -104,19 +135,20 @@ async def hello( The default value for this option. If provided, ``required`` will be considered ``False``. min_value: Optional[:class:`int`] The minimum value that can be entered. - Only applies to Options with an input_type of ``int`` or ``float``. + Only applies to Options with an :attr:`.input_type` of :class:`int` or :class:`float`. max_value: Optional[:class:`int`] The maximum value that can be entered. - Only applies to Options with an input_type of ``int`` or ``float``. + Only applies to Options with an :attr:`.input_type` of :class:`int` or :class:`float`. min_length: Optional[:class:`int`] The minimum length of the string that can be entered. Must be between 0 and 6000 (inclusive). - Only applies to Options with an input_type of ``str``. + Only applies to Options with an :attr:`input_type` of :class:`str`. max_length: Optional[:class:`int`] The maximum length of the string that can be entered. Must be between 1 and 6000 (inclusive). - Only applies to Options with an input_type of ``str``. + Only applies to Options with an :attr:`input_type` of :class:`str`. autocomplete: Optional[:class:`Any`] - The autocomplete handler for the option. Accepts an iterable of :class:`str`, a callable (sync or async) that takes a - single argument of :class:`AutocompleteContext`, or a coroutine. Must resolve to an iterable of :class:`str`. + The autocomplete handler for the option. Accepts an iterable of :class:`str`, a callable (sync or async) + that takes a single argument of :class:`AutocompleteContext`, or a coroutine. + Must resolve to an iterable of :class:`str`. .. note:: @@ -129,93 +161,91 @@ async def hello( See `here `_ for a list of valid locales. """ - def __init__(self, input_type: Any = str, /, description: Optional[str] = None, **kwargs) -> None: + input_type: SlashCommandOptionType + converter: Optional[Union[Converter, Type[Converter]]] = None + + def __init__(self, input_type: InputType = 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._raw_type: Union[InputType, tuple] = input_type + + enum_choices = [] + input_type_is_class = isinstance(input_type, type) + if input_type_is_class and issubclass(input_type, (Enum, DiscordEnum)): + description = inspect.getdoc(input_type) + enum_choices = [OptionChoice(e.name, e.value) for e in input_type] + value_class = enum_choices[0].value.__class__ + if all(isinstance(elem.value, value_class) for elem in enum_choices): + input_type = SlashCommandOptionType.from_datatype(enum_choices[0].value.__class__) + else: + enum_choices = [OptionChoice(e.name, str(e.value)) for e in input_type] + input_type = SlashCommandOptionType.string + self.description = description or "No description provided" - 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"): + + if isinstance(input_type, SlashCommandOptionType): + self.input_type = input_type + else: + from ..ext.commands import Converter + if isinstance(input_type, Converter) or input_type_is_class and issubclass(input_type, Converter): self.converter = input_type self._raw_type = str - input_type = SlashCommandOptionType.string - elif isinstance(input_type, type) and 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__) + self.input_type = SlashCommandOptionType.string else: try: - _type = SlashCommandOptionType.from_datatype(input_type) + self.input_type = SlashCommandOptionType.from_datatype(input_type) except TypeError as exc: from ..ext.commands.converter import CONVERTER_MAPPING if input_type not in CONVERTER_MAPPING: raise exc self.converter = CONVERTER_MAPPING[input_type] - input_type = SlashCommandOptionType.string + self._raw_type = str + self.input_type = SlashCommandOptionType.string else: - if _type == SlashCommandOptionType.channel: - if not isinstance(input_type, tuple): - if hasattr(input_type, "__args__"): # Union - input_type = input_type.__args__ + if self.input_type == SlashCommandOptionType.channel: + if not isinstance(self._raw_type, tuple): + if hasattr(input_type, "__args__"): + self._raw_type = input_type.__args__ # type: ignore # Union.__args__ else: - input_type = (input_type,) - for i in input_type: - if i is GuildChannel: - continue - if isinstance(i, ThreadOption): - self.channel_types.append(i._type) - continue - - channel_type = CHANNEL_TYPE_MAP[i] - self.channel_types.append(channel_type) - input_type = _type - self.input_type = input_type + self._raw_type = (input_type,) + self.channel_types = [CHANNEL_TYPE_MAP[t] for t in self._raw_type if t is not GuildChannel] self.required: bool = kwargs.pop("required", True) if "default" not in kwargs else False self.default = kwargs.pop("default", None) 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)) + minmax_typehint = Optional[int] elif self.input_type == SlashCommandOptionType.number: minmax_types = (int, float, type(None)) + minmax_typehint = Optional[Union[int, float]] else: minmax_types = (type(None),) - minmax_typehint = Optional[Union[minmax_types]] # type: ignore + minmax_typehint = type(None) if self.input_type == SlashCommandOptionType.string: minmax_length_types = (int, type(None)) + minmax_length_typehint = Optional[int] else: minmax_length_types = (type(None),) - minmax_length_typehint = Optional[Union[minmax_length_types]] # type: ignore + minmax_length_typehint = type(None) - self.min_value: minmax_typehint = kwargs.pop("min_value", None) - self.max_value: minmax_typehint = kwargs.pop("max_value", None) - self.min_length: minmax_length_typehint = kwargs.pop("min_length", None) - self.max_length: minmax_length_typehint = kwargs.pop("max_length", None) + self.min_value: Optional[Union[int, float]] = kwargs.pop("min_value", None) + self.max_value: Optional[Union[int, float]] = kwargs.pop("max_value", None) + self.min_length: Optional[int] = kwargs.pop("min_length", None) + self.max_length: Optional[int] = kwargs.pop("max_length", None) - if (input_type != SlashCommandOptionType.integer and input_type != SlashCommandOptionType.number + if (self.input_type != SlashCommandOptionType.integer and self.input_type != SlashCommandOptionType.number and (self.min_value or self.max_value)): raise AttributeError("Option does not take min_value or max_value if not of type " "SlashCommandOptionType.integer or SlashCommandOptionType.number") - if input_type != SlashCommandOptionType.string and (self.min_length or self.max_length): + if self.input_type != SlashCommandOptionType.string and (self.min_length or self.max_length): raise AttributeError('Option does not take min_length or max_length if not of type str') if self.min_value is not None and not isinstance(self.min_value, minmax_types): @@ -225,12 +255,14 @@ def __init__(self, input_type: Any = str, /, description: Optional[str] = None, if self.min_length is not None: if not isinstance(self.min_length, minmax_length_types): - raise TypeError(f'Expected {minmax_length_typehint} for min_length, got "{type(self.min_length).__name__}"') + raise TypeError(f'Expected {minmax_length_typehint} for min_length,' + f' got "{type(self.min_length).__name__}"') if self.min_length < 0 or self.min_length > 6000: raise AttributeError("min_length must be between 0 and 6000 (inclusive)") if self.max_length is not None: if not isinstance(self.max_length, minmax_length_types): - raise TypeError(f'Expected {minmax_length_typehint} for max_length, got "{type(self.max_length).__name__}"') + raise TypeError(f'Expected {minmax_length_typehint} for max_length,' + f' got "{type(self.max_length).__name__}"') if self.max_length < 1 or self.max_length > 6000: raise AttributeError("max_length must between 1 and 6000 (inclusive)") @@ -305,12 +337,18 @@ def to_dict(self) -> Dict[str, Union[str, int, float]]: def option(name, type=None, **kwargs): - """A decorator that can be used instead of typehinting Option""" + """A decorator that can be used instead of typehinting :class:`Option`. + + .. versionadded:: 2.0 + """ def decorator(func): nonlocal type type = type or func.__annotations__.get(name, str) - func.__annotations__[name] = Option(type, **kwargs) + if parameter := kwargs.get("parameter_name"): + func.__annotations__[parameter] = Option(type, name=name ,**kwargs) + else: + func.__annotations__[name] = Option(type, **kwargs) return func return decorator diff --git a/discord/commands/permissions.py b/discord/commands/permissions.py index 86bd0df63a..12b4bd7864 100644 --- a/discord/commands/permissions.py +++ b/discord/commands/permissions.py @@ -48,7 +48,7 @@ def default_permissions(**perms: bool) -> Callable: Parameters ------------ - perms + **perms: Dict[:class:`str`, :class:`bool`] An argument list of permissions to check for. Example @@ -95,7 +95,7 @@ def guild_only() -> Callable: @bot.slash_command() @guild_only() async def test(ctx): - await ctx.respond('You\'re in a guild.') + await ctx.respond("You're in a guild.") """ diff --git a/discord/components.py b/discord/components.py index 695647191e..7d5bfebda0 100644 --- a/discord/components.py +++ b/discord/components.py @@ -374,8 +374,6 @@ class SelectOption: description: Optional[:class:`str`] An additional description of the option, if any. Can only be up to 100 characters. - emoji: Optional[Union[:class:`str`, :class:`Emoji`, :class:`PartialEmoji`]] - The emoji of the option, if available. default: :class:`bool` Whether this option is selected by default. """ @@ -384,7 +382,7 @@ class SelectOption: "label", "value", "description", - "emoji", + "_emoji", "default", ) @@ -399,22 +397,16 @@ def __init__( ) -> None: if len(label) > 100: raise ValueError("label must be 100 characters or fewer") + if value is not MISSING and len(value) > 100: raise ValueError("value must be 100 characters or fewer") + if description is not None and len(description) > 100: raise ValueError("description must be 100 characters or fewer") + self.label = label self.value = label if value is MISSING else value self.description = description - - if emoji is not None: - if isinstance(emoji, str): - emoji = PartialEmoji.from_str(emoji) - elif isinstance(emoji, _EmojiTag): - emoji = emoji._to_partial() - else: - raise TypeError(f"expected emoji to be str, Emoji, or PartialEmoji not {emoji.__class__}") - self.emoji = emoji self.default = default @@ -430,6 +422,23 @@ def __str__(self) -> str: return f"{base}\n{self.description}" return base + @property + def emoji(self) -> Optional[Union[str, Emoji, PartialEmoji]]: + """Optional[Union[:class:`str`, :class:`Emoji`, :class:`PartialEmoji`]]: The emoji of the option, if available.""" + return self._emoji + + @emoji.setter + def emoji(self, value) -> None: + if value is not None: + if isinstance(value, str): + value = PartialEmoji.from_str(value) + elif isinstance(value, _EmojiTag): + value = value._to_partial() + else: + raise TypeError(f"expected emoji to be str, Emoji, or PartialEmoji not {value.__class__}") + + self._emoji = value + @classmethod def from_dict(cls, data: SelectOptionPayload) -> SelectOption: try: diff --git a/discord/embeds.py b/discord/embeds.py index 07cebbfec4..d3b8f976d2 100644 --- a/discord/embeds.py +++ b/discord/embeds.py @@ -158,7 +158,13 @@ def from_dict(cls: Type[E], data: Mapping[str, Any]) -> E: return self def to_dict(self) -> Dict[str, Union[str, bool]]: - """Converts this EmbedField object into a dict.""" + """Converts this EmbedField object into a dict. + + Returns + -------- + Dict[:class:`str`, Union[:class:`str`, :class:`bool`]] + A dictionary of :class:`str` embed field keys bound to the respective value. + """ return { "name": self.name, "value": self.value, @@ -286,6 +292,11 @@ def from_dict(cls: Type[E], data: Mapping[str, Any]) -> E: ----------- data: :class:`dict` The dictionary to convert into an embed. + + Returns + -------- + :class:`Embed` + The converted embed object. """ # we are bypassing __init__ here since it doesn't apply here self: E = cls.__new__(cls) @@ -341,7 +352,13 @@ def from_dict(cls: Type[E], data: Mapping[str, Any]) -> E: return self def copy(self: E) -> E: - """Returns a shallow copy of the embed.""" + """Creates a shallow copy of the :class:`Embed` object. + + Returns + -------- + :class:`Embed` + The copied embed object. + """ return self.__class__.from_dict(self.to_dict()) def __len__(self) -> int: @@ -818,7 +835,13 @@ def set_field_at(self: E, index: int, *, name: Any, value: Any, inline: bool = T return self def to_dict(self) -> EmbedData: - """Converts this embed object into a dict.""" + """Converts this embed object into a dict. + + Returns + -------- + Dict[:class:`str`, Union[:class:`str`, :class:`int`, :class:`bool`]] + A dictionary of :class:`str` embed keys bound to the respective value. + """ # add in the raw data into the dict result = { diff --git a/discord/emoji.py b/discord/emoji.py index 0b2cf788d3..a73427af8f 100644 --- a/discord/emoji.py +++ b/discord/emoji.py @@ -47,7 +47,7 @@ class Emoji(_EmojiTag, AssetMixin): """Represents a custom emoji. - Depending on the way this object was created, some of the attributes can + Depending on the way this object was created, some attributes can have a value of ``None``. .. container:: operations @@ -131,7 +131,7 @@ def __iter__(self) -> Iterator[Tuple[str, Any]]: if attr[0] != "_": value = getattr(self, attr, None) if value is not None: - yield (attr, value) + yield attr, value def __str__(self) -> str: if self.animated: diff --git a/discord/enums.py b/discord/enums.py index 950d33cbf1..eb0ac79cc5 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -72,6 +72,10 @@ "ScheduledEventLocationType", "InputTextStyle", "SlashCommandOptionType", + "AutoModTriggerType", + "AutoModEventType", + "AutoModActionType", + "AutoModKeywordPresetType", ) diff --git a/discord/errors.py b/discord/errors.py index e4e6fe570e..c3263b5567 100644 --- a/discord/errors.py +++ b/discord/errors.py @@ -208,7 +208,7 @@ class InvalidArgument(ClientException): """Exception that's raised when an argument to a function is invalid some way (e.g. wrong value or wrong type). - This could be considered the analogous of ``ValueError`` and + This could be considered the parallel of ``ValueError`` and ``TypeError`` except inherited from :exc:`ClientException` and thus :exc:`DiscordException`. """ @@ -256,11 +256,11 @@ def __init__( class PrivilegedIntentsRequired(ClientException): - """Exception that's raised when the gateway is requesting privileged intents - but they're not ticked in the developer page yet. + """Exception that's raised when the gateway is requesting privileged intents, but + they're not ticked in the developer page yet. Go to https://discord.com/developers/applications/ and enable the intents - that are required. Currently these are as follows: + that are required. Currently, these are as follows: - :attr:`Intents.members` - :attr:`Intents.presences` diff --git a/discord/ext/bridge/bot.py b/discord/ext/bridge/bot.py index b13fe3cca9..e8bc595fb0 100644 --- a/discord/ext/bridge/bot.py +++ b/discord/ext/bridge/bot.py @@ -30,7 +30,7 @@ from ..commands import AutoShardedBot as ExtAutoShardedBot from ..commands import Bot as ExtBot from .context import BridgeApplicationContext, BridgeExtContext -from .core import BridgeCommand, bridge_command +from .core import BridgeCommand, BridgeCommandGroup, bridge_command, bridge_group __all__ = ("Bot", "AutoShardedBot") @@ -75,6 +75,22 @@ def decorator(func) -> BridgeCommand: return decorator + def bridge_group(self, **kwargs): + """A decorator that is used to wrap a function as a bridge command group. + + Parameters + ---------- + kwargs: Optional[Dict[:class:`str`, Any]] + Keyword arguments that are directly passed to the respective command constructors. (:class:`.SlashCommandGroup` and :class:`.ext.commands.Group`) + """ + + def decorator(func) -> BridgeCommandGroup: + result = bridge_group(**kwargs)(func) + self.add_bridge_command(result) + return result + + return decorator + class Bot(BotBase, ExtBot): """Represents a discord bot, with support for cross-compatibility between command types. diff --git a/discord/ext/bridge/context.py b/discord/ext/bridge/context.py index 5024d370dd..be48ab35cb 100644 --- a/discord/ext/bridge/context.py +++ b/discord/ext/bridge/context.py @@ -23,7 +23,7 @@ DEALINGS IN THE SOFTWARE. """ from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Any, Optional, Union +from typing import Any, Optional, Union from discord.commands import ApplicationContext from discord.interactions import Interaction, InteractionMessage @@ -42,7 +42,7 @@ class BridgeContext(ABC): this class are meant to give parity between the two contexts, while still allowing for all of their functionality. When this is passed to a command, it will either be passed as :class:`BridgeExtContext`, or - :class:`BridgeApplicationContext`. Since they are two separate classes, it is quite simple to use :func:`isinstance` + :class:`BridgeApplicationContext`. Since they are two separate classes, it's easy to use the :attr:`BridgeContext.is_app` attribute. to make different functionality for each context. For example, if you want to respond to a command with the command type that it was invoked with, you can do the following: @@ -50,10 +50,10 @@ class BridgeContext(ABC): @bot.bridge_command() async def example(ctx: BridgeContext): - if isinstance(ctx, BridgeExtContext): - command_type = "Traditional (prefix-based) command" - elif isinstance(ctx, BridgeApplicationContext): + if ctx.is_app: command_type = "Application command" + else: + command_type = "Traditional (prefix-based) command" await ctx.send(f"This command was invoked with a(n) {command_type}.") .. versionadded:: 2.0 @@ -118,6 +118,11 @@ async def edit(self, *args, **kwargs) -> Union[InteractionMessage, Message]: def _get_super(self, attr: str) -> Any: return getattr(super(), attr) + @property + def is_app(self) -> bool: + """bool: Whether the context is an :class:`BridgeApplicationContext` or not.""" + return isinstance(self, BridgeApplicationContext) + class BridgeApplicationContext(BridgeContext, ApplicationContext): """ diff --git a/discord/ext/bridge/core.py b/discord/ext/bridge/core.py index 5b55ee5677..d0855dbb04 100644 --- a/discord/ext/bridge/core.py +++ b/discord/ext/bridge/core.py @@ -22,50 +22,100 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ -from typing import Any, List, Union -import asyncio -import discord.commands.options -from discord.commands import Option, SlashCommand -from discord.enums import SlashCommandOptionType +import inspect +from typing import Any, List, Union, Optional -from ..commands import AutoShardedBot as ExtAutoShardedBot -from ..commands import BadArgument -from ..commands import Bot as ExtBot +import discord.commands.options +from discord import SlashCommandOptionType, Attachment, Option, SlashCommand, SlashCommandGroup +from .context import BridgeApplicationContext +from ..commands.converter import _convert_to_bool, run_converters from ..commands import ( Command, + Group, Converter, GuildChannelConverter, RoleConverter, UserConverter, + BadArgument, + Context, + Bot as ExtBot, +) +from ...utils import get, filter_params, find + + +__all__ = ( + "BridgeCommand", + "BridgeCommandGroup", + "bridge_command", + "bridge_group", + "BridgeExtCommand", + "BridgeSlashCommand", + "BridgeExtGroup", + "BridgeSlashGroup", + "map_to", ) - -__all__ = ("BridgeCommand", "bridge_command", "BridgeExtCommand", "BridgeSlashCommand") - -from ...utils import get -from ..commands.converter import _convert_to_bool class BridgeSlashCommand(SlashCommand): - """ - A subclass of :class:`.SlashCommand` that is used to implement bridge commands. - """ + """A subclass of :class:`.SlashCommand` that is used for bridge commands.""" - ... + def __init__(self, func, **kwargs): + kwargs = filter_params(kwargs, brief="description") + super().__init__(func, **kwargs) class BridgeExtCommand(Command): - """ - A subclass of :class:`.ext.commands.Command` that is used to implement bridge commands. - """ + """A subclass of :class:`.ext.commands.Command` that is used for bridge commands.""" + + def __init__(self, func, **kwargs): + kwargs = filter_params(kwargs, description="brief") + super().__init__(func, **kwargs) + + async def transform(self, ctx: Context, param: inspect.Parameter) -> Any: + if param.annotation is Attachment: + # skip the parameter checks for bridge attachments + return await run_converters(ctx, AttachmentConverter, None, param) + else: + return await super().transform(ctx, param) - ... +class BridgeSlashGroup(SlashCommandGroup): + """A subclass of :class:`.SlashCommandGroup` that is used for bridge commands.""" + __slots__ = ("module",) + + def __init__(self, callback, *args, **kwargs): + super().__init__(*args, **kwargs) + self.callback = callback + self.__command = None + + async def _invoke(self, ctx: BridgeApplicationContext) -> None: + if not (options := ctx.interaction.data.get("options")): + if not self.__command: + self.__command = BridgeSlashCommand(self.callback) + ctx.command = self.__command + return await ctx.command.invoke(ctx) + option = options[0] + resolved = ctx.interaction.data.get("resolved", None) + command = find(lambda x: x.name == option["name"], self.subcommands) + option["resolved"] = resolved + ctx.interaction.data = option + await command.invoke(ctx) + + +class BridgeExtGroup(BridgeExtCommand, Group): + """A subclass of :class:`.ext.commands.Group` that is used for bridge commands.""" + pass class BridgeCommand: - """ - This is the base class for commands that are compatible with both traditional (prefix-based) commands and slash - commands. + """Compatibility class between prefixed-based commands and slash commands. + + Attributes + ---------- + slash_variant: :class:`.BridgeSlashCommand` + The slash command version of this bridge command. + ext_variant: :class:`.BridgeExtCommand` + The prefix-based version of this bridge command. Parameters ---------- @@ -73,47 +123,60 @@ class BridgeCommand: The callback to invoke when the command is executed. The first argument will be a :class:`BridgeContext`, and any additional arguments will be passed to the callback. This callback must be a coroutine. kwargs: Optional[Dict[:class:`str`, Any]] - Keyword arguments that are directly passed to the respective command constructors. + Keyword arguments that are directly passed to the respective command constructors. (:class:`.SlashCommand` and :class:`.ext.commands.Command`) """ - def __init__(self, callback, **kwargs): - self.callback = callback - self.kwargs = kwargs + self.slash_variant: BridgeSlashCommand = kwargs.pop("slash_variant", None) or BridgeSlashCommand(callback, **kwargs) + self.ext_variant: BridgeExtCommand = kwargs.pop("ext_variant", None) or BridgeExtCommand(callback, **kwargs) - self.ext_command = BridgeExtCommand(self.callback, **self.kwargs) - self.application_command = BridgeSlashCommand(self.callback, **self.kwargs) + @property + def name_localizations(self): + """Dict[:class:`str`, :class:`str`]: Returns name_localizations from :attr:`slash_variant` - def get_ext_command(self): - """A method to get the ext.commands version of this command. + You can edit/set name_localizations directly with + + .. code-block:: python3 + + bridge_command.name_localizations["en-UK"] = ... # or any other locale + # or + bridge_command.name_localizations = {"en-UK": ..., "fr-FR": ...} - Returns - ------- - :class:`BridgeExtCommand` - The respective traditional (prefix-based) version of the command. """ - return self.ext_command + return self.slash_variant.name_localizations - def get_application_command(self): - """A method to get the discord.commands version of this command. + @name_localizations.setter + def name_localizations(self, value): + self.slash_variant.name_localizations = value + + @property + def description_localizations(self): + """Dict[:class:`str`, :class:`str`]: Returns description_localizations from :attr:`slash_variant` + + You can edit/set description_localizations directly with + + .. code-block:: python3 + + bridge_command.description_localizations["en-UK"] = ... # or any other locale + # or + bridge_command.description_localizations = {"en-UK": ..., "fr-FR": ...} - Returns - ------- - :class:`BridgeSlashCommand` - The respective slash command version of the command. """ - return self.application_command + return self.slash_variant.description_localizations - def add_to(self, bot: Union[ExtBot, ExtAutoShardedBot]) -> None: - """Adds the command to a bot. + @description_localizations.setter + def description_localizations(self, value): + self.slash_variant.description_localizations = value + + def add_to(self, bot: ExtBot) -> None: + """Adds the command to a bot. This method is inherited by :class:`.BridgeCommandGroup`. Parameters ---------- bot: Union[:class:`.Bot`, :class:`.AutoShardedBot`] The bot to add the command to. """ - - bot.add_command(self.ext_command) - bot.add_application_command(self.application_command) + bot.add_application_command(self.slash_variant) + bot.add_command(self.ext_variant) def error(self, coro): """A decorator that registers a coroutine as a local error handler. @@ -136,12 +199,8 @@ def error(self, coro): TypeError The coroutine passed is not actually a coroutine. """ - - if not asyncio.iscoroutinefunction(coro): - raise TypeError("The error handler must be a coroutine.") - - self.ext_command.on_error = coro - self.application_command.on_error = coro + self.slash_variant.error(coro) + self.ext_variant.on_error = coro return coro @@ -164,12 +223,8 @@ def before_invoke(self, coro): TypeError The coroutine passed is not actually a coroutine. """ - - if not asyncio.iscoroutinefunction(coro): - raise TypeError("The pre-invoke hook must be a coroutine.") - - self.ext_command.before_invoke = coro - self.application_command.before_invoke = coro + self.slash_variant.before_invoke(coro) + self.ext_variant._before_invoke = coro return coro @@ -192,31 +247,129 @@ def after_invoke(self, coro): TypeError The coroutine passed is not actually a coroutine. """ + self.slash_variant.after_invoke(coro) + self.ext_variant._after_invoke = coro - if not asyncio.iscoroutinefunction(coro): - raise TypeError("The post-invoke hook must be a coroutine.") + return coro - self.ext_command.after_invoke = coro - self.application_command.after_invoke = coro - return coro +class BridgeCommandGroup(BridgeCommand): + """Compatibility class between prefixed-based commands and slash commands. + + Parameters + ---------- + callback: Callable[[:class:`.BridgeContext`, ...], Awaitable[Any]] + The callback to invoke when the command is executed. The first argument will be a :class:`BridgeContext`, + and any additional arguments will be passed to the callback. This callback must be a coroutine. + kwargs: Optional[Dict[:class:`str`, Any]] + Keyword arguments that are directly passed to the respective command constructors. (:class:`.SlashCommand` and :class:`.ext.commands.Command`) + + Attributes + ---------- + slash_variant: :class:`.SlashCommandGroup` + The slash command version of this command group. + ext_variant: :class:`.ext.commands.Group` + The prefix-based version of this command group. + subcommands: List[:class:`.BridgeCommand`] + List of bridge commands in this group + mapped: Optional[:class:`.SlashCommand`] + If :func:`map_to` is used, the mapped slash command. + """ + def __init__(self, callback, *args, **kwargs): + self.ext_variant: BridgeExtGroup = BridgeExtGroup(callback, *args, **kwargs) + self.slash_variant: BridgeSlashGroup = BridgeSlashGroup(callback, self.ext_variant.name, *args, **kwargs) + self.subcommands: List[BridgeCommand] = [] + + self.mapped: Optional[SlashCommand] = None + if map_to := getattr(callback, "__custom_map_to__", None): + kwargs.update(map_to) + self.mapped = self.slash_variant.command(**kwargs)(callback) + + def command(self, *args, **kwargs): + """A decorator to register a function as a subcommand. + + Parameters + ---------- + kwargs: Optional[Dict[:class:`str`, Any]] + Keyword arguments that are directly passed to the respective command constructors. (:class:`.SlashCommand` and :class:`.ext.commands.Command`) + """ + def wrap(callback): + slash = self.slash_variant.command(*args, **filter_params(kwargs, brief="description"), cls=BridgeSlashCommand)(callback) + ext = self.ext_variant.command(*args, **filter_params(kwargs, description="brief"), cls=BridgeExtGroup)(callback) + command = BridgeCommand(callback, slash_variant=slash, ext_variant=ext) + self.subcommands.append(command) + return command + + return wrap def bridge_command(**kwargs): - """A decorator that is used to wrap a function as a command. + """A decorator that is used to wrap a function as a bridge command. Parameters ---------- kwargs: Optional[Dict[:class:`str`, Any]] - Keyword arguments that are directly passed to the respective command constructors. + Keyword arguments that are directly passed to the respective command constructors. (:class:`.SlashCommand` and :class:`.ext.commands.Command`) """ - def decorator(callback): return BridgeCommand(callback, **kwargs) return decorator +def bridge_group(**kwargs): + """A decorator that is used to wrap a function as a bridge command group. + + Parameters + ---------- + kwargs: Optional[Dict[:class:`str`, Any]] + Keyword arguments that are directly passed to the respective command constructors. (:class:`.SlashCommandGroup` and :class:`.ext.commands.Group`) + """ + def decorator(callback): + return BridgeCommandGroup(callback, **kwargs) + + return decorator + + +def map_to(name, description = None): + """To be used with bridge command groups, map the main command to a slash subcommand. + + Example + ------- + + .. code-block:: python3 + + @bot.bridge_group() + @bridge.map_to("show") + async def config(ctx: BridgeContext): + ... + + @config.command() + async def toggle(ctx: BridgeContext): + ... + + Prefixed commands will not be affected, but slash commands will appear as: + + .. code-block:: + + /config show + /config toggle + + Parameters + ---------- + name: :class:`str` + The new name of the mapped command. + description: Optional[:class:`str`] + The new description of the mapped command. + """ + + def decorator(callback): + callback.__custom_map_to__ = {"name": name, "description": description} + return callback + + return decorator + + class MentionableConverter(Converter): """A converter that can convert a mention to a user or a role.""" @@ -226,9 +379,14 @@ async def convert(self, ctx, argument): except BadArgument: return await UserConverter().convert(ctx, argument) - -def attachment_callback(*args): # pylint: disable=unused-argument - raise ValueError("Attachments are not supported for bridge commands.") +class AttachmentConverter(Converter): + async def convert(self, ctx: Context, arg: str): + try: + attach = ctx.message.attachments[0] + except IndexError: + raise BadArgument("At least 1 attachment is needed") + else: + return attach BRIDGE_CONVERTER_MAPPING = { @@ -240,7 +398,7 @@ def attachment_callback(*args): # pylint: disable=unused-argument SlashCommandOptionType.role: RoleConverter, SlashCommandOptionType.mentionable: MentionableConverter, SlashCommandOptionType.number: float, - SlashCommandOptionType.attachment: attachment_callback, + SlashCommandOptionType.attachment: AttachmentConverter, } diff --git a/discord/ext/commands/bot.py b/discord/ext/commands/bot.py index 121ebdf438..c3af75c236 100644 --- a/discord/ext/commands/bot.py +++ b/discord/ext/commands/bot.py @@ -156,7 +156,7 @@ async def on_command_error(self, context: Context, exception: errors.CommandErro The default command error handler provided by the bot. - By default this prints to :data:`sys.stderr` however it could be + By default, this prints to :data:`sys.stderr` however it could be overridden to have a different implementation. This only fires if you do not specify any listeners for command error. @@ -253,7 +253,7 @@ async def get_context(self, message: Message, *, cls: Type[CXT] = Context) -> CX Returns the invocation context from the message. This is a more low-level counter-part for :meth:`.process_commands` - to allow users more fine grained control over the processing. + to allow users more fine-grained control over the processing. The returned context is not guaranteed to be a valid invocation context, :attr:`.Context.valid` must be checked to make sure it is. @@ -430,9 +430,9 @@ class Bot(BotBase, discord.Bot): when passing an empty string, it should always be last as no prefix after it will be matched. case_insensitive: :class:`bool` - Whether the commands should be case insensitive. Defaults to ``False``. This + Whether the commands should be case-insensitive. Defaults to ``False``. This attribute does not carry over to groups. You must set it to every group if - you require group commands to be case insensitive as well. + you require group commands to be case-insensitive as well. help_command: Optional[:class:`.HelpCommand`] The help command implementation to use. This can be dynamically set at runtime. To remove the help command pass ``None``. For more diff --git a/discord/ext/commands/context.py b/discord/ext/commands/context.py index de2097f3f3..019e7b8bee 100644 --- a/discord/ext/commands/context.py +++ b/discord/ext/commands/context.py @@ -66,7 +66,7 @@ class Context(discord.abc.Messageable, Generic[BotT]): r"""Represents the context in which a command is being invoked under. - This class contains a lot of meta data to help you understand more about + This class contains a lot of metadata to help you understand more about the invocation context. This class is not created manually and is instead passed around to commands as the first parameter. @@ -264,7 +264,7 @@ def clean_prefix(self) -> str: return "" user = self.me - # this breaks if the prefix mention is not the bot itself but I + # this breaks if the prefix mention is not the bot itself, but I # consider this to be an *incredibly* strange use case. I'd rather go # for this common use case rather than waste performance for the # odd one. @@ -273,7 +273,8 @@ def clean_prefix(self) -> str: @property def cog(self) -> Optional[Cog]: - """Optional[:class:`.Cog`]: Returns the cog associated with this context's command. None if it does not exist.""" + """Optional[:class:`.Cog`]: Returns the cog associated with this context's command. + None if it does not exist.""" if self.command is None: return None @@ -281,7 +282,8 @@ def cog(self) -> Optional[Cog]: @discord.utils.cached_property def guild(self) -> Optional[Guild]: - """Optional[:class:`.Guild`]: Returns the guild associated with this context's command. None if not available.""" + """Optional[:class:`.Guild`]: Returns the guild associated with this context's command. + None if not available.""" return self.message.guild @discord.utils.cached_property diff --git a/discord/ext/commands/converter.py b/discord/ext/commands/converter.py index 1de137b2ce..5ec807f417 100644 --- a/discord/ext/commands/converter.py +++ b/discord/ext/commands/converter.py @@ -276,8 +276,8 @@ class UserConverter(IDConverter[discord.User]): Raise :exc:`.UserNotFound` instead of generic :exc:`.BadArgument` .. versionchanged:: 1.6 - This converter now lazily fetches users from the HTTP APIs if an ID is passed - and it's not available in cache. + This converter now lazily fetches users from the HTTP APIs if an ID is + passed, and it's not available in cache. """ async def convert(self, ctx: Context, argument: str) -> discord.User: @@ -393,7 +393,8 @@ class MessageConverter(IDConverter[discord.Message]): 3. Lookup by message URL .. versionchanged:: 1.5 - Raise :exc:`.ChannelNotFound`, :exc:`.MessageNotFound` or :exc:`.ChannelNotReadable` instead of generic :exc:`.BadArgument` + Raise :exc:`.ChannelNotFound`, :exc:`.MessageNotFound` or :exc:`.ChannelNotReadable` + instead of generic :exc:`.BadArgument` """ async def convert(self, ctx: Context, argument: str) -> discord.Message: @@ -1145,8 +1146,8 @@ async def run_converters(ctx: Context, converter, argument: str, param: inspect. _NoneType = type(None) union_args = converter.__args__ for conv in union_args: - # if we got to this part in the code, then the previous conversions have failed - # so we should just undo the view, return the default, and allow parsing to continue + # if we got to this part in the code, then the previous conversions have failed, so + # we should just undo the view, return the default, and allow parsing to continue # with the other parameters if conv is _NoneType and param.kind != param.VAR_POSITIONAL: ctx.view.undo() diff --git a/discord/ext/commands/cooldowns.py b/discord/ext/commands/cooldowns.py index 7fb348833e..c22121c63e 100644 --- a/discord/ext/commands/cooldowns.py +++ b/discord/ext/commands/cooldowns.py @@ -67,13 +67,13 @@ def get_key(self, msg: Message) -> Any: elif self is BucketType.channel: return msg.channel.id elif self is BucketType.member: - return ((msg.guild and msg.guild.id), msg.author.id) + return (msg.guild and msg.guild.id), msg.author.id elif self is BucketType.category: return (msg.channel.category or msg.channel).id # type: ignore elif self is BucketType.role: # we return the channel id of a private-channel as there are only roles in guilds # and that yields the same result as for a guild with only the @everyone role - # NOTE: PrivateChannel doesn't actually have an id attribute but we assume we are + # NOTE: PrivateChannel doesn't actually have an id attribute, but we assume we are # receiving a DMChannel or GroupChannel which inherit from PrivateChannel and do return (msg.channel if isinstance(msg.channel, PrivateChannel) else msg.author.top_role).id # type: ignore @@ -231,7 +231,7 @@ def _bucket_key(self, msg: Message) -> Any: def _verify_cache_integrity(self, current: Optional[float] = None) -> None: # we want to delete all cache objects that haven't been used # in a cooldown window. e.g. if we have a command that has a - # cooldown of 60s and it has not been used in 60s then that key should be deleted + # cooldown of 60s, and it has not been used in 60s then that key should be deleted current = current or time.time() dead_keys = [k for k, v in self._cache.items() if current > v._last + v.per] for k in dead_keys: diff --git a/discord/ext/commands/core.py b/discord/ext/commands/core.py index 582c6aa73b..053aae3d38 100644 --- a/discord/ext/commands/core.py +++ b/discord/ext/commands/core.py @@ -589,7 +589,7 @@ async def transform(self, ctx: Context, param: inspect.Parameter) -> Any: view.skip_ws() # The greedy converter is simple -- it keeps going until it fails in which case, - # it undos the view ready for the next parameter to use instead + # it undoes the view ready for the next parameter to use instead if isinstance(converter, Greedy): if param.kind in (param.POSITIONAL_OR_KEYWORD, param.POSITIONAL_ONLY): return await self._transform_greedy_pos(ctx, param, required, converter.converter) @@ -888,7 +888,7 @@ def is_on_cooldown(self, ctx: Context) -> bool: Parameters ----------- ctx: :class:`.Context` - The invocation context to use when checking the commands cooldown status. + The invocation context to use when checking the command's cooldown status. Returns -------- @@ -1108,7 +1108,7 @@ def signature(self) -> str: if origin is Literal: name = "|".join(f'"{v}"' if isinstance(v, str) else str(v) for v in annotation.__args__) if param.default is not param.empty: - # We don't want None or '' to trigger the [name=value] case and instead it should + # We don't want None or '' to trigger the [name=value] case, and instead it should # do [name] since [name=None] or [name=] are not exactly useful for the user. should_print = param.default if isinstance(param.default, str) else param.default is not None if should_print: @@ -1199,7 +1199,7 @@ class GroupMixin(Generic[CogT]): A mapping of command name to :class:`.Command` objects. case_insensitive: :class:`bool` - Whether the commands should be case insensitive. Defaults to ``False``. + Whether the commands should be case-insensitive. Defaults to ``False``. """ def __init__(self, *args: Any, **kwargs: Any) -> None: @@ -1288,7 +1288,7 @@ def remove_command(self, name: str) -> Optional[Command[CogT, Any, Any]]: return None if name in command.aliases: - # we're removing an alias so we don't want to remove the rest + # we're removing an alias, so we don't want to remove the rest return command # we're not removing the alias so let's delete the rest of them. @@ -1389,7 +1389,8 @@ def command( *args: Any, **kwargs: Any, ) -> Callable[ - [Callable[ + [ + Callable[ [Concatenate[ContextT, P]], Coro[Any] ] @@ -1502,7 +1503,7 @@ class Group(GroupMixin[CogT], Command[CogT, P, T]): that the checks and the parsing dictated by its parameters will be executed. Defaults to ``False``. case_insensitive: :class:`bool` - Indicates if the group's commands should be case insensitive. + Indicates if the group's commands should be case-insensitive. Defaults to ``False``. """ @@ -1675,10 +1676,10 @@ def command( Parameters ----------- name: :class:`str` - The name to create the command with. By default this uses the + The name to create the command with. By default, this uses the function name unchanged. cls - The class to construct with. By default this is :class:`.Command`. + The class to construct with. By default, this is :class:`.Command`. You usually do not change this. attrs Keyword arguments to pass into the construction of the class denoted @@ -2060,7 +2061,7 @@ def bot_has_any_role(*items: int) -> Callable[[T], T]: .. versionchanged:: 1.1 Raise :exc:`.BotMissingAnyRole` or :exc:`.NoPrivateMessage` - instead of generic checkfailure + instead of generic :exc:`.CheckFailure`. """ def predicate(ctx): @@ -2115,8 +2116,9 @@ async def test(ctx): raise TypeError(f"Invalid permission(s): {', '.join(invalid)}") def predicate(ctx: Context) -> bool: - ch = ctx.channel - permissions = ch.permissions_for(ctx.author) # type: ignore + if ctx.channel.type == ChannelType.private: + return True + permissions = ctx.channel.permissions_for(ctx.author) # type: ignore missing = [perm for perm, value in perms.items() if getattr(permissions, perm) != value] @@ -2431,14 +2433,14 @@ async def record_usage(ctx): @bot.command() @commands.before_invoke(record_usage) async def who(ctx): # Output: used who at