diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index c81d538827..c2edbb1b77 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -18,6 +18,7 @@ body: - discord.ext.commands - discord.ext.tasks - discord.ext.pages + - discord.ext.bridge - The documentation validations: required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index a4fa4c0b4c..4607c4fa7e 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -2,14 +2,20 @@ -## Checklist +## Information -- [ ] If code changes were made then they have been tested. - - [ ] I have updated the documentation to reflect the changes. -- [ ] If `type: ignore` comments were used, a comment is also left explaining why - [ ] This PR fixes an issue. - [ ] This PR adds something new (e.g. new method or parameters). -- [ ] This PR is a breaking change (e.g. methods or parameters removed/renamed) -- [ ] This PR is **not** a code change (e.g. documentation, README, typehinting, examples, ...) +- [ ] This PR is a breaking change (e.g. methods or parameters removed/renamed). +- [ ] This PR is **not** a code change (e.g. documentation, README, typehinting, examples, ...). + +## Checklist + + + +- [ ] I have searched the open pull requests for duplicates. +- [ ] If code changes were made then they have been tested. + - [ ] I have updated the documentation to reflect the changes. +- [ ] If `type: ignore` comments were used, a comment is also left explaining why. diff --git a/discord/__init__.py b/discord/__init__.py index 9bae40d1e9..b5e4c39ef2 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -13,7 +13,7 @@ __author__ = "Pycord Development" __license__ = "MIT" __copyright__ = "Copyright 2015-2021 Rapptz & Copyright 2021-present Pycord Development" -__version__ = "2.0.0rc1" +__version__ = "2.0.0" __path__ = __import__("pkgutil").extend_path(__path__, __name__) @@ -75,6 +75,6 @@ class VersionInfo(NamedTuple): serial: int -version_info: VersionInfo = VersionInfo(major=2, minor=0, micro=0, releaselevel="candidate", serial=1) +version_info: VersionInfo = VersionInfo(major=2, minor=0, micro=0, releaselevel="final", serial=0) logging.getLogger(__name__).addHandler(logging.NullHandler()) diff --git a/discord/abc.py b/discord/abc.py index 44657d26b4..90a7256c8e 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -1534,6 +1534,7 @@ async def send( ret = state.create_message(channel=channel, data=data) if view: state.store_view(view, ret.id) + view.message = ret if delete_after is not None: await ret.delete(delay=delete_after) diff --git a/discord/activity.py b/discord/activity.py index 8202d468ae..2aa6a1739b 100644 --- a/discord/activity.py +++ b/discord/activity.py @@ -78,6 +78,8 @@ buttons: list[dict] label: str (max: 32) url: str (max: 512) +NOTE: Bots cannot access a user's activity button URLs. When received through the +gateway, the type of the buttons field will be list[str]. There are also activity flags which are mostly uninteresting for the library atm. @@ -186,13 +188,18 @@ class Activity(BaseActivity): - ``id``: A string representing the party ID. - ``size``: A list of up to two integer elements denoting (current_size, maximum_size). - buttons: List[:class:`dict`] - An list of dictionaries representing custom buttons shown in a rich presence. + buttons: Union[List[:class:`dict`], List[:class:`str`]] + A list of dictionaries representing custom buttons shown in a rich presence. Each dictionary contains the following keys: - ``label``: A string representing the text shown on the button. - ``url``: A string representing the URL opened upon clicking the button. + .. note:: + + 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 emoji: Optional[:class:`PartialEmoji`] @@ -230,7 +237,7 @@ def __init__(self, **kwargs): self.flags: int = kwargs.pop("flags", 0) self.sync_id: Optional[str] = kwargs.pop("sync_id", None) self.session_id: Optional[str] = kwargs.pop("session_id", None) - self.buttons: List[ActivityButton] = kwargs.pop("buttons", []) + self.buttons: List[str] = kwargs.pop("buttons", []) activity_type = kwargs.pop("type", -1) self.type: ActivityType = ( diff --git a/discord/audit_logs.py b/discord/audit_logs.py index 0f1471d06c..34263abbe7 100644 --- a/discord/audit_logs.py +++ b/discord/audit_logs.py @@ -233,7 +233,6 @@ class AuditLogChanges: "default_notifications", _enum_transformer(enums.NotificationLevel), ), - "region": (None, _enum_transformer(enums.VoiceRegion)), "rtc_region": (None, _enum_transformer(enums.VoiceRegion)), "video_quality_mode": (None, _enum_transformer(enums.VideoQualityMode)), "privacy_level": (None, _enum_transformer(enums.StagePrivacyLevel)), @@ -602,5 +601,5 @@ def _convert_target_sticker(self, target_id: int) -> Union[GuildSticker, Object] def _convert_target_thread(self, target_id: int) -> Union[Thread, Object]: return self.guild.get_thread(target_id) or Object(id=target_id) - def _convert_target_scheduled_event(self, target_id: int) -> Union[ScheduledEvent, None]: + def _convert_target_scheduled_event(self, target_id: int) -> Union[ScheduledEvent, Object]: return self.guild.get_scheduled_event(target_id) or Object(id=target_id) diff --git a/discord/automod.py b/discord/automod.py new file mode 100644 index 0000000000..4eeee9a46e --- /dev/null +++ b/discord/automod.py @@ -0,0 +1,446 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from datetime import timedelta +from functools import cached_property +from typing import TYPE_CHECKING, Dict, List, Optional, Union + +from . import utils +from .enums import ( + AutoModActionType, + AutoModEventType, + AutoModKeywordPresetType, + AutoModTriggerType, + try_enum, +) +from .mixins import Hashable +from .object import Object + +__all__ = ( + "AutoModRule", +) + +if TYPE_CHECKING: + from .abc import Snowflake + from .channel import ForumChannel, TextChannel, VoiceChannel + from .guild import Guild + from .member import Member + from .role import Role + from .state import ConnectionState + from .types.automod import ( + AutoModAction as AutoModActionPayload, + AutoModActionMetadata as AutoModActionMetadataPayload, + AutoModRule as AutoModRulePayload, + AutoModTriggerMetadata as AutoModTriggerMetadataPayload, + ) + +MISSING = utils.MISSING + + +class AutoModActionMetadata: + """Represents an action's metadata. + + Depending on the action's type, different attributes will be used. + + .. versionadded:: 2.0 + + 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`. + 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`. + """ + # maybe add a table of action types and attributes? + + __slots__ = ( + "channel_id", + "timeout_duration", + ) + + def __init__(self, channel_id: int = MISSING, timeout_duration: timedelta = MISSING): + self.channel_id: int = channel_id + self.timeout_duration: timedelta = timeout_duration + + def to_dict(self) -> Dict: + data = {} + + if self.channel_id is not MISSING: + data["channel_id"] = self.channel_id + + if self.timeout_duration is not MISSING: + data["duration_seconds"] = self.timeout_duration.total_seconds() + + return data + + @classmethod + def from_dict(cls, data: AutoModActionMetadataPayload): + kwargs = {} + + if (channel_id := data.get("channel_id")) is not None: + kwargs["channel_id"] = int(channel_id) + + if (duration_seconds := data.get("duration_seconds")) is not None: + # might need an explicit int cast + kwargs["timeout_duration"] = timedelta(seconds=duration_seconds) + + return cls(**kwargs) + + def __repr__(self) -> str: + repr_attrs = ( + "channel_id", + "timeout_duration", + ) + inner = [] + + for attr in repr_attrs: + if (value := getattr(self, attr)) is not MISSING: + inner.append(f"{attr}={value}") + inner = " ".join(inner) + + return f"" + + + +class AutoModAction: + """Represents an action for a guild's auto moderation rule. + + .. versionadded:: 2.0 + + Attributes + ----------- + type: :class:`AutoModActionType` + The action's type. + metadata: :class:`AutoModActionMetadata` + The action's metadata. + """ + # note that AutoModActionType.timeout is only valid for trigger type 1? + + __slots__ = ( + "type", + "metadata", + ) + + def __init__(self, action_type: AutoModActionType, metadata: AutoModActionMetadata): + self.type: AutoModActionType = action_type + self.metadata: AutoModActionMetadata = metadata + + def to_dict(self) -> Dict: + return { + "type": self.type.value, + "metadata": self.metadata, + } + + @classmethod + def from_dict(cls, data: AutoModActionPayload): + return cls(try_enum(AutoModActionType, data["type"]), AutoModActionMetadata.from_dict(data["metadata"])) + + def __repr__(self) -> str: + return f"" + + +class AutoModTriggerMetadata: + """Represents a rule's trigger metadata. + + Depending on the trigger type, different attributes will be used. + + .. versionadded:: 2.0 + + Attributes + ----------- + keyword_filter: List[:class:`str`] + A list of substrings to filter. Only for triggers of type :attr:`AutoModTriggerType.keyword`. + presets: List[:class:`AutoModKeywordPresetType`] + A list of keyword presets to filter. Only for triggers of type :attr:`AutoModTriggerType.keyword_preset`. + """ + # maybe add a table of action types and attributes? + # wording for presets could change + + __slots__ = ( + "keyword_filter", + "presets", + ) + + def __init__(self, keyword_filter: List[str] = MISSING, presets: List[AutoModKeywordPresetType] = MISSING): + self.keyword_filter = keyword_filter + self.presets = presets + + def to_dict(self) -> Dict: + data = {} + + if self.keyword_filter is not MISSING: + data["keyword_filter"] = self.keyword_filter + + if self.presets is not MISSING: + data["presets"] = [wordset.value for wordset in self.presets] + + return data + + @classmethod + def from_dict(cls, data: AutoModActionMetadataPayload): + kwargs = {} + + if (keyword_filter := data.get("keyword_filter")) is not None: + kwargs["keyword_filter"] = keyword_filter + + if (presets := data.get("presets")) is not None: + kwargs["presets"] = [try_enum(AutoModKeywordPresetType, wordset) for wordset in presets] + + return cls(**kwargs) + + def __repr__(self) -> str: + repr_attrs = ( + "keyword_filter", + "presets", + ) + inner = [] + + for attr in repr_attrs: + if (value := getattr(self, attr)) is not MISSING: + inner.append(f"{attr}={value}") + inner = " ".join(inner) + + return f"" + + +class AutoModRule(Hashable): + """Represents a guild's auto moderation rule. + + .. versionadded:: 2.0 + + .. container:: operations + + .. describe:: x == y + + Checks if two rules are equal. + + .. describe:: x != y + + Checks if two rules are not equal. + + .. describe:: hash(x) + + Returns the rule's hash. + + .. describe:: str(x) + + Returns the rule's name. + + Attributes + ---------- + id: :class:`int` + The rule's ID. + name: :class:`str` + The rule's name. + creator_id: :class:`int` + The ID of the user who created this rule. + event_type: :class:`AutoModEventType` + Indicates in what context the rule is checked. + trigger_type: :class:`AutoModTriggerType` + Indicates what type of information is checked to determine whether the rule is triggered. + trigger_metadata: :class:`AutoModTriggerMetadata` + The rule's trigger metadata. + actions: List[:class:`AutoModAction`] + The actions to perform when the rule is triggered. + enabled: :class:`bool` + Whether this rule is enabled. + exempt_role_ids: List[:class:`int`] + The IDs of the roles that are exempt from this rule. + exempt_channel_ids: List[:class:`int`] + The IDs of the channels that are exempt from this rule. + """ + + __slots__ = ( + "_state", + "id", + "guild_id", + "name", + "creator_id", + "event_type", + "trigger_type", + "trigger_metadata", + "actions", + "enabled", + "exempt_role_ids", + "exempt_channel_ids", + ) + + def __init__( + self, + *, + state: ConnectionState, + data: AutoModRulePayload, + ): + self._state: ConnectionState = state + self.id: int = int(data["id"]) + self.guild_id: int = int(data["guild_id"]) + self.name: str = data["name"] + self.creator_id: int = int(data["creator_id"]) + self.event_type: AutoModEventType = try_enum(AutoModEventType, data["event_type"]) + self.trigger_type: AutoModTriggerType = try_enum(AutoModTriggerType, data["trigger_type"]) + self.trigger_metadata: AutoModTriggerMetadata = AutoModTriggerMetadata.from_dict(data["trigger_metadata"]) + self.actions: List[AutoModAction] = [AutoModAction.from_dict(d) for d in data["actions"]] + self.enabled: bool = data["enabled"] + self.exempt_role_ids: List[int] = [int(r) for r in data["exempt_roles"]] + self.exempt_channel_ids: List[int] = [int(c) for c in data["exempt_channels"]] + + def __repr__(self) -> str: + return f"" + + def __str__(self) -> str: + return self.name + + @cached_property + def guild(self) -> Optional[Guild]: + """Optional[:class:`Guild`]: The guild this rule belongs to.""" + return self._state._get_guild(self.guild_id) + + @cached_property + def creator(self) -> Optional[Member]: + """Optional[:class:`Member`]: The member who created this rule.""" + if self.guild is None: + return None + return self.guild.get_member(self.creator_id) + + @cached_property + def exempt_roles(self) -> List[Union[Role, Object]]: + """List[Union[:class:`Role`, :class:`Object`]]: The roles that are exempt + from this rule. + + If a role is not found in the guild's cache, + then it will be returned as an :class:`Object`. + """ + if self.guild is None: + return [Object(role_id) for role_id in self.exempt_role_ids] + return [self.guild.get_role(role_id) or Object(role_id) for role_id in self.exempt_role_ids] + + @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. + + If a channel is not found in the guild's cache, + then it will be returned as an :class:`Object`. + """ + if self.guild is None: + return [Object(channel_id) for channel_id in self.exempt_channel_ids] + return [self.guild.get_channel(channel_id) or Object(channel_id) for channel_id in self.exempt_channel_ids] + + async def delete(self, reason: Optional[str] = None) -> None: + """|coro| + + Deletes this rule. + + Parameters + ----------- + reason: Optional[:class:`str`] + The reason for deleting this rule. Shows up in the audit log. + + Raises + ------- + Forbidden + You do not have the Manage Guild permission. + HTTPException + The operation failed. + """ + await self._state.http.delete_auto_moderation_rule(self.guild_id, self.id, reason=reason) + + async def edit( + self, + *, + name: str = MISSING, + event_type: AutoModEventType = MISSING, + trigger_metadata: AutoModTriggerMetadata = MISSING, + actions: List[AutoModAction] = MISSING, + enabled: bool = MISSING, + exempt_roles: List[Snowflake] = MISSING, + exempt_channels: List[Snowflake] = MISSING, + reason: Optional[str] = None, + ) -> Optional[AutoModRule]: + """|coro| + + Edits this rule. + + Parameters + ----------- + name: :class:`str` + The rule's new name. + event_type: :class:`AutoModEventType` + The new context in which the rule is checked. + trigger_metadata: :class:`AutoModTriggerMetadata` + The new trigger metadata. + actions: List[:class:`AutoModAction`] + The new actions to perform when the rule is triggered. + enabled: :class:`bool` + Whether this rule is enabled. + exempt_roles: List[:class:`Snowflake`] + The roles that will be exempt from this rule. + exempt_channels: List[:class:`Snowflake`] + The channels that will be exempt from this rule. + reason: Optional[:class:`str`] + The reason for editing this rule. Shows up in the audit log. + + Raises + ------- + Forbidden + You do not have the Manage Guild permission. + HTTPException + The operation failed. + + Returns + -------- + Optional[:class:`.AutoModRule`] + The newly updated rule, if applicable. This is only returned + when fields are updated. + """ + http = self._state.http + payload = {} + + if name is not MISSING: + payload["name"] = name + + if event_type is not MISSING: + payload["event_type"] = event_type.value + + if trigger_metadata is not MISSING: + payload["trigger_metadata"] = trigger_metadata.to_dict() + + if actions is not MISSING: + payload["actions"] = [a.to_dict() for a in actions] + + if enabled is not MISSING: + payload["enabled"] = enabled + + # Maybe consider enforcing limits on the number of exempt roles/channels? + if exempt_roles is not MISSING: + payload["exempt_roles"] = [r.id for r in exempt_roles] + + if exempt_channels is not MISSING: + payload["exempt_channels"] = [c.id for c in exempt_channels] + + if payload: + data = await http.edit_auto_moderation_rule(self.guild_id, self.id, payload, reason=reason) + return AutoModRule(state=self._state, data=data) diff --git a/discord/bot.py b/discord/bot.py index a5601f8cfb..912bc2d510 100644 --- a/discord/bot.py +++ b/discord/bot.py @@ -165,8 +165,7 @@ def remove_application_command(self, command: ApplicationCommand) -> Optional[Ap except ValueError: return None return self._pending_application_commands.pop(index) - - return self._application_commands.pop(int(command.id), None) + return self._application_commands.pop(command.id, None) @property def get_command(self): diff --git a/discord/channel.py b/discord/channel.py index 2b4b5e957d..10ebb42f90 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -179,7 +179,6 @@ class _TextChannel(discord.abc.GuildChannel, Hashable): def __init__(self, *, state: ConnectionState, guild: Guild, data: Union[TextChannelPayload, ForumChannelPayload]): self._state: ConnectionState = state self.id: int = int(data["id"]) - self._type: int = data["type"] self._update(guild, data) @property @@ -192,19 +191,23 @@ def __repr__(self) -> str: return f"<{self.__class__.__name__} {joined}>" def _update(self, guild: Guild, data: Union[TextChannelPayload, ForumChannelPayload]) -> 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.topic: Optional[str] = data.get("topic") - self.position: int = data.get("position") - self.nsfw: bool = data.get("nsfw", False) - # Does this need coercion into `int`? No idea yet. - self.slowmode_delay: int = data.get("rate_limit_per_user", 0) - self.default_auto_archive_duration: ThreadArchiveDuration = data.get("default_auto_archive_duration", 1440) - self._type: int = data.get("type", self._type) - self.last_message_id: Optional[int] = utils._get_as_snowflake(data, "last_message_id") - self.flags: ChannelFlags = ChannelFlags._from_value(data.get("flags", 0)) - self._fill_overwrites(data) + self._type: int = data["type"] + + # This data may be missing depending on how this object is being created/updated + if not data.pop("_invoke_flag", False): + self.topic: Optional[str] = data.get("topic") + self.position: int = data.get("position") + self.nsfw: bool = data.get("nsfw", False) + # Does this need coercion into `int`? No idea yet. + self.slowmode_delay: int = data.get("rate_limit_per_user", 0) + self.default_auto_archive_duration: ThreadArchiveDuration = data.get("default_auto_archive_duration", 1440) + self.last_message_id: Optional[int] = utils._get_as_snowflake(data, "last_message_id") + self.flags: ChannelFlags = ChannelFlags._from_value(data.get("flags", 0)) + self._fill_overwrites(data) @property def type(self) -> ChannelType: @@ -720,6 +723,10 @@ def is_news(self) -> bool: """:class:`bool`: Checks if the channel is a news/anouncements channel.""" return self._type == ChannelType.news.value + @property + def news(self) -> bool: + return self.is_news() + async def create_thread( self, *, @@ -1130,6 +1137,7 @@ class VoiceChannel(discord.abc.Messageable, VocalGuildChannel): .. versionadded:: 2.0 last_message_id: Optional[:class:`int`] The ID of the last message sent to this channel. It may not always point to an existing or valid message. + .. versionadded:: 2.0 flags: :class:`ChannelFlags` Extra features of the channel. diff --git a/discord/client.py b/discord/client.py index e4c5434689..584fa3549e 100644 --- a/discord/client.py +++ b/discord/client.py @@ -53,7 +53,7 @@ from .backoff import ExponentialBackoff from .channel import PartialMessageable, _threaded_channel_factory from .emoji import Emoji -from .enums import ChannelType, Status, VoiceRegion +from .enums import ChannelType, Status from .errors import * from .flags import ApplicationFlags, Intents from .gateway import * @@ -1319,7 +1319,6 @@ async def create_guild( self, *, name: str, - region: Union[VoiceRegion, str] = VoiceRegion.us_west, icon: bytes = MISSING, code: str = MISSING, ) -> Guild: @@ -1333,9 +1332,6 @@ async def create_guild( ---------- name: :class:`str` The name of the guild. - region: :class:`.VoiceRegion` - The region for the voice communication server. - Defaults to :attr:`.VoiceRegion.us_west`. icon: Optional[:class:`bytes`] The :term:`py:bytes-like object` representing the icon. See :meth:`.ClientUser.edit` for more details on what is expected. @@ -1362,12 +1358,10 @@ async def create_guild( else: icon_base64 = None - region_value = str(region) - if code: - data = await self.http.create_from_template(code, name, region_value, icon_base64) + data = await self.http.create_from_template(code, name, icon_base64) else: - data = await self.http.create_guild(name, region_value, icon_base64) + data = await self.http.create_guild(name, icon_base64) return Guild(data=data, state=self._connection) async def fetch_stage_instance(self, channel_id: int, /) -> StageInstance: diff --git a/discord/cog.py b/discord/cog.py index 21c326440d..5844c46197 100644 --- a/discord/cog.py +++ b/discord/cog.py @@ -26,6 +26,8 @@ import importlib import inspect +import os +import pathlib import sys import types from typing import ( @@ -40,6 +42,7 @@ Tuple, Type, TypeVar, + Union, ) import discord.utils @@ -739,7 +742,14 @@ def _resolve_name(self, name: str, package: Optional[str]) -> str: except ImportError: raise errors.ExtensionNotFound(name) - def load_extension(self, name: str, *, package: Optional[str] = None) -> None: + def load_extension( + self, + name: str, + *, + package: Optional[str] = None, + recursive: bool = False, + store: bool = True, + ) -> Optional[Union[Dict[str, Union[Exception, bool]], List[str]]]: """Loads an extension. An extension is a python module that contains commands, cogs, or @@ -749,21 +759,41 @@ def load_extension(self, name: str, *, package: Optional[str] = None) -> None: the entry point on what to do when the extension is loaded. This entry point must have a single argument, the ``bot``. + The extension passed can either be the direct name of a file within + the current working directory or a folder that contains multiple extensions. + Parameters - ------------ + ----------- name: :class:`str` - The extension name to load. It must be dot separated like - regular Python imports if accessing a sub-module. e.g. + The extension or folder name to load. It must be dot separated + like regular Python imports if accessing a sub-module. e.g. ``foo.test`` if you want to import ``foo/test.py``. package: Optional[:class:`str`] The package name to resolve relative imports with. - This is required when loading an extension using a relative path, e.g ``.foo.test``. + This is required when loading an extension using a relative + path, e.g ``.foo.test``. Defaults to ``None``. .. versionadded:: 1.7 + recursive: Optional[:class:`bool`] + If subdirectories under the given head directory should be + recursively loaded. + Defaults to ``False``. + + .. versionadded:: 2.0 + store: Optional[:class:`bool`] + If exceptions should be stored or raised. If set to ``True``, + all exceptions encountered will be stored in a returned dictionary + as a load status. If set to ``False``, if any exceptions are + 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``. + + .. versionadded:: 2.0 Raises - -------- + ------- ExtensionNotFound The extension could not be imported. This is also raised if the name of the extension could not @@ -774,17 +804,133 @@ def load_extension(self, name: str, *, package: Optional[str] = None) -> None: The extension does not have a setup function. ExtensionFailed The extension or its setup function had an execution error. + + Returns + -------- + Optional[Union[Dict[:class:`str`, Union[:exc:`errors.ExtensionError`, :class:`bool`]], List[:class:`str`]]] + If the store parameter is set to ``True``, a dictionary will be returned that + contains keys to represent the loaded extension names. The values bound to + each key can either be an exception that occurred when loading that extension + or a ``True`` boolean representing a successful load. If the store parameter + is set to ``False``, either a list containing a list of loaded extensions or + nothing due to an encountered exception. """ name = self._resolve_name(name, package) + if name in self.__extensions: - raise errors.ExtensionAlreadyLoaded(name) + exc = errors.ExtensionAlreadyLoaded(name) + final_out = {name: exc} if store else exc + # This indicates that there is neither an extension nor folder here + elif (spec := importlib.util.find_spec(name)) is None: + exc = errors.ExtensionNotFound(name) + final_out = {name: exc} if store else exc + # This indicates we've found an extension file to load, and we need to store any exceptions + elif spec.has_location and store: + try: + self._load_from_module_spec(spec, name) + except Exception as exc: + final_out = {name: exc} + else: + final_out = {name: True} + # This indicates we've found an extension file to load, and any encountered exceptions can be raised + elif spec.has_location: + self._load_from_module_spec(spec, name) + final_out = [name] + # This indicates we've been given a folder because the ModuleSpec exists but is not a file + else: + # Split the directory path and join it to get an os-native Path object + path = pathlib.Path(os.path.join(*name.split("."))) + glob = path.rglob if recursive else path.glob + final_out = {} if store else [] + + # Glob all files with a pattern to gather all .py files that don't start with _ + for ext_file in glob("[!_]*.py"): + # Gets all parts leading to the directory minus the file name + parts = list(ext_file.parts[:-1]) + # Gets the file name without the extension + parts.append(ext_file.stem) + loaded = self.load_extension(".".join(parts)) + final_out.update(loaded) if store else final_out.extend(loaded) + + if isinstance(final_out, Exception): + raise final_out + else: + return final_out - spec = importlib.util.find_spec(name) - if spec is None: - raise errors.ExtensionNotFound(name) + def load_extensions( + self, + *names: str, + package: Optional[str] = None, + recursive: bool = False, + store: bool = True, + ) -> Optional[Union[Dict[str, Union[Exception, bool]], List[str]]]: + """Loads multiple extensions at once. + + This method simplifies the process of loading multiple + extensions by handling the looping of ``load_extension``. + + Parameters + ----------- + 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. + ``foo.test`` if you want to import ``foo/test.py``. + package: Optional[:class:`str`] + The package name to resolve relative imports with. + This is required when loading an extension using a relative + path, e.g ``.foo.test``. + Defaults to ``None``. + + .. versionadded:: 1.7 + recursive: Optional[:class:`bool`] + If subdirectories under the given head directory should be + recursively loaded. + Defaults to ``False``. + + .. versionadded:: 2.0 + store: Optional[:class:`bool`] + If exceptions should be stored or raised. If set to ``True``, + all exceptions encountered will be stored in a returned dictionary + as a load status. If set to ``False``, if any exceptions are + 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``. + + .. versionadded:: 2.0 + + Raises + -------- + ExtensionNotFound + A given extension could not be imported. + This is also raised if the name of the extension could not + be resolved using the provided ``package`` parameter. + ExtensionAlreadyLoaded + A given extension is already loaded. + NoEntryPointError + A given extension does not have a setup function. + ExtensionFailed + A given extension or its setup function had an execution error. + + Returns + -------- + Optional[Union[Dict[:class:`str`, Union[:exc:`errors.ExtensionError`, :class:`bool`]], List[:class:`str`]]] + If the store parameter is set to ``True``, a dictionary will be returned that + contains keys to represent the loaded extension names. The values bound to + each key can either be an exception that occurred when loading that extension + or a ``True`` boolean representing a successful load. If the store parameter + is set to ``False``, either a list containing names of loaded extensions or + nothing due to an encountered exception. + """ + + loaded_extensions = {} if store else [] + + for ext_path in names: + loaded = self.load_extension(ext_path, package=package, recursive=recursive, store=store) + loaded_extensions.update(loaded) if store else loaded_extensions.extend(loaded) - self._load_from_module_spec(spec, name) + return loaded_extensions def unload_extension(self, name: str, *, package: Optional[str] = None) -> None: """Unloads an extension. diff --git a/discord/commands/context.py b/discord/commands/context.py index 11150b3d1b..b3576c32d7 100644 --- a/discord/commands/context.py +++ b/discord/commands/context.py @@ -27,7 +27,8 @@ from typing import TYPE_CHECKING, Dict, List, Optional, TypeVar, Union import discord.abc -from discord.interactions import InteractionMessage +from discord.interactions import InteractionMessage, InteractionResponse, Interaction +from discord.webhook.async_ import Webhook if TYPE_CHECKING: from typing_extensions import ParamSpec @@ -43,6 +44,7 @@ from ..member import Member from ..message import Message from ..user import User + from ..permissions import Permissions from ..client import ClientUser from discord.webhook.async_ import Webhook @@ -126,6 +128,7 @@ async def invoke( The arguments to use. \*\*kwargs The keyword arguments to use. + Raises ------- TypeError @@ -135,44 +138,62 @@ async def invoke( @cached_property def channel(self) -> Optional[InteractionChannel]: + """Union[:class:`abc.GuildChannel`, :class:`PartialMessageable`, :class:`Thread`]: + Returns the channel associated with this context's command. Shorthand for :attr:`.Interaction.channel`.""" return self.interaction.channel @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`.""" 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`.""" 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`.""" 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`.""" 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`.""" return self.interaction.guild_locale + @cached_property + def app_permissions(self) -> Permissions: + return self.interaction.app_permissions + @cached_property def me(self) -> Optional[Union[Member, ClientUser]]: + """Union[:class:`.Member`, :class:`.ClientUser`]: + Similar to :attr:`.Guild.me` except it may return the :class:`.ClientUser` in private message + message contexts, or when :meth:`Intents.guilds` is absent. + """ return self.interaction.guild.me if self.interaction.guild is not None else self.bot.user @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.""" 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`.""" 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.""" if self.interaction.guild is None: return None @@ -180,6 +201,7 @@ 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`.""" return self.interaction.response @property @@ -216,12 +238,21 @@ def unselected_options(self) -> Optional[List[Option]]: return None @property + @discord.utils.copy_doc(InteractionResponse.send_modal) def send_modal(self) -> Callable[..., Awaitable[Interaction]]: - """Sends a modal dialog to the user who invoked the interaction.""" return self.interaction.response.send_modal async def respond(self, *args, **kwargs) -> Union[Interaction, WebhookMessage]: - """Sends either a response or a followup response depending if the interaction has been responded to yet or not.""" + """|coro| + + Sends either a response or a message using the followup webhook depending determined by whether the interaction + has been responded to or not. + + Returns + ------- + Union[:class:`discord.Interaction`, :class:`discord.WebhookMessage`]: + The response, its type depending on whether it's an interaction response or a followup. + """ try: if not self.interaction.response.is_done(): return await self.interaction.response.send_message(*args, **kwargs) # self.response @@ -231,6 +262,7 @@ async def respond(self, *args, **kwargs) -> Union[Interaction, WebhookMessage]: return await self.followup.send(*args, **kwargs) @property + @discord.utils.copy_doc(InteractionResponse.send_message) def send_response(self) -> Callable[..., Awaitable[Interaction]]: if not self.interaction.response.is_done(): return self.interaction.response.send_message @@ -240,6 +272,7 @@ def send_response(self) -> Callable[..., Awaitable[Interaction]]: ) @property + @discord.utils.copy_doc(Webhook.send) def send_followup(self) -> Callable[..., Awaitable[WebhookMessage]]: if self.interaction.response.is_done(): return self.followup.send @@ -249,11 +282,13 @@ def send_followup(self) -> Callable[..., Awaitable[WebhookMessage]]: ) @property + @discord.utils.copy_doc(InteractionResponse.defer) def defer(self) -> Callable[..., Awaitable[None]]: return self.interaction.response.defer @property def followup(self) -> Webhook: + """:class:`Webhook`: Returns the follow up webhook for follow up interactions.""" return self.interaction.followup async def delete(self, *, delay: Optional[float] = None) -> None: @@ -281,6 +316,7 @@ async def delete(self, *, delay: Optional[float] = None) -> None: return await self.interaction.delete_original_message(delay=delay) @property + @discord.utils.copy_doc(Interaction.edit_original_message) def edit(self) -> Callable[..., Awaitable[InteractionMessage]]: return self.interaction.edit_original_message diff --git a/discord/commands/core.py b/discord/commands/core.py index 8c7b16d19a..04e148817d 100644 --- a/discord/commands/core.py +++ b/discord/commands/core.py @@ -48,7 +48,7 @@ Union, ) -from ..channel import _guild_channel_factory +from ..channel import _threaded_guild_channel_factory from ..enums import MessageType, SlashCommandOptionType, try_enum, Enum as DiscordEnum from ..errors import ( ApplicationCommandError, @@ -61,6 +61,7 @@ from ..message import Attachment, Message from ..object import Object from ..role import Role +from ..threads import Thread from ..user import User from ..utils import async_all, find, utcnow from .context import ApplicationContext, AutocompleteContext @@ -239,8 +240,8 @@ async def __call__(self, ctx, *args, **kwargs): def callback( self, ) -> Union[ - Callable[Concatenate[CogT, ApplicationContext, P], Coro[T]], - Callable[Concatenate[ApplicationContext, P], Coro[T]], + Callable[[Concatenate[CogT, ApplicationContext, P]], Coro[T]], + Callable[[Concatenate[ApplicationContext, P]], Coro[T]], ]: return self._callback @@ -248,8 +249,8 @@ def callback( def callback( self, function: Union[ - Callable[Concatenate[CogT, ApplicationContext, P], Coro[T]], - Callable[Concatenate[ApplicationContext, P], Coro[T]], + Callable[[Concatenate[CogT, ApplicationContext, P]], Coro[T]], + Callable[[Concatenate[ApplicationContext, P]], Coro[T]], ], ) -> None: self._callback = function @@ -688,13 +689,17 @@ def _parse_options(self, params, *, check_params: bool = True) -> List[Option]: option = Option(option.__args__) if not isinstance(option, Option): - option = Option(option) + if isinstance(p_obj.default, Option): + p_obj.default.input_type = SlashCommandOptionType.from_datatype(option) + option = p_obj.default + else: + option = Option(option) - if option.default is None: - if p_obj.default == inspect.Parameter.empty: - option.default = None - elif isinstance(p_obj.default, type) and issubclass(p_obj.default, (DiscordEnum, Enum)): + if option.default is None and not p_obj.default == inspect.Parameter.empty: + if isinstance(p_obj.default, type) and issubclass(p_obj.default, (DiscordEnum, Enum)): option = Option(p_obj.default) + elif isinstance(p_obj.default, Option) and not (default := p_obj.default.default) is None: + option.default = default else: option.default = p_obj.default option.required = False @@ -767,7 +772,7 @@ def to_dict(self) -> Dict: as_dict["type"] = SlashCommandOptionType.sub_command.value if self.guild_only is not None: - as_dict["guild_only"] = self.guild_only + as_dict["dm_permission"] = not self.guild_only if self.default_member_permissions is not None: as_dict["default_member_permissions"] = self.default_member_permissions.value @@ -779,6 +784,8 @@ async def _invoke(self, ctx: ApplicationContext) -> None: kwargs = {} for arg in ctx.interaction.data.get("options", []): op = find(lambda x: x.name == arg["name"], self.options) + if op is None: + continue arg = arg["value"] # Checks if input_type is user, role or channel @@ -798,7 +805,8 @@ async def _invoke(self, ctx: ApplicationContext) -> None: if (_user_data := resolved.get("users", {}).get(arg)) is not None: # We resolved the user from the user id _data["user"] = _user_data - arg = Member(state=ctx.interaction._state, data=_data, guild=ctx.guild) + cache_flag = ctx.interaction._state.member_cache_flags.interaction + arg = ctx.guild._get_and_update_member(_data, int(arg), cache_flag) elif op.input_type is SlashCommandOptionType.mentionable: if (_data := resolved.get("users", {}).get(arg)) is not None: arg = User(state=ctx.interaction._state, data=_data) @@ -807,25 +815,42 @@ async def _invoke(self, ctx: ApplicationContext) -> None: else: arg = Object(id=int(arg)) elif (_data := resolved.get(f"{op.input_type.name}s", {}).get(arg)) is not None: - obj_type = None - kw = {} - if op.input_type is SlashCommandOptionType.user: - obj_type = User - elif op.input_type is SlashCommandOptionType.role: - obj_type = Role - kw["guild"] = ctx.guild - elif op.input_type is SlashCommandOptionType.channel: - obj_type = _guild_channel_factory(_data["type"])[0] - kw["guild"] = ctx.guild - elif op.input_type is SlashCommandOptionType.attachment: - obj_type = Attachment - arg = obj_type(state=ctx.interaction._state, data=_data, **kw) + if op.input_type is SlashCommandOptionType.channel and ( + int(arg) in ctx.guild._channels or int(arg) in ctx.guild._threads + ): + arg = ctx.guild.get_channel_or_thread(int(arg)) + _data["_invoke_flag"] = True + arg._update(_data) if isinstance(arg, Thread) else arg._update(ctx.guild, _data) + else: + obj_type = None + kw = {} + if op.input_type is SlashCommandOptionType.user: + obj_type = User + elif op.input_type is SlashCommandOptionType.role: + obj_type = Role + kw["guild"] = ctx.guild + elif op.input_type is SlashCommandOptionType.channel: + # NOTE: + # This is a fallback in case the channel/thread is not found in the + # guild's channels/threads. For channels, if this fallback occurs, at the very minimum, + # permissions will be incorrect due to a lack of permission_overwrite data. + # For threads, if this fallback occurs, info like thread owner id, message count, + # flags, and more will be missing due to a lack of data sent by Discord. + obj_type = _threaded_guild_channel_factory(_data["type"])[0] + kw["guild"] = ctx.guild + elif op.input_type is SlashCommandOptionType.attachment: + obj_type = Attachment + arg = obj_type(state=ctx.interaction._state, data=_data, **kw) else: # We couldn't resolve the object, so we just return an empty object arg = Object(id=int(arg)) elif op.input_type == SlashCommandOptionType.string and (converter := op.converter) is not None: - arg = await converter.convert(converter, ctx, arg) + 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) elif op._raw_type in (SlashCommandOptionType.integer, SlashCommandOptionType.number, @@ -839,8 +864,8 @@ async def _invoke(self, ctx: ApplicationContext) -> None: arg = op._raw_type(int(arg)) except ValueError: arg = op._raw_type(arg) - else: - arg = op._raw_type(arg) + elif choice := find(lambda c: c.value == arg, op.choices): + arg = getattr(op._raw_type, choice.name) kwargs[op._parameter_name] = arg @@ -938,10 +963,6 @@ class SlashCommandGroup(ApplicationCommand): Whether the command should only be usable inside a guild. default_member_permissions: :class:`~discord.Permissions` The default permissions a member needs to be able to run the command. - subcommands: List[Union[:class:`SlashCommand`, :class:`SlashCommandGroup`]] - The list of all subcommands under this group. - cog: Optional[:class:`.Cog`] - The cog that this command belongs to. ``None`` if there isn't one. checks: List[Callable[[:class:`.ApplicationContext`], :class:`bool`]] A list of predicates that verifies if the command could be executed with the given :class:`.ApplicationContext` as the sole parameter. If an exception @@ -949,6 +970,12 @@ class SlashCommandGroup(ApplicationCommand): :exc:`.ApplicationCommandError` should be used. Note that if the checks fail then :exc:`.CheckFailure` exception is raised to the :func:`.on_application_command_error` event. + name_localizations: Optional[Dict[:class:`str`, :class:`str`]] + The name localizations for this command. The values of this should be ``"locale": "name"``. See + `here `_ for a list of valid locales. + description_localizations: Optional[Dict[:class:`str`, :class:`str`]] + The description localizations for this command. The values of this should be ``"locale": "description"``. + See `here `_ for a list of valid locales. """ __initial_commands__: List[Union[SlashCommand, SlashCommandGroup]] type = 1 @@ -978,7 +1005,7 @@ def __new__(cls, *args, **kwargs) -> SlashCommandGroup: def __init__( self, name: str, - description: str, + description: Optional[str] = None, guild_ids: Optional[List[int]] = None, parent: Optional[SlashCommandGroup] = None, **kwargs, @@ -1025,7 +1052,7 @@ def to_dict(self) -> Dict: as_dict["type"] = self.input_type.value if self.guild_only is not None: - as_dict["guild_only"] = self.guild_only + as_dict["dm_permission"] = not self.guild_only if self.default_member_permissions is not None: as_dict["default_member_permissions"] = self.default_member_permissions.value @@ -1045,6 +1072,7 @@ def create_subgroup( name: str, description: Optional[str] = None, guild_ids: Optional[List[int]] = None, + **kwargs, ) -> SlashCommandGroup: """ Creates a new subgroup for this SlashCommandGroup. @@ -1058,6 +1086,23 @@ def create_subgroup( guild_ids: Optional[List[:class:`int`]] A list of the IDs of each guild this group should be added to, making it a guild command. This will be a global command if ``None`` is passed. + guild_only: :class:`bool` + Whether the command should only be usable inside a guild. + default_member_permissions: :class:`~discord.Permissions` + The default permissions a member needs to be able to run the command. + checks: List[Callable[[:class:`.ApplicationContext`], :class:`bool`]] + A list of predicates that verifies if the command could be executed + with the given :class:`.ApplicationContext` as the sole parameter. If an exception + is necessary to be thrown to signal failure, then one inherited from + :exc:`.ApplicationCommandError` should be used. Note that if the checks fail then + :exc:`.CheckFailure` exception is raised to the :func:`.on_application_command_error` + event. + name_localizations: Optional[Dict[:class:`str`, :class:`str`]] + The name localizations for this command. The values of this should be ``"locale": "name"``. See + `here `_ for a list of valid locales. + description_localizations: Optional[Dict[:class:`str`, :class:`str`]] + The description localizations for this command. The values of this should be ``"locale": "description"``. + See `here `_ for a list of valid locales. Returns -------- @@ -1067,9 +1112,9 @@ def create_subgroup( if self.parent is not None: # TODO: Improve this error message - raise Exception("Subcommands can only be nested once") + raise Exception("a subgroup cannot have a subgroup") - sub_command_group = SlashCommandGroup(name, description, guild_ids, parent=self) + sub_command_group = SlashCommandGroup(name, description, guild_ids, parent=self, **kwargs) self.subcommands.append(sub_command_group) return sub_command_group @@ -1300,7 +1345,7 @@ def to_dict(self) -> Dict[str, Union[str, int]]: } if self.guild_only is not None: - as_dict["guild_only"] = self.guild_only + as_dict["dm_permission"] = not self.guild_only if self.default_member_permissions is not None: as_dict["default_member_permissions"] = self.default_member_permissions.value @@ -1638,20 +1683,18 @@ def validate_chat_input_name(name: Any, locale: Optional[str] = None): # Must meet the regex ^[-_\w\d\u0901-\u097D\u0E00-\u0E7F]{1,32}$ if locale is not None and locale not in valid_locales: raise ValidationError( - f"Locale '{locale}' is not a valid locale, " f"see {docs}/reference#locales for list of supported locales." + f"Locale '{locale}' is not a valid locale, see {docs}/reference#locales for list of supported locales." ) error = None - if not isinstance(name, str) or not re.match(r"^[\w-]{1,32}$", name): + if not isinstance(name, str): error = TypeError(f'Command names and options must be of type str. Received "{name}"') elif not re.match(r"^[-_\w\d\u0901-\u097D\u0E00-\u0E7F]{1,32}$", name): error = ValidationError( - r"Command names and options must follow the regex \"^[-_\w\d\u0901-\u097D\u0E00-\u0E7F]{1,32}$\". For more information, see " - f"{docs}/interactions/application-commands#application-command-object-application-command-naming. " - f'Received "{name}"' + r"Command names and options must follow the regex \"^[-_\w\d\u0901-\u097D\u0E00-\u0E7F]{1,32}$\". " + f"For more information, see {docs}/interactions/application-commands#application-command-object-" + f'application-command-naming. Received "{name}"' ) - elif not 1 <= len(name) <= 32: - error = ValidationError(f'Command names and options must be 1-32 characters long. Received "{name}"') - elif name.lower() != name: # Can't use islower() as it fails if none of the chars can be lower. See #512. + elif name.lower() != name: # Can't use islower() as it fails if none of the chars can be lowered. See #512. error = ValidationError(f'Command names and options must be lowercase. Received "{name}"') if error: @@ -1663,7 +1706,7 @@ def validate_chat_input_name(name: Any, locale: Optional[str] = None): def validate_chat_input_description(description: Any, locale: Optional[str] = None): if locale is not None and locale not in valid_locales: raise ValidationError( - f"Locale '{locale}' is not a valid locale, " f"see {docs}/reference#locales for list of supported locales." + f"Locale '{locale}' is not a valid locale, see {docs}/reference#locales for list of supported locales." ) error = None if not isinstance(description, str): diff --git a/discord/commands/options.py b/discord/commands/options.py index 13156325fc..d183371b1b 100644 --- a/discord/commands/options.py +++ b/discord/commands/options.py @@ -26,6 +26,8 @@ from typing import Any, Dict, List, Literal, Optional, Union from enum import Enum +from ..abc import GuildChannel +from ..channel import TextChannel, VoiceChannel, StageChannel, CategoryChannel, Thread from ..enums import ChannelType, SlashCommandOptionType, Enum as DiscordEnum __all__ = ( @@ -35,16 +37,24 @@ "option", ) -channel_type_map = { - "TextChannel": ChannelType.text, - "VoiceChannel": ChannelType.voice, - "StageChannel": ChannelType.stage_voice, - "CategoryChannel": ChannelType.category, - "Thread": ChannelType.public_thread, +CHANNEL_TYPE_MAP = { + TextChannel: ChannelType.text, + VoiceChannel: ChannelType.voice, + StageChannel: ChannelType.stage_voice, + CategoryChannel: ChannelType.category, + Thread: ChannelType.public_thread, } class ThreadOption: + """Represents a class that can be passed as the input_type for an Option class. + + Parameters + ----------- + thread_type: Literal["public", "private", "news"] + The thread type to expect for this options input. + """ + def __init__(self, thread_type: Literal["public", "private", "news"]): type_map = { "public": ChannelType.public_thread, @@ -53,10 +63,6 @@ def __init__(self, thread_type: Literal["public", "private", "news"]): } self._type = type_map[thread_type] - @property - def __name__(self): - return "ThreadOption" - class Option: """Represents a selectable option for a slash command. @@ -102,6 +108,12 @@ async def hello( max_value: Optional[:class:`int`] The maximum value that can be entered. Only applies to Options with an input_type of ``int`` or ``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``. + 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``. 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`. @@ -130,8 +142,9 @@ def __init__(self, input_type: Any = str, /, description: Optional[str] = None, if not isinstance(input_type, SlashCommandOptionType): if hasattr(input_type, "convert"): self.converter = input_type + self._raw_type = str input_type = SlashCommandOptionType.string - elif issubclass(input_type, (Enum, DiscordEnum)): + 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] @@ -156,13 +169,13 @@ def __init__(self, input_type: Any = str, /, description: Optional[str] = None, else: input_type = (input_type,) for i in input_type: - if i.__name__ == "GuildChannel": + if i is GuildChannel: continue if isinstance(i, ThreadOption): self.channel_types.append(i._type) continue - channel_type = channel_type_map[i.__name__] + channel_type = CHANNEL_TYPE_MAP[i] self.channel_types.append(channel_type) input_type = _type self.input_type = input_type @@ -187,14 +200,40 @@ def __init__(self, input_type: Any = str, /, description: Optional[str] = None, minmax_types = (type(None),) minmax_typehint = Optional[Union[minmax_types]] # type: ignore + if self.input_type == SlashCommandOptionType.string: + minmax_length_types = (int, type(None)) + else: + minmax_length_types = (type(None),) + minmax_length_typehint = Optional[Union[minmax_length_types]] # type: ignore + 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) - if not isinstance(self.min_value, minmax_types) and self.min_value is not None: + if (input_type != SlashCommandOptionType.integer and 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): + 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): raise TypeError(f'Expected {minmax_typehint} for min_value, got "{type(self.min_value).__name__}"') - if not (isinstance(self.max_value, minmax_types) or self.min_value is None): + if self.max_value is not None and not isinstance(self.max_value, minmax_types): raise TypeError(f'Expected {minmax_typehint} for max_value, got "{type(self.max_value).__name__}"') + 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__}"') + 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__}"') + if self.max_length < 1 or self.max_length > 6000: + raise AttributeError("max_length must between 1 and 6000 (inclusive)") + self.autocomplete = kwargs.pop("autocomplete", None) self.name_localizations = kwargs.pop("name_localizations", None) @@ -219,6 +258,10 @@ def to_dict(self) -> Dict: as_dict["min_value"] = self.min_value if self.max_value is not None: as_dict["max_value"] = self.max_value + if self.min_length is not None: + as_dict["min_length"] = self.min_length + if self.max_length is not None: + as_dict["max_length"] = self.max_length return as_dict diff --git a/discord/commands/permissions.py b/discord/commands/permissions.py index 71d9132c5c..86bd0df63a 100644 --- a/discord/commands/permissions.py +++ b/discord/commands/permissions.py @@ -71,6 +71,8 @@ async def test(ctx): def inner(command: Callable): if isinstance(command, ApplicationCommand): + if command.parent is not None: + raise RuntimeError("Permission restrictions can only be set on top-level commands") command.default_member_permissions = Permissions(**perms) else: command.__default_member_permissions__ = Permissions(**perms) diff --git a/discord/enums.py b/discord/enums.py index 9920d70a86..950d33cbf1 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -232,6 +232,7 @@ class MessageType(Enum): thread_starter_message = 21 guild_invite_reminder = 22 context_menu_command = 23 + auto_moderation_action = 24 class VoiceRegion(Enum): @@ -381,6 +382,10 @@ class AuditLogAction(Enum): thread_update = 111 thread_delete = 112 application_command_permission_update = 121 + auto_moderation_rule_create = 140 + auto_moderation_rule_update = 141 + auto_moderation_rule_delete = 142 + auto_moderation_block_message = 143 @property def category(self) -> Optional[AuditLogActionCategory]: @@ -433,6 +438,10 @@ def category(self) -> Optional[AuditLogActionCategory]: AuditLogAction.thread_update: AuditLogActionCategory.update, AuditLogAction.thread_delete: AuditLogActionCategory.delete, AuditLogAction.application_command_permission_update: AuditLogActionCategory.update, + AuditLogAction.auto_moderation_rule_create: AuditLogActionCategory.create, + AuditLogAction.auto_moderation_rule_update: AuditLogActionCategory.update, + AuditLogAction.auto_moderation_rule_delete: AuditLogActionCategory.delete, + AuditLogAction.auto_moderation_block_message: None, } return lookup[self] @@ -469,8 +478,10 @@ def target_type(self) -> Optional[str]: return "scheduled_event" elif v < 113: return "thread" - elif v < 121: + elif v < 122: return "application_command_permission" + elif v < 144: + return "auto_moderation_rule" class UserFlags(Enum): @@ -764,6 +775,29 @@ class ScheduledEventLocationType(Enum): voice = 2 external = 3 + +class AutoModTriggerType(Enum): + keyword = 1 + harmful_link = 2 + spam = 3 + keyword_preset = 4 + + +class AutoModEventType(Enum): + message_send = 1 + + +class AutoModActionType(Enum): + block_message = 1 + send_alert_message = 2 + timeout = 3 + + +class AutoModKeywordPresetType(Enum): + profanity = 1 + sexual_content = 2 + slurs = 3 + T = TypeVar("T") diff --git a/discord/ext/bridge/bot.py b/discord/ext/bridge/bot.py index 3657a0f92a..b13fe3cca9 100644 --- a/discord/ext/bridge/bot.py +++ b/discord/ext/bridge/bot.py @@ -36,7 +36,9 @@ class BotBase(ABC): - async def get_application_context(self, interaction: Interaction, cls=None) -> BridgeApplicationContext: + async def get_application_context( + self, interaction: Interaction, cls=None + ) -> BridgeApplicationContext: cls = cls if cls is not None else BridgeApplicationContext # Ignore the type hinting error here. BridgeApplicationContext is a subclass of ApplicationContext, and since # we gave it cls, it will be used instead. @@ -56,7 +58,7 @@ def add_bridge_command(self, command: BridgeCommand): command.add_to(self) # type: ignore def bridge_command(self, **kwargs): - """A shortcut decorator that invokes :func:`.bridge_command` and adds it to + """A shortcut decorator that invokes :func:`bridge_command` and adds it to the internal command list via :meth:`~.Bot.add_bridge_command`. Returns diff --git a/discord/ext/bridge/context.py b/discord/ext/bridge/context.py index 9d17316ec2..5024d370dd 100644 --- a/discord/ext/bridge/context.py +++ b/discord/ext/bridge/context.py @@ -37,12 +37,12 @@ class BridgeContext(ABC): """ - The base context class for compatibility commands. This class is an :class:`ABC` (abstract base class), which is - subclassed by :class:`BridgeExtContext` and :class:`BridgeApplicationContext`. The methods in this class are meant - to give parity between the two contexts, while still allowing for all of their functionality. + The base context class for compatibility commands. This class is an :term:`abstract base class` (also known as an + ``abc``), which is subclassed by :class:`BridgeExtContext` and :class:`BridgeApplicationContext`. The methods in + 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 :meth:`isinstance` + :class:`BridgeApplicationContext`. Since they are two separate classes, it is quite simple to use :func:`isinstance` 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: @@ -60,7 +60,9 @@ async def example(ctx: BridgeContext): """ @abstractmethod - async def _respond(self, *args, **kwargs) -> Union[Union[Interaction, WebhookMessage], Message]: + async def _respond( + self, *args, **kwargs + ) -> Union[Union[Interaction, WebhookMessage], Message]: ... @abstractmethod @@ -71,16 +73,20 @@ async def _defer(self, *args, **kwargs) -> None: async def _edit(self, *args, **kwargs) -> Union[InteractionMessage, Message]: ... - async def respond(self, *args, **kwargs) -> Union[Union[Interaction, WebhookMessage], Message]: + async def respond( + self, *args, **kwargs + ) -> Union[Union[Interaction, WebhookMessage], Message]: """|coro| Responds to the command with the respective response type to the current context. In :class:`BridgeExtContext`, - this will be :meth:`~.ExtContext.reply` while in :class:`BridgeApplicationContext`, this will be + this will be :meth:`~.Context.reply` while in :class:`BridgeApplicationContext`, this will be :meth:`~.ApplicationContext.respond`. """ return await self._respond(*args, **kwargs) - async def reply(self, *args, **kwargs) -> Union[Union[Interaction, WebhookMessage], Message]: + async def reply( + self, *args, **kwargs + ) -> Union[Union[Interaction, WebhookMessage], Message]: """|coro| Alias for :meth:`~.BridgeContext.respond`. @@ -91,8 +97,8 @@ async def defer(self, *args, **kwargs) -> None: """|coro| Defers the command with the respective approach to the current context. In :class:`BridgeExtContext`, this will - be :meth:`~.ExtContext.trigger_typing` while in :class:`BridgeApplicationContext`, this will be - :meth:`~.ApplicationContext.defer`. + be :meth:`~discord.abc.Messageable.trigger_typing` while in :class:`BridgeApplicationContext`, this will be + :attr:`~.ApplicationContext.defer`. .. note:: There is no ``trigger_typing`` alias for this method. ``trigger_typing`` will always provide the same @@ -105,22 +111,26 @@ async def edit(self, *args, **kwargs) -> Union[InteractionMessage, Message]: Edits the original response message with the respective approach to the current context. In :class:`BridgeExtContext`, this will have a custom approach where :meth:`.respond` caches the message to be - edited here. In :class:`BridgeApplicationContext`, this will be :meth:`~.ApplicationContext.edit`. + edited here. In :class:`BridgeApplicationContext`, this will be :attr:`~.ApplicationContext.edit`. """ return await self._edit(*args, **kwargs) - def _get_super(self, attr: str) -> Optional[Any]: + def _get_super(self, attr: str) -> Any: return getattr(super(), attr) class BridgeApplicationContext(BridgeContext, ApplicationContext): """ The application context class for compatibility commands. This class is a subclass of :class:`BridgeContext` and - :class:`ApplicationContext`. This class is meant to be used with :class:`BridgeCommand`. + :class:`~.ApplicationContext`. This class is meant to be used with :class:`BridgeCommand`. .. versionadded:: 2.0 """ + def __init__(self, *args, **kwargs): + # This is needed in order to represent the correct class init signature on the docs + super().__init__(*args, **kwargs) + async def _respond(self, *args, **kwargs) -> Union[Interaction, WebhookMessage]: return await self._get_super("respond")(*args, **kwargs) @@ -134,7 +144,7 @@ async def _edit(self, *args, **kwargs) -> InteractionMessage: class BridgeExtContext(BridgeContext, Context): """ The ext.commands context class for compatibility commands. This class is a subclass of :class:`BridgeContext` and - :class:`Context`. This class is meant to be used with :class:`BridgeCommand`. + :class:`~.Context`. This class is meant to be used with :class:`BridgeCommand`. .. versionadded:: 2.0 """ @@ -144,18 +154,23 @@ def __init__(self, *args, **kwargs): self._original_response_message: Optional[Message] = None async def _respond(self, *args, **kwargs) -> Message: + kwargs.pop("ephemeral", None) message = await self._get_super("reply")(*args, **kwargs) if self._original_response_message is None: self._original_response_message = message return message async def _defer(self, *args, **kwargs) -> None: + kwargs.pop("ephemeral", None) return await self._get_super("trigger_typing")(*args, **kwargs) - async def _edit(self, *args, **kwargs) -> Message: - return await self._original_response_message.edit(*args, **kwargs) + async def _edit(self, *args, **kwargs) -> Optional[Message]: + if self._original_response_message: + return await self._original_response_message.edit(*args, **kwargs) - async def delete(self, *, delay: Optional[float] = None, reason: Optional[str] = None) -> None: + async def delete( + self, *, delay: Optional[float] = None, reason: Optional[str] = None + ) -> None: """|coro| Deletes the original response message, if it exists. @@ -169,9 +184,3 @@ async def delete(self, *, delay: Optional[float] = None, reason: Optional[str] = """ if self._original_response_message: await self._original_response_message.delete(delay=delay, reason=reason) - - -if TYPE_CHECKING: - # This is a workaround for mypy not being able to resolve the type of BridgeCommand. - class BridgeContext(ApplicationContext, Context): - ... diff --git a/discord/ext/bridge/core.py b/discord/ext/bridge/core.py index f9aa6f7548..5b55ee5677 100644 --- a/discord/ext/bridge/core.py +++ b/discord/ext/bridge/core.py @@ -22,7 +22,8 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ -from typing import Any, Union +from typing import Any, List, Union +import asyncio import discord.commands.options from discord.commands import Option, SlashCommand @@ -62,22 +63,26 @@ class BridgeExtCommand(Command): class BridgeCommand: - def __init__(self, callback, **kwargs): - """ - This is the base class for commands that are compatible with both traditional (prefix-based) commands and slash - commands. + """ + This is the base class for commands that are compatible with both traditional (prefix-based) commands and slash + commands. - Parameters - ---------- - callback: Callable[[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[str, Any]] - Keyword arguments that are directly passed to the respective command constructors. - """ + 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. + """ + + def __init__(self, callback, **kwargs): self.callback = callback self.kwargs = kwargs + self.ext_command = BridgeExtCommand(self.callback, **self.kwargs) + self.application_command = BridgeSlashCommand(self.callback, **self.kwargs) + def get_ext_command(self): """A method to get the ext.commands version of this command. @@ -86,8 +91,7 @@ def get_ext_command(self): :class:`BridgeExtCommand` The respective traditional (prefix-based) version of the command. """ - command = BridgeExtCommand(self.callback, **self.kwargs) - return command + return self.ext_command def get_application_command(self): """A method to get the discord.commands version of this command. @@ -97,19 +101,105 @@ def get_application_command(self): :class:`BridgeSlashCommand` The respective slash command version of the command. """ - command = BridgeSlashCommand(self.callback, **self.kwargs) - return command + return self.application_command def add_to(self, bot: Union[ExtBot, ExtAutoShardedBot]) -> None: """Adds the command to a bot. Parameters ---------- - bot: Union[:class:`ExtBot`, :class:`ExtAutoShardedBot`] + bot: Union[:class:`.Bot`, :class:`.AutoShardedBot`] The bot to add the command to. """ - bot.add_command(self.get_ext_command()) - bot.add_application_command(self.get_application_command()) + + bot.add_command(self.ext_command) + bot.add_application_command(self.application_command) + + def error(self, coro): + """A decorator that registers a coroutine as a local error handler. + + This error handler is limited to the command it is defined to. + However, higher scope handlers (per-cog and global) are still + invoked afterwards as a catch-all. This handler also functions as + the handler for both the prefixed and slash versions of the command. + + This error handler takes two parameters, a :class:`.BridgeContext` and + a :class:`~discord.DiscordException`. + + Parameters + ----------- + coro: :ref:`coroutine ` + The coroutine to register as the local error handler. + + Raises + ------- + 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 + + return coro + + def before_invoke(self, coro): + """A decorator that registers a coroutine as a pre-invoke hook. + + This hook is called directly before the command is called, making + it useful for any sort of set up required. This hook is called + for both the prefixed and slash versions of the command. + + This pre-invoke hook takes a sole parameter, a :class:`.BridgeContext`. + + Parameters + ----------- + coro: :ref:`coroutine ` + The coroutine to register as the pre-invoke hook. + + Raises + ------- + 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 + + return coro + + def after_invoke(self, coro): + """A decorator that registers a coroutine as a post-invoke hook. + + This hook is called directly after the command is called, making it + useful for any sort of clean up required. This hook is called for + both the prefixed and slash versions of the command. + + This post-invoke hook takes a sole parameter, a :class:`.BridgeContext`. + + Parameters + ----------- + coro: :ref:`coroutine ` + The coroutine to register as the post-invoke hook. + + Raises + ------- + TypeError + The coroutine passed is not actually a coroutine. + """ + + if not asyncio.iscoroutinefunction(coro): + raise TypeError("The post-invoke hook must be a coroutine.") + + self.ext_command.after_invoke = coro + self.application_command.after_invoke = coro + + return coro def bridge_command(**kwargs): @@ -117,7 +207,7 @@ def bridge_command(**kwargs): Parameters ---------- - kwargs: Optional[Dict[str, Any]] + kwargs: Optional[Dict[:class:`str`, Any]] Keyword arguments that are directly passed to the respective command constructors. """ @@ -138,35 +228,42 @@ async def convert(self, ctx, argument): def attachment_callback(*args): # pylint: disable=unused-argument - raise ValueError("Attachments are not supported for compatibility commands.") + raise ValueError("Attachments are not supported for bridge commands.") + + +BRIDGE_CONVERTER_MAPPING = { + SlashCommandOptionType.string: str, + SlashCommandOptionType.integer: int, + SlashCommandOptionType.boolean: lambda val: _convert_to_bool(str(val)), + SlashCommandOptionType.user: UserConverter, + SlashCommandOptionType.channel: GuildChannelConverter, + SlashCommandOptionType.role: RoleConverter, + SlashCommandOptionType.mentionable: MentionableConverter, + SlashCommandOptionType.number: float, + SlashCommandOptionType.attachment: attachment_callback, +} class BridgeOption(Option, Converter): - async def convert(self, ctx, argument) -> Any: + async def convert(self, ctx, argument: str) -> Any: try: if self.converter is not None: converted = await self.converter.convert(ctx, argument) else: - mapping = { - SlashCommandOptionType.string: str, - SlashCommandOptionType.integer: int, - SlashCommandOptionType.boolean: lambda val: _convert_to_bool(str(val)), - SlashCommandOptionType.user: UserConverter, - SlashCommandOptionType.channel: GuildChannelConverter, - SlashCommandOptionType.role: RoleConverter, - SlashCommandOptionType.mentionable: MentionableConverter, - SlashCommandOptionType.number: float, - SlashCommandOptionType.attachment: attachment_callback, - } - converter = mapping[self.input_type] + converter = BRIDGE_CONVERTER_MAPPING[self.input_type] if issubclass(converter, Converter): - converted = await converter().convert(ctx, argument) + converted = await converter().convert(ctx, argument) # type: ignore # protocol class else: converted = converter(argument) + if self.choices: - choices_names = [choice.name for choice in self.choices] - if converted in choices_names: - converted = get(self.choices, name=converted).value + choices_names: List[Union[str, int, float]] = [ + choice.name for choice in self.choices + ] + if converted in choices_names and ( + choice := get(self.choices, name=converted) + ): + converted = choice.value else: choices = [choice.value for choice in self.choices] if converted not in choices: diff --git a/discord/ext/commands/converter.py b/discord/ext/commands/converter.py index c40aaef90a..1de137b2ce 100644 --- a/discord/ext/commands/converter.py +++ b/discord/ext/commands/converter.py @@ -62,6 +62,7 @@ "MessageConverter", "PartialMessageConverter", "TextChannelConverter", + "ForumChannelConverter", "InviteConverter", "GuildConverter", "RoleConverter", @@ -160,7 +161,7 @@ class ObjectConverter(IDConverter[discord.Object]): """ async def convert(self, ctx: Context, argument: str) -> discord.Object: - match = self._get_id_match(argument) or re.match(r"<(?:@(?:!|&)?|#)([0-9]{15,20})>$", argument) + match = self._get_id_match(argument) or re.match(r"<(?:@[!&]?|#)([0-9]{15,20})>$", argument) if match is None: raise ObjectNotFound(argument) @@ -563,6 +564,25 @@ async def convert(self, ctx: Context, argument: str) -> discord.CategoryChannel: return GuildChannelConverter._resolve_channel(ctx, argument, "categories", discord.CategoryChannel) +class ForumChannelConverter(IDConverter[discord.ForumChannel]): + """Converts to a :class:`~discord.ForumChannel`. + + All lookups are via the local guild. If in a DM context, then the lookup + is done by the global cache. + + The lookup strategy is as follows (in order): + + 1. Lookup by ID. + 2. Lookup by mention. + 3. Lookup by name + + .. versionadded:: 2.0 + """ + + async def convert(self, ctx: Context, argument: str) -> discord.ForumChannel: + return GuildChannelConverter._resolve_channel(ctx, argument, "forum_channels", discord.ForumChannel) + + class ThreadConverter(IDConverter[discord.Thread]): """Coverts to a :class:`~discord.Thread`. @@ -767,7 +787,7 @@ class EmojiConverter(IDConverter[discord.Emoji]): """ async def convert(self, ctx: Context, argument: str) -> discord.Emoji: - match = self._get_id_match(argument) or re.match(r"$", argument) + match = self._get_id_match(argument) or re.match(r"$", argument) result = None bot = ctx.bot guild = ctx.guild @@ -801,7 +821,7 @@ class PartialEmojiConverter(Converter[discord.PartialEmoji]): """ async def convert(self, ctx: Context, argument: str) -> discord.PartialEmoji: - match = re.match(r"<(a?):([a-zA-Z0-9\_]{1,32}):([0-9]{15,20})>$", argument) + match = re.match(r"<(a?):(\w{1,32}):([0-9]{15,20})>$", argument) if match: emoji_animated = bool(match.group(1)) @@ -1043,6 +1063,7 @@ def is_generic_type(tp: Any, *, _GenericAlias: Type = _GenericAlias) -> bool: discord.Emoji: EmojiConverter, discord.PartialEmoji: PartialEmojiConverter, discord.CategoryChannel: CategoryChannelConverter, + discord.ForumChannel: ForumChannelConverter, discord.Thread: ThreadConverter, discord.abc.GuildChannel: GuildChannelConverter, discord.GuildSticker: GuildStickerConverter, diff --git a/discord/ext/commands/core.py b/discord/ext/commands/core.py index 676bd34b02..582c6aa73b 100644 --- a/discord/ext/commands/core.py +++ b/discord/ext/commands/core.py @@ -29,6 +29,7 @@ import functools import inspect import types + from typing import ( TYPE_CHECKING, Any, @@ -67,6 +68,8 @@ DynamicCooldownMapping, MaxConcurrency, ) +from ...enums import ChannelType + from .errors import * if TYPE_CHECKING: @@ -320,8 +323,14 @@ def __new__(cls: Type[CommandT], *args: Any, **kwargs: Any) -> CommandT: def __init__( self, func: Union[ - Callable[Concatenate[CogT, ContextT, P], Coro[T]], - Callable[Concatenate[ContextT, P], Coro[T]], + Callable[ + [Concatenate[CogT, ContextT, P]], + Coro[T] + ], + Callable[ + [Concatenate[ContextT, P]], + Coro[T] + ], ], **kwargs: Any, ): @@ -414,15 +423,30 @@ def __init__( @property def callback( self, - ) -> Union[Callable[Concatenate[CogT, Context, P], Coro[T]], Callable[Concatenate[Context, P], Coro[T]],]: + ) -> Union[ + Callable[ + [Concatenate[CogT, Context, P]], + Coro[T] + ], + Callable[ + [Concatenate[Context, P]], + Coro[T] + ], + ]: return self._callback @callback.setter def callback( self, function: Union[ - Callable[Concatenate[CogT, Context, P], Coro[T]], - Callable[Concatenate[Context, P], Coro[T]], + Callable[ + [Concatenate[CogT, Context, P]], + Coro[T] + ], + Callable[ + [Concatenate[Context, P]], + Coro[T] + ], ], ) -> None: self._callback = function @@ -1343,8 +1367,14 @@ def command( ) -> Callable[ [ Union[ - Callable[Concatenate[CogT, ContextT, P], Coro[T]], - Callable[Concatenate[ContextT, P], Coro[T]], + Callable[ + [Concatenate[CogT, ContextT, P]], + Coro[T] + ], + Callable[ + [Concatenate[ContextT, P]], + Coro[T] + ], ] ], Command[CogT, P, T], @@ -1358,7 +1388,14 @@ def command( cls: Type[CommandT] = ..., *args: Any, **kwargs: Any, - ) -> Callable[[Callable[Concatenate[ContextT, P], Coro[Any]]], CommandT]: + ) -> Callable[ + [Callable[ + [Concatenate[ContextT, P]], + Coro[Any] + ] + ], + CommandT + ]: ... def command( @@ -1367,7 +1404,15 @@ def command( cls: Type[CommandT] = MISSING, *args: Any, **kwargs: Any, - ) -> Callable[[Callable[Concatenate[ContextT, P], Coro[Any]]], CommandT]: + ) -> Callable[ + [ + Callable[ + [Concatenate[ContextT, P]], + Coro[Any] + ] + ], + CommandT + ]: """A shortcut decorator that invokes :func:`.command` and adds it to the internal command list via :meth:`~.GroupMixin.add_command`. @@ -1377,7 +1422,7 @@ def command( A decorator that converts the provided method into a Command, adds it to the bot, then returns it. """ - def decorator(func: Callable[Concatenate[ContextT, P], Coro[Any]]) -> CommandT: + def decorator(func: Callable[[Concatenate[ContextT, P]], Coro[Any]]) -> CommandT: kwargs.setdefault("parent", self) result = command(name=name, cls=cls, *args, **kwargs)(func) self.add_command(result) @@ -1395,8 +1440,8 @@ def group( ) -> Callable[ [ Union[ - Callable[Concatenate[CogT, ContextT, P], Coro[T]], - Callable[Concatenate[ContextT, P], Coro[T]], + Callable[[Concatenate[CogT, ContextT, P]], Coro[T]], + Callable[[Concatenate[ContextT, P]], Coro[T]], ] ], Group[CogT, P, T], @@ -1410,7 +1455,7 @@ def group( cls: Type[GroupT] = ..., *args: Any, **kwargs: Any, - ) -> Callable[[Callable[Concatenate[ContextT, P], Coro[Any]]], GroupT]: + ) -> Callable[[Callable[[Concatenate[ContextT, P]], Coro[Any]]], GroupT]: ... def group( @@ -1419,7 +1464,7 @@ def group( cls: Type[GroupT] = MISSING, *args: Any, **kwargs: Any, - ) -> Callable[[Callable[Concatenate[ContextT, P], Coro[Any]]], GroupT]: + ) -> Callable[[Callable[[Concatenate[ContextT, P]], Coro[Any]]], GroupT]: """A shortcut decorator that invokes :func:`.group` and adds it to the internal command list via :meth:`~.GroupMixin.add_command`. @@ -1429,7 +1474,7 @@ def group( A decorator that converts the provided method into a Group, adds it to the bot, then returns it. """ - def decorator(func: Callable[Concatenate[ContextT, P], Coro[Any]]) -> GroupT: + def decorator(func: Callable[[Concatenate[ContextT, P]], Coro[Any]]) -> GroupT: kwargs.setdefault("parent", self) result = group(name=name, cls=cls, *args, **kwargs)(func) self.add_command(result) @@ -1553,6 +1598,23 @@ async def reinvoke(self, ctx: Context, *, call_hooks: bool = False) -> None: # Decorators +@overload # for py 3.10 +def command( + name: str = ..., + cls: Type[Command[CogT, P, T]] = ..., + **attrs: Any, +) -> Callable[ + [ + Union[ + Callable[Concatenate[CogT, ContextT, P]], Coro[T], + Callable[Concatenate[ContextT, P]], Coro[T], + ] + ], + Command[CogT, P, T], +]: + ... + + @overload def command( name: str = ..., @@ -1561,8 +1623,8 @@ def command( ) -> Callable[ [ Union[ - Callable[Concatenate[CogT, ContextT, P], Coro[T]], - Callable[Concatenate[ContextT, P], Coro[T]], + Callable[[Concatenate[CogT, ContextT, P]], Coro[T]], + Callable[[Concatenate[ContextT, P]], Coro[T]], ] ], Command[CogT, P, T], @@ -1578,8 +1640,8 @@ def command( ) -> Callable[ [ Union[ - Callable[Concatenate[CogT, ContextT, P], Coro[Any]], - Callable[Concatenate[ContextT, P], Coro[Any]], + Callable[[Concatenate[CogT, ContextT, P]], Coro[Any]], + Callable[[Concatenate[ContextT, P]], Coro[Any]], ] ], CommandT, @@ -1592,8 +1654,8 @@ def command( ) -> Callable[ [ Union[ - Callable[Concatenate[ContextT, P], Coro[Any]], - Callable[Concatenate[CogT, ContextT, P], Coro[T]], + Callable[[Concatenate[ContextT, P]], Coro[Any]], + Callable[[Concatenate[CogT, ContextT, P]], Coro[T]], ] ], Union[Command[CogT, P, T], CommandT], @@ -1632,8 +1694,8 @@ def command( def decorator( func: Union[ - Callable[Concatenate[ContextT, P], Coro[Any]], - Callable[Concatenate[CogT, ContextT, P], Coro[Any]], + Callable[[Concatenate[ContextT, P]], Coro[Any]], + Callable[[Concatenate[CogT, ContextT, P]], Coro[Any]], ] ) -> CommandT: if isinstance(func, Command): @@ -1651,8 +1713,8 @@ def group( ) -> Callable[ [ Union[ - Callable[Concatenate[CogT, ContextT, P], Coro[T]], - Callable[Concatenate[ContextT, P], Coro[T]], + Callable[[Concatenate[CogT, ContextT, P]], Coro[T]], + Callable[[Concatenate[ContextT, P]], Coro[T]], ] ], Group[CogT, P, T], @@ -1668,8 +1730,8 @@ def group( ) -> Callable[ [ Union[ - Callable[Concatenate[CogT, ContextT, P], Coro[Any]], - Callable[Concatenate[ContextT, P], Coro[Any]], + Callable[[Concatenate[CogT, ContextT, P]], Coro[Any]], + Callable[[Concatenate[ContextT, P]], Coro[Any]], ] ], GroupT, @@ -1684,8 +1746,8 @@ def group( ) -> Callable[ [ Union[ - Callable[Concatenate[ContextT, P], Coro[Any]], - Callable[Concatenate[CogT, ContextT, P], Coro[T]], + Callable[[Concatenate[ContextT, P]], Coro[Any]], + Callable[[Concatenate[CogT, ContextT, P]], Coro[T]], ] ], Union[Group[CogT, P, T], GroupT], @@ -2029,6 +2091,8 @@ def has_permissions(**perms: bool) -> Callable[[T], T]: This check raises a special exception, :exc:`.MissingPermissions` that is inherited from :exc:`.CheckFailure`. + If the command is executed within a DM, it returns ``True``. + Parameters ------------ perms @@ -2079,7 +2143,13 @@ def bot_has_permissions(**perms: bool) -> Callable[[T], T]: def predicate(ctx: Context) -> bool: guild = ctx.guild me = guild.me if guild is not None else ctx.bot.user - permissions = ctx.channel.permissions_for(me) # type: ignore + if ctx.channel.type == ChannelType.private: + return True + + if hasattr(ctx, 'app_permissions'): + permissions = ctx.app_permissions + else: + permissions = ctx.channel.permissions_for(me) # type: ignore missing = [perm for perm, value in perms.items() if getattr(permissions, perm) != value] diff --git a/discord/ext/commands/flags.py b/discord/ext/commands/flags.py index d5c4718078..bc8e01e65f 100644 --- a/discord/ext/commands/flags.py +++ b/discord/ext/commands/flags.py @@ -342,9 +342,9 @@ def __new__( aliases = {key.casefold(): value.casefold() for key, value in aliases.items()} regex_flags = re.IGNORECASE - keys = list(re.escape(k) for k in flags) + keys = [re.escape(k) for k in flags] keys.extend(re.escape(a) for a in aliases) - keys = sorted(keys, key=lambda t: len(t), reverse=True) + keys = sorted(keys, key=len, reverse=True) joined = "|".join(keys) pattern = re.compile( diff --git a/discord/flags.py b/discord/flags.py index c17790e647..d7b11e16ce 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -970,6 +970,28 @@ def scheduled_events(self): - :meth:`Guild.get_scheduled_event` """ return 1 << 16 + + @flag_value + def auto_moderation_configuration(self): + """:class:`bool`: Whether guild auto moderation configuration events are enabled. + + This corresponds to the following events: + + - :func:`on_auto_moderation_rule_create` + - :func:`on_auto_moderation_rule_update` + - :func:`on_auto_moderation_rule_delete` + """ + return 1 << 20 + + @flag_value + def auto_moderation_execution(self): + """:class:`bool`: Whether guild auto moderation execution events are enabled. + + This corresponds to the following events: + + - :func:`on_auto_moderation_action_execution` + """ + return 1 << 21 @fill_with_flags() @@ -1066,6 +1088,15 @@ def joined(self): """ return 2 + @flag_value + def interaction(self): + """:class:`bool`: Whether to cache members obtained through interactions. + + This includes members received through + :class:`discord.Interaction` and :class:`discord.Option`. + """ + return 4 + @classmethod def from_intents(cls: Type[MemberCacheFlags], intents: Intents) -> MemberCacheFlags: """A factory method that creates a :class:`MemberCacheFlags` based on @@ -1083,6 +1114,7 @@ def from_intents(cls: Type[MemberCacheFlags], intents: Intents) -> MemberCacheFl """ self = cls.none() + self.interaction = True if intents.members: self.joined = True if intents.voice_states: diff --git a/discord/gateway.py b/discord/gateway.py index 1f9094e0e5..e6baa96424 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -395,11 +395,9 @@ async def identify(self): "d": { "token": self.token, "properties": { - "$os": sys.platform, - "$browser": "pycord", - "$device": "pycord", - "$referrer": "", - "$referring_domain": "", + "os": sys.platform, + "browser": "pycord", + "device": "pycord", }, "compress": True, "large_threshold": 250, diff --git a/discord/guild.py b/discord/guild.py index 41c6bac1f4..72f0ea1fbe 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -44,6 +44,7 @@ ) from . import abc, utils +from .automod import AutoModAction, AutoModRule, AutoModTriggerMetadata from .asset import Asset from .channel import * from .channel import _guild_channel_factory, _threaded_guild_channel_factory @@ -51,6 +52,8 @@ from .emoji import Emoji from .enums import ( AuditLogAction, + AutoModEventType, + AutoModTriggerType, ChannelType, ContentFilter, NotificationLevel, @@ -101,13 +104,14 @@ from .types.guild import Ban as BanPayload from .types.guild import Guild as GuildPayload from .types.guild import GuildFeature, MFALevel + from .types.member import Member as MemberPayload from .types.threads import Thread as ThreadPayload from .types.voice import GuildVoiceState from .voice_client import VoiceProtocol from .webhook import Webhook VocalGuildChannel = Union[VoiceChannel, StageChannel] - GuildChannel = Union[VoiceChannel, StageChannel, TextChannel, CategoryChannel] + GuildChannel = Union[VoiceChannel, StageChannel, TextChannel, ForumChannel, CategoryChannel] ByCategoryItem = Tuple[Optional[CategoryChannel], List[GuildChannel]] @@ -156,9 +160,6 @@ class Guild(Hashable): All stickers that the guild owns. .. versionadded:: 2.0 - region: :class:`VoiceRegion` - The region the guild belongs on. There is a chance that the region - will be a :class:`str` if the value is not recognised by the enumerator. afk_timeout: :class:`int` The timeout to get sent to the AFK channel. afk_channel: Optional[:class:`VoiceChannel`] @@ -205,6 +206,7 @@ class Guild(Hashable): - ``ANIMATED_BANNER``: Guild can upload an animated banner. - ``ANIMATED_ICON``: Guild can upload an animated icon. + - ``AUTO_MODERATION``: Guild has enabled the auto moderation system. - ``BANNER``: Guild can upload and use a banner. (i.e. :attr:`.banner`) - ``CHANNEL_BANNER``: Guild can upload and use a channel banners. - ``COMMERCE``: Guild can sell things using store channels, which have now been removed. @@ -279,7 +281,6 @@ class Guild(Hashable): "name", "id", "unavailable", - "region", "owner_id", "mfa_level", "emojis", @@ -320,8 +321,8 @@ class Guild(Hashable): ) _PREMIUM_GUILD_LIMITS: ClassVar[Dict[Optional[int], _GuildLimit]] = { - None: _GuildLimit(emoji=50, stickers=0, bitrate=96e3, filesize=8388608), - 0: _GuildLimit(emoji=50, stickers=0, bitrate=96e3, filesize=8388608), + None: _GuildLimit(emoji=50, stickers=5, bitrate=96e3, filesize=8388608), + 0: _GuildLimit(emoji=50, stickers=5, bitrate=96e3, filesize=8388608), 1: _GuildLimit(emoji=100, stickers=15, bitrate=128e3, filesize=8388608), 2: _GuildLimit(emoji=150, stickers=30, bitrate=256e3, filesize=52428800), 3: _GuildLimit(emoji=250, stickers=60, bitrate=384e3, filesize=104857600), @@ -353,6 +354,22 @@ def _voice_state_for(self, user_id: int, /) -> Optional[VoiceState]: def _add_member(self, member: Member, /) -> None: self._members[member.id] = member + def _get_and_update_member(self, payload: MemberPayload, user_id: int, cache_flag: bool, /) -> Member: + # we always get the member, and we only update if the cache_flag (this cache + # flag should always be MemberCacheFlag.interaction) is set to True + if user_id in self._members: + member = self.get_member(user_id) + member._update(payload) if cache_flag else None + else: + # NOTE: + # This is a fallback in case the member is not found in the guild's members. + # If this fallback occurs, multiple aspects of the Member + # class will be incorrect such as status and activities. + member = Member(guild=self, state=self._state, data=payload) # type: ignore + if cache_flag: + self._members[user_id] = member + return member + def _store_thread(self, payload: ThreadPayload, /) -> Thread: thread = Thread(guild=self, state=self._state, data=payload) self._threads[thread.id] = thread @@ -466,7 +483,6 @@ def _from_data(self, guild: GuildPayload) -> None: self._member_count: int = member_count self.name: str = guild.get("name") - self.region: VoiceRegion = try_enum(VoiceRegion, guild.get("region")) self.verification_level: VerificationLevel = try_enum(VerificationLevel, guild.get("verification_level")) self.default_notifications: NotificationLevel = try_enum( NotificationLevel, guild.get("default_message_notifications") @@ -533,7 +549,6 @@ def _from_data(self, guild: GuildPayload) -> None: for obj in guild.get("voice_states", []): self._update_voice_state(obj, int(obj["channel_id"])) - # TODO: refactor/remove? def _sync(self, data: GuildPayload) -> None: try: @@ -1575,7 +1590,6 @@ async def edit( splash: Optional[bytes] = MISSING, discovery_splash: Optional[bytes] = MISSING, community: bool = MISSING, - region: Optional[Union[str, VoiceRegion]] = MISSING, afk_channel: Optional[VoiceChannel] = MISSING, owner: Snowflake = MISSING, afk_timeout: int = MISSING, @@ -1634,8 +1648,6 @@ async def edit( community: :class:`bool` Whether the guild should be a Community guild. If set to ``True``\, both ``rules_channel`` and ``public_updates_channel`` parameters are required. - region: Union[:class:`str`, :class:`VoiceRegion`] - The new region for the guild's voice communication. afk_channel: Optional[:class:`VoiceChannel`] The new channel that is the AFK channel. Could be ``None`` for no AFK channel. afk_timeout: :class:`int` @@ -1762,9 +1774,6 @@ async def edit( fields["owner_id"] = owner.id - if region is not MISSING: - fields["region"] = str(region) - if verification_level is not MISSING: if not isinstance(verification_level, VerificationLevel): raise InvalidArgument("verification_level field must be of type VerificationLevel") @@ -3514,3 +3523,110 @@ async def create_scheduled_event( def scheduled_events(self) -> List[ScheduledEvent]: """List[:class:`.ScheduledEvent`]: A list of scheduled events in this guild.""" return list(self._scheduled_events.values()) + + async def fetch_auto_moderation_rules(self) -> List[AutoModRule]: + """|coro| + + Retrieves a list of auto moderation rules for this guild. + + Raises + ------- + HTTPException + Getting the auto moderation rules failed. + Forbidden + You do not have the Manage Guild permission. + + Returns + -------- + List[:class:`AutoModRule`] + The auto moderation rules for this guild. + """ + data = await self._state.http.get_auto_moderation_rules(self.id) + return [AutoModRule(state=self._state, data=rule) for rule in data] + + async def fetch_auto_moderation_rule(self, id: int) -> AutoModRule: + """|coro| + + Retrieves a :class:`AutoModRule` from rule ID. + + Raises + ------- + HTTPException + Getting the auto moderation rule failed. + Forbidden + You do not have the Manage Guild permission. + + Returns + -------- + :class:`AutoModRule` + The requested auto moderation rule. + """ + data = await self._state.http.get_auto_moderation_rule(self.id, id) + return AutoModRule(state=self._state, data=data) + + async def create_auto_moderation_rule( + self, + *, + name: str, + event_type: AutoModEventType, + trigger_type: AutoModTriggerType, + trigger_metadata: AutoModTriggerMetadata, + actions: List[AutoModAction], + enabled: bool = False, + exempt_roles: List[Snowflake] = None, + exempt_channels: List[Snowflake] = None, + reason: Optional[str] = None, + ) -> AutoModRule: + """ + Creates an auto moderation rule. + + Parameters + ----------- + name: :class:`str` + The name of the auto moderation rule. + event_type: :class:`AutoModEventType` + The type of event that triggers the rule. + trigger_type: :class:`AutoModTriggerType` + The rule's trigger type. + trigger_metadata: :class:`AutoModTriggerMetadata` + The rule's trigger metadata. + actions: List[:class:`AutoModAction`] + The actions to take when the rule is triggered. + enabled: :class:`bool` + Whether the rule is enabled. + exempt_roles: List[:class:`Snowflake`] + A list of roles that are exempt from the rule. + exempt_channels: List[:class:`Snowflake`] + A list of channels that are exempt from the rule. + reason: Optional[:class:`str`] + The reason for creating the rule. Shows up in the audit log. + + Raises + ------- + HTTPException + Creating the auto moderation rule failed. + Forbidden + You do not have the Manage Guild permission. + + Returns + -------- + :class:`AutoModRule` + The new auto moderation rule. + """ + payload = { + "name": name, + "event_type": event_type.value, + "trigger_type": trigger_type.value, + "trigger_metadata": trigger_metadata.to_dict(), + "actions": [a.to_dict() for a in actions], + "enabled": enabled, + } + + if exempt_roles: + payload["exempt_roles"] = [r.id for r in exempt_roles] + + if exempt_channels: + payload["exempt_channels"] = [c.id for c in exempt_channels] + + data = await self._state.http.create_auto_moderation_rule(self.id, payload) + return AutoModRule(state=self._state, data=data, reason=reason) diff --git a/discord/http.py b/discord/http.py index 08def4cf1e..4fce824082 100644 --- a/discord/http.py +++ b/discord/http.py @@ -71,6 +71,7 @@ from .types import ( appinfo, audit_log, + automod, channel, components, embed, @@ -899,7 +900,7 @@ def change_my_nickname( *, reason: Optional[str] = None, ) -> Response[member.Nickname]: - r = Route("PATCH", "/guilds/{guild_id}/members/@me/nick", guild_id=guild_id) + r = Route("PATCH", "/guilds/{guild_id}/members/@me", guild_id=guild_id) payload = { "nick": nickname, } @@ -1136,7 +1137,7 @@ def start_forum_thread( def join_thread(self, channel_id: Snowflake) -> Response[None]: return self.request( Route( - "POST", + "PUT", "/channels/{channel_id}/thread-members/@me", channel_id=channel_id, ) @@ -1294,10 +1295,9 @@ def get_guild(self, guild_id: Snowflake, *, with_counts=True) -> Response[guild. def delete_guild(self, guild_id: Snowflake) -> Response[None]: return self.request(Route("DELETE", "/guilds/{guild_id}", guild_id=guild_id)) - def create_guild(self, name: str, region: str, icon: Optional[str]) -> Response[guild.Guild]: + def create_guild(self, name: str, icon: Optional[str]) -> Response[guild.Guild]: payload = { "name": name, - "region": region, } if icon: payload["icon"] = icon @@ -1307,7 +1307,6 @@ def create_guild(self, name: str, region: str, icon: Optional[str]) -> Response[ def edit_guild(self, guild_id: Snowflake, *, reason: Optional[str] = None, **fields: Any) -> Response[guild.Guild]: valid_keys = ( "name", - "region", "icon", "afk_timeout", "owner_id", @@ -1384,13 +1383,13 @@ def delete_template(self, guild_id: Snowflake, code: str) -> Response[None]: ) ) - def create_from_template(self, code: str, name: str, region: str, icon: Optional[str]) -> Response[guild.Guild]: + def create_from_template(self, code: str, name: str, icon: Optional[str]) -> Response[guild.Guild]: payload = { "name": name, - "region": region, } if icon: payload["icon"] = icon + return self.request(Route("POST", "/guilds/templates/{code}", code=code), json=payload) def get_bans( @@ -2317,6 +2316,74 @@ def get_guild_command_permissions( ) return self.request(r) + # Guild Automod Rules + + def get_auto_moderation_rules( + self, + guild_id: Snowflake, + ) -> Response[List[automod.AutoModRule]]: + r = Route( + "GET", + "/guilds/{guild_id}/auto-moderation/rules", + guild_id=guild_id, + ) + return self.request(r) + + def get_auto_moderation_rule( + self, + guild_id: Snowflake, + rule_id: Snowflake, + ) -> Response[automod.AutoModRule]: + r = Route( + "GET", + "/guilds/{guild_id}/auto-moderation/rules/{rule_id}", + guild_id=guild_id, + rule_id=rule_id, + ) + return self.request(r) + + def create_auto_moderation_rule( + self, + guild_id: Snowflake, + payload: automod.CreateAutoModRule, + reason: Optional[str] = None, + ) -> Response[automod.AutoModRule]: + r = Route( + "POST", + "/guilds/{guild_id}/auto-moderation/rules", + guild_id=guild_id, + ) + return self.request(r, json=payload, reason=reason) + + def edit_auto_moderation_rule( + self, + guild_id: Snowflake, + rule_id: Snowflake, + payload: automod.EditAutoModRule, + reason: Optional[str] = None, + ) -> Response[automod.AutoModRule]: + r = Route( + "PATCH", + "/guilds/{guild_id}/auto-moderation/rules/{rule_id}", + guild_id=guild_id, + rule_id=rule_id, + ) + return self.request(r, json=payload, reason=reason) + + def delete_auto_moderation_rule( + self, + guild_id: Snowflake, + rule_id: Snowflake, + reason: Optional[str] = None, + ) -> Response[None]: + r = Route( + "DELETE", + "/guilds/{guild_id}/auto-moderation/rules/{rule_id}", + guild_id=guild_id, + rule_id=rule_id, + ) + return self.request(r, reason=reason) + # Interaction responses def _edit_webhook_helper( diff --git a/discord/interactions.py b/discord/interactions.py index beb3c37057..f481dba1b9 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -54,7 +54,7 @@ from .channel import ( CategoryChannel, - PartialMessageable, + ForumChannel, StageChannel, TextChannel, VoiceChannel, @@ -76,6 +76,7 @@ VoiceChannel, StageChannel, TextChannel, + ForumChannel, CategoryChannel, Thread, PartialMessageable, @@ -137,9 +138,11 @@ class Interaction: "custom_id", "_message_data", "_permissions", + "_app_permissions", "_state", "_session", "_original_message", + "_cs_app_permissions", "_cs_response", "_cs_followup", "_cs_channel", @@ -163,6 +166,7 @@ def _from_data(self, data: InteractionPayload): self.locale: Optional[str] = data.get("locale") self.guild_locale: Optional[str] = data.get("guild_locale") self.custom_id: Optional[str] = self.data.get("custom_id") if self.data is not None else None + self._app_permissions: int = int(data.get("app_permissions", 0)) self.message: Optional[Message] = None @@ -182,7 +186,8 @@ def _from_data(self, data: InteractionPayload): except KeyError: pass else: - self.user = Member(state=self._state, guild=guild, data=member) # type: ignore + cache_flag = self._state.member_cache_flags.interaction + self.user = guild._get_and_update_member(member, int(member["user"]["id"]), cache_flag) self._permissions = int(member.get("permissions", 0)) else: try: @@ -232,6 +237,11 @@ def permissions(self) -> Permissions: """ return Permissions(self._permissions) + @utils.cached_slot_property("_cs_app_permissions") + def app_permissions(self) -> Permissions: + """:class:`Permissions`: The resolved permissions of the application in the channel, including overwrites.""" + return Permissions(self._app_permissions) + @utils.cached_slot_property("_cs_response") def response(self) -> InteractionResponse: """:class:`InteractionResponse`: Returns an object responsible for handling responding to the interaction. @@ -490,7 +500,7 @@ def is_done(self) -> bool: """ return self._responded - async def defer(self, *, ephemeral: bool = False) -> None: + async def defer(self, *, ephemeral: bool = False, invisible: bool = True) -> None: """|coro| Defers the interaction response. @@ -498,11 +508,22 @@ async def defer(self, *, ephemeral: bool = False) -> None: This is typically used when the interaction is acknowledged and a secondary action will be done later. + This can only be used with the following interaction types: + - :attr:`InteractionType.application_command` + - :attr:`InteractionType.component` + - :attr:`InteractionType.modal_submit` + Parameters ----------- ephemeral: :class:`bool` Indicates whether the deferred message will eventually be ephemeral. - If ``True`` for interactions of type :attr:`InteractionType.component`, this will defer ephemerally. + This only applies to :attr:`InteractionType.application_command` interactions, or if ``invisible`` is ``False``. + invisible: :class:`bool` + Indicates whether the deferred type should be 'invisible' (:attr:`InteractionResponseType.deferred_message_update`) + instead of 'thinking' (:attr:`InteractionResponseType.deferred_channel_message`). + In the Discord UI, this is represented as the bot thinking of a response. You must + eventually send a followup message via :attr:`Interaction.followup` to make this thinking state go away. + This parameter does not apply to interactions of type :attr:`InteractionType.application_command`. Raises ------- @@ -517,16 +538,18 @@ async def defer(self, *, ephemeral: bool = False) -> None: defer_type: int = 0 data: Optional[Dict[str, Any]] = None parent = self._parent - if parent.type is InteractionType.component: - if ephemeral: - data = {"flags": 64} - defer_type = InteractionResponseType.deferred_channel_message.value - else: - defer_type = InteractionResponseType.deferred_message_update.value - elif parent.type in (InteractionType.application_command, InteractionType.modal_submit): + if parent.type is InteractionType.component or parent.type is InteractionType.modal_submit: + defer_type = ( + InteractionResponseType.deferred_message_update.value + if invisible + else InteractionResponseType.deferred_channel_message.value + ) + if not invisible and ephemeral: + data = {'flags': 64} + elif parent.type is InteractionType.application_command: defer_type = InteractionResponseType.deferred_channel_message.value if ephemeral: - data = {"flags": 64} + data = {'flags': 64} if defer_type: adapter = async_context.get() @@ -615,7 +638,7 @@ async def send_message( before deleting the message we just sent. file: :class:`File` The file to upload. - files: :class:`List[File]` + files: List[:class:`File`] A list of files to upload. Must be a maximum of 10. Raises diff --git a/discord/message.py b/discord/message.py index 32ca085309..b8e33a115d 100644 --- a/discord/message.py +++ b/discord/message.py @@ -657,6 +657,10 @@ class Message(Hashable): The guild that the message belongs to, if applicable. interaction: Optional[:class:`MessageInteraction`] The interaction associated with the message, if applicable. + thread: Optional[:class:`Thread`] + The thread created from this message, if applicable. + + .. versionadded:: 2.0 """ __slots__ = ( @@ -691,6 +695,7 @@ class Message(Hashable): "components", "guild", "interaction", + "thread", ) if TYPE_CHECKING: @@ -766,6 +771,12 @@ def __init__( except KeyError: self.interaction = None + self.thread: Optional[Thread] + try: + self.thread = Thread(guild=self.guild, state=self._state, data=data["thread"]) + except KeyError: + self.thread = None + for handler in ("author", "member", "mentions", "mention_roles"): try: getattr(self, f"_handle_{handler}")(data[handler]) @@ -1320,32 +1331,20 @@ async def edit( if file is not MISSING and files is not MISSING: raise InvalidArgument("cannot pass both file and files parameter to edit()") - if file is not MISSING: - if "attachments" not in payload: - # don't want it to remove any attachments when we just add a new file - payload["attachments"] = [a.to_dict() for a in self.attachments] - if not isinstance(file, File): - raise InvalidArgument("file parameter must be File") - - try: - data = await self._state.http.edit_files( - self.channel.id, - self.id, - files=[file], - **payload, - ) - finally: - file.close() - - elif files is not MISSING: - if len(files) > 10: - raise InvalidArgument("files parameter must be a list of up to 10 elements") - elif not all(isinstance(file, File) for file in files): - raise InvalidArgument("files parameter must be a list of File") + if file is not MISSING or files is not MISSING: + if file is not MISSING: + if not isinstance(file, File): + raise InvalidArgument("file parameter must be of type File") + files = [file] + else: + if len(files) > 10: + raise InvalidArgument("files parameter must be a list of up to 10 elements") + elif not all(isinstance(file, File) for file in files): + raise InvalidArgument("files parameter must be a list of File") + if "attachments" not in payload: # don't want it to remove any attachments when we just add a new file payload["attachments"] = [a.to_dict() for a in self.attachments] - try: data = await self._state.http.edit_files( self.channel.id, @@ -1602,13 +1601,16 @@ async def create_thread(self, *, name: str, auto_archive_duration: ThreadArchive default_auto_archive_duration: ThreadArchiveDuration = getattr( self.channel, "default_auto_archive_duration", 1440 ) + data = await self._state.http.start_thread_with_message( self.channel.id, self.id, name=name, auto_archive_duration=auto_archive_duration or default_auto_archive_duration, ) - return Thread(guild=self.guild, state=self._state, data=data) + + self.thread = Thread(guild=self.guild, state=self._state, data=data) + return self.thread async def reply(self, content: Optional[str] = None, **kwargs) -> Message: """|coro| @@ -1724,6 +1726,7 @@ class PartialMessage(Hashable): def __init__(self, *, channel: PartialMessageableChannel, id: int): if channel.type not in ( ChannelType.text, + ChannelType.voice, ChannelType.news, ChannelType.private, ChannelType.news_thread, diff --git a/discord/opus.py b/discord/opus.py index 0cbac13c6e..c4c1a5ae15 100644 --- a/discord/opus.py +++ b/discord/opus.py @@ -537,6 +537,7 @@ def run(self): try: data = self.decode_queue.pop(0) except IndexError: + time.sleep(0.001) continue try: diff --git a/discord/partial_emoji.py b/discord/partial_emoji.py index 68e224f358..18f6b06673 100644 --- a/discord/partial_emoji.py +++ b/discord/partial_emoji.py @@ -93,7 +93,7 @@ class PartialEmoji(_EmojiTag, AssetMixin): __slots__ = ("animated", "name", "id", "_state") - _CUSTOM_EMOJI_RE = re.compile(r"a)?:?(?P[A-Za-z0-9\_]+):(?P[0-9]{13,20})>?") + _CUSTOM_EMOJI_RE = re.compile(r"a)?:?(?P\w+):(?P[0-9]{13,20})>?") if TYPE_CHECKING: id: Optional[int] diff --git a/discord/raw_models.py b/discord/raw_models.py index b86ff27d00..9b8fef4415 100644 --- a/discord/raw_models.py +++ b/discord/raw_models.py @@ -28,15 +28,19 @@ import datetime from typing import TYPE_CHECKING, List, Optional, Set +from .automod import AutoModAction from .enums import ChannelType, try_enum if TYPE_CHECKING: + from .abc import MessageableChannel from .guild import Guild from .member import Member from .message import Message from .partial_emoji import PartialEmoji + from .state import ConnectionState from .threads import Thread from .types.raw_models import ( + AutoModActionExecutionEvent as AutoModActionExecution, BulkMessageDeleteEvent, IntegrationDeleteEvent, MessageDeleteEvent, @@ -61,6 +65,7 @@ "RawThreadDeleteEvent", "RawTypingEvent", "RawScheduledEventSubscription", + "AutoModActionExecutionEvent", ) @@ -386,3 +391,110 @@ def __init__(self, data: ScheduledEventSubscription, event_type: str): self.user_id: int = int(data["user_id"]) self.guild: Guild = None self.event_type: str = event_type + + +class AutoModActionExecutionEvent: + """Represents the payload for a :func:`auto_moderation_action_execution` + + .. versionadded:: 2.0 + + Attributes + ----------- + action: :class:`AutoModAction` + The action that was executed. + rule_id: :class:`int` + The ID of the rule that the action belongs to. + guild_id: :class:`int` + The ID of the guild that the action was executed in. + guild: Optional[:class:`Guild`] + The guild that the action was executed in, if cached. + user_id: :class:`int` + The ID of the user that triggered the action. + member: Optional[:class:`Member`] + The member that triggered the action, if cached. + channel_id: Optional[:class:`int`] + The ID of the channel in which the member's content was posted. + channel: Optional[Union[:class:`TextChannel`, :class:`Thread`, :class:`VoiceChannel`]] + The channel in which the member's content was posted, if cached. + message_id: Optional[:class:`int`] + The ID of the message that triggered the action. This is only available if the + message was not blocked. + message: Optional[:class:`Message`] + The message that triggered the action, if cached. + alert_system_message_id: Optional[:class:`int`] + The ID of the system auto moderation message that was posted as a result + of the action. + alert_system_message: Optional[:class:`Message`] + The system auto moderation message that was posted as a result of the action, + if cached. + content: :class:`str` + The content of the message that triggered the action. + matched_keyword: :class:`str` + The word or phrase configured that was matched in the content. + matched_content: :class:`str` + The substring in the content that was matched. + """ + + __slots__ = ( + "action", + "rule_id", + "guild_id", + "guild", + "user_id", + "member", + "content", + "matched_keyword", + "matched_content", + "channel_id", + "channel", + "message_id", + "message", + "alert_system_message_id", + "alert_system_message", + ) + + def __init__(self, state: ConnectionState, data: AutoModActionExecution) -> None: + self.action: AutoModAction = AutoModAction.from_dict(data["action"]) + self.rule_id: int = int(data["rule_id"]) + self.guild_id: int = int(data["guild_id"]) + self.guild: Optional[Guild] = state._get_guild(self.guild_id) + self.user_id: int = int(data["user_id"]) + self.content: str = data["content"] + self.matched_keyword: str = data["matched_keyword"] + self.matched_content: str = data["matched_content"] + + if self.guild: + self.member: Optional[Member] = self.guild.get_member(self.user_id) + else: + self.member: Optional[Member] = None + + try: + # I don't see why this would be optional, but it's documented as such + # so we should treat it that way + self.channel_id: Optional[int] = int(data["channel_id"]) + self.channel: Optional[MessageableChannel] = self.guild.get_channel_or_thread(self.channel_id) + except KeyError: + self.channel_id: Optional[int] = None + self.channel: Optional[MessageableChannel] = None + + try: + self.message_id: Optional[int] = int(data["message_id"]) + self.message: Optional[Message] = state._get_message(self.message_id) + except KeyError: + self.message_id: Optional[int] = None + self.message: Optional[Message] = None + + try: + self.alert_system_message_id: Optional[int] = int(data["alert_system_message_id"]) + self.alert_system_message: Optional[Message] = state._get_message(self.alert_system_message_id) + except KeyError: + self.alert_system_message_id: Optional[int] = None + self.alert_system_message: Optional[Message] = None + + def __repr__(self) -> str: + return ( + f"" + ) + diff --git a/discord/scheduled_events.py b/discord/scheduled_events.py index e24a839f88..95d2d75cc8 100644 --- a/discord/scheduled_events.py +++ b/discord/scheduled_events.py @@ -99,7 +99,7 @@ def __init__( self.value = value def __repr__(self) -> str: - return f"" + return f"" def __str__(self) -> str: return str(self.value) @@ -255,7 +255,7 @@ def cover(self) -> Optional[Asset]: return Asset._from_scheduled_event_cover( self._state, self.id, - self._image, + self._cover, ) async def edit( diff --git a/discord/state.py b/discord/state.py index c1571d9255..a0312f6ff2 100644 --- a/discord/state.py +++ b/discord/state.py @@ -49,6 +49,7 @@ from . import utils from .activity import BaseActivity +from .automod import AutoModAction, AutoModRule from .channel import * from .channel import _channel_factory from .emoji import Emoji @@ -616,6 +617,23 @@ def parse_resumed(self, data) -> None: def parse_application_command_permissions_update(self, data) -> None: # unsure what the implementation would be like pass + + def parse_auto_moderation_rule_create(self, data) -> None: + rule = AutoModRule(state=self, data=data) + self.dispatch("auto_moderation_rule_create", rule) + + def parse_auto_moderation_rule_update(self, data) -> None: + # somehow get a 'before' object? + rule = AutoModRule(state=self, data=data) + self.dispatch("auto_moderation_rule_update", rule) + + def parse_auto_moderation_rule_delete(self, data) -> None: + rule = AutoModRule(state=self, data=data) + self.dispatch("auto_moderation_rule_delete", rule) + + def parse_auto_moderation_action_execution(self, data) -> None: + event = AutoModActionExecutionEvent(self, data) + self.dispatch("auto_moderation_action_execution", event) def parse_message_create(self, data) -> None: channel, _ = self._get_guild_channel(data) @@ -948,6 +966,9 @@ def parse_thread_delete(self, data) -> None: guild._remove_thread(thread) # type: ignore self.dispatch("thread_delete", thread) + if (msg := thread.starting_message) is not None: + msg.thread = None + def parse_thread_list_sync(self, data) -> None: guild_id = int(data["guild_id"]) guild: Optional[Guild] = self._get_guild(guild_id) diff --git a/discord/template.py b/discord/template.py index e40cc4a277..f8f8986c24 100644 --- a/discord/template.py +++ b/discord/template.py @@ -27,7 +27,6 @@ from typing import TYPE_CHECKING, Any, Optional -from .enums import VoiceRegion from .guild import Guild from .utils import MISSING, _bytes_to_base64_data, parse_time @@ -167,7 +166,7 @@ def __repr__(self) -> str: f" creator={self.creator!r} source_guild={self.source_guild!r} is_dirty={self.is_dirty}>" ) - async def create_guild(self, name: str, region: Optional[VoiceRegion] = None, icon: Any = None) -> Guild: + async def create_guild(self, name: str, icon: Any = None) -> Guild: """|coro| Creates a :class:`.Guild` using the template. @@ -178,9 +177,6 @@ async def create_guild(self, name: str, region: Optional[VoiceRegion] = None, ic ---------- name: :class:`str` The name of the guild. - region: :class:`.VoiceRegion` - The region for the voice communication server. - Defaults to :attr:`.VoiceRegion.us_west`. icon: :class:`bytes` The :term:`py:bytes-like object` representing the icon. See :meth:`.ClientUser.edit` for more details on what is expected. @@ -201,10 +197,7 @@ async def create_guild(self, name: str, region: Optional[VoiceRegion] = None, ic if icon is not None: icon = _bytes_to_base64_data(icon) - region = region or VoiceRegion.us_west - region_value = region.value - - data = await self._state.http.create_from_template(self.code, name, region_value, icon) + data = await self._state.http.create_from_template(self.code, name, icon) return Guild(data=data, state=self._state) async def sync(self) -> Template: diff --git a/discord/threads.py b/discord/threads.py index e58668e2af..f232053251 100644 --- a/discord/threads.py +++ b/discord/threads.py @@ -86,6 +86,10 @@ class Thread(Messageable, Hashable): The guild the thread belongs to. id: :class:`int` The thread ID. + + .. note:: + This ID is the same as the thread starting message ID. + parent_id: :class:`int` The parent :class:`TextChannel` ID this thread belongs to. owner_id: :class:`int` @@ -168,16 +172,27 @@ def __str__(self) -> str: return self.name def _from_data(self, data: ThreadPayload): + # This data will always exist self.id = int(data["id"]) self.parent_id = int(data["parent_id"]) - self.owner_id = int(data["owner_id"]) self.name = data["name"] self._type = try_enum(ChannelType, data["type"]) + + # This data may be missing depending on how this object is being created + self.owner_id = int(data.get("owner_id")) if data.get("owner_id", None) is not None else None self.last_message_id = _get_as_snowflake(data, "last_message_id") self.slowmode_delay = data.get("rate_limit_per_user", 0) - self.message_count = data["message_count"] - self.member_count = data["member_count"] + self.message_count = data.get("message_count", None) + self.member_count = data.get("member_count", None) self.flags: ChannelFlags = ChannelFlags._from_value(data.get("flags", 0)) + + # Here, we try to fill in potentially missing data + if thread := self.guild.get_thread(self.id) and data.pop("_invoke_flag", False): + self.owner_id = thread.owner_id if self.owner_id is None else self.owner_id + self.last_message_id = thread.last_message_id if self.last_message_id is None else self.last_message_id + self.message_count = thread.message_count if self.message_count is None else self.message_count + self.member_count = thread.member_count if self.member_count is None else self.member_count + self._unroll_metadata(data["thread_metadata"]) try: @@ -249,7 +264,7 @@ def members(self) -> List[ThreadMember]: @property def last_message(self) -> Optional[Message]: - """Fetches the last message from this channel in cache. + """Returns the last message from this thread in cache. The message might not be valid or point to an existing message. @@ -262,7 +277,7 @@ def last_message(self) -> Optional[Message]: attribute. Returns - --------- + -------- Optional[:class:`Message`] The last message in this channel or ``None`` if not found. """ @@ -278,7 +293,7 @@ def category(self) -> Optional[CategoryChannel]: The parent channel was not cached and returned ``None``. Returns - ------- + -------- Optional[:class:`CategoryChannel`] The parent channel's category. """ @@ -298,7 +313,7 @@ def category_id(self) -> Optional[int]: The parent channel was not cached and returned ``None``. Returns - ------- + -------- Optional[:class:`int`] The parent channel's category ID. """ @@ -308,6 +323,22 @@ def category_id(self) -> Optional[int]: raise ClientException("Parent channel not found") return parent.category_id + @property + def starting_message(self) -> Optional[Message]: + """Returns the message that started this thread. + + The message might not be valid or point to an existing message. + + .. note:: + The ID for this message is the same as the thread ID. + + Returns + -------- + Optional[:class:`Message`] + The message that started this thread or ``None`` if not found in the cache. + """ + return self._state._get_message(self.id) + def is_private(self) -> bool: """:class:`bool`: Whether the thread is a private thread. @@ -704,7 +735,13 @@ async def fetch_members(self) -> List[ThreadMember]: """ members = await self._state.http.get_thread_members(self.id) - return [ThreadMember(parent=self, data=data) for data in members] + + thread_members = [ThreadMember(parent=self, data=data) for data in members] + + for member in thread_members: + self._add_member(member) + + return thread_members async def delete(self): """|coro| diff --git a/discord/types/activity.py b/discord/types/activity.py index f277643bc8..f7805985ad 100644 --- a/discord/types/activity.py +++ b/discord/types/activity.py @@ -112,4 +112,4 @@ class Activity(_BaseActivity, total=False): secrets: ActivitySecrets session_id: Optional[str] instance: bool - buttons: List[ActivityButton] + buttons: List[str] diff --git a/discord/types/audit_log.py b/discord/types/audit_log.py index 273b6e1bb0..1057e80a2f 100644 --- a/discord/types/audit_log.py +++ b/discord/types/audit_log.py @@ -27,6 +27,7 @@ from typing import List, Literal, Optional, TypedDict, Union +from .automod import AutoModRule from .channel import ChannelType, PermissionOverwrite, VideoQualityMode from .guild import ( DefaultMessageNotificationLevel, @@ -90,6 +91,11 @@ 110, 111, 112, + 121, + 140, + 141, + 142, + 143, ] @@ -152,7 +158,6 @@ class _AuditLogChange_Bool(TypedDict): "mute", "nick", "enabled_emoticons", - "region", "rtc_region", "available", "archived", @@ -283,3 +288,4 @@ class AuditLog(TypedDict): integrations: List[PartialIntegration] threads: List[Thread] scheduled_events: List[ScheduledEvent] + auto_moderation_rules: List[AutoModRule] diff --git a/discord/types/automod.py b/discord/types/automod.py new file mode 100644 index 0000000000..ebb6b8278d --- /dev/null +++ b/discord/types/automod.py @@ -0,0 +1,88 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from typing import Dict, List, Literal, TypedDict + +from .snowflake import Snowflake + +AutoModTriggerType = Literal[1, 2, 3, 4] + +AutoModEventType = Literal[1] + +AutoModActionType = Literal[1, 2, 3] + +AutoModKeywordPresetType = Literal[1, 2, 3] + + +class AutoModTriggerMetadata(TypedDict, total=False): + keyword_filter: List[str] + presets: List[AutoModKeywordPresetType] + + +class AutoModActionMetadata(TypedDict, total=False): + channel_id: Snowflake + duration_seconds: int + + +class AutoModAction(TypedDict): + type: AutoModActionType + metadata: AutoModActionMetadata + + +class AutoModRule(TypedDict): + id: Snowflake + guild_id: Snowflake + name: str + creator_id: Snowflake + event_type: AutoModEventType + trigger_type: AutoModTriggerType + trigger_metadata: AutoModTriggerMetadata + actions: List[AutoModAction] + enabled: bool + exempt_roles: List[Snowflake] + exempt_channels: List[Snowflake] + + +class _CreateAutoModRuleOptional(TypedDict, total=False): + enabled: bool + exempt_roles: List[Snowflake] + exempt_channels: List[Snowflake] + + +class CreateAutoModRule(_CreateAutoModRuleOptional): + name: str + event_type: AutoModEventType + trigger_type: AutoModTriggerType + trigger_metadata: AutoModTriggerMetadata + actions: List[AutoModAction] + + +class EditAutoModRule(TypedDict, total=False): + name: str + event_type: AutoModEventType + trigger_metadata: AutoModTriggerMetadata + actions: List[AutoModAction] + enabled: bool + exempt_roles: List[Snowflake] + exempt_channels: List[Snowflake] diff --git a/discord/types/channel.py b/discord/types/channel.py index e2f2de5d54..2b7c2d3a8d 100644 --- a/discord/types/channel.py +++ b/discord/types/channel.py @@ -140,7 +140,8 @@ class ThreadChannel(_BaseChannel, _ThreadChannelOptional): ] -class DMChannel(_BaseChannel): +class DMChannel(TypedDict): + id: Snowflake type: Literal[1] last_message_id: Optional[Snowflake] recipients: List[PartialUser] diff --git a/discord/types/embed.py b/discord/types/embed.py index 6dccd1906e..8e64c946f5 100644 --- a/discord/types/embed.py +++ b/discord/types/embed.py @@ -77,7 +77,7 @@ class EmbedAuthor(TypedDict, total=False): proxy_icon_url: str -EmbedType = Literal["rich", "image", "video", "gifv", "article", "link"] +EmbedType = Literal["rich", "image", "video", "gifv", "article", "link", "auto_moderation_message"] class Embed(TypedDict, total=False): diff --git a/discord/types/guild.py b/discord/types/guild.py index 7ecaaa96b1..8435e9c45e 100644 --- a/discord/types/guild.py +++ b/discord/types/guild.py @@ -82,6 +82,7 @@ class _GuildOptional(TypedDict, total=False): GuildFeature = Literal[ "ANIMATED_BANNER", "ANIMATED_ICON", + "AUTO_MODERATION", "BANNER", "COMMERCE", "COMMUNITY", @@ -133,12 +134,11 @@ class _GuildPreviewUnique(TypedDict): class GuildPreview(_BaseGuildPreview, _GuildPreviewUnique): - ... + pass class Guild(_BaseGuildPreview, _GuildOptional): owner_id: Snowflake - region: str afk_channel_id: Optional[Snowflake] afk_timeout: int verification_level: VerificationLevel @@ -163,7 +163,7 @@ class InviteGuild(Guild, total=False): class GuildWithCounts(Guild, _GuildPreviewUnique): - ... + pass class GuildPrune(TypedDict): diff --git a/discord/types/interactions.py b/discord/types/interactions.py index 56cb9c1f5e..77096ebf47 100644 --- a/discord/types/interactions.py +++ b/discord/types/interactions.py @@ -35,6 +35,7 @@ from .role import Role from .snowflake import Snowflake from .user import User +from ..permissions import Permissions if TYPE_CHECKING: from .message import AllowedMentions, Message @@ -202,6 +203,7 @@ class _InteractionOptional(TypedDict, total=False): message: Message locale: str guild_locale: str + app_permissions: Permissions class Interaction(_InteractionOptional): diff --git a/discord/types/invite.py b/discord/types/invite.py index a36d7e0592..3cd29f9d5b 100644 --- a/discord/types/invite.py +++ b/discord/types/invite.py @@ -65,11 +65,11 @@ class IncompleteInvite(_InviteMetadata): class Invite(IncompleteInvite, _InviteOptional): - ... + pass class InviteWithCounts(Invite, _GuildPreviewUnique): - ... + pass class _GatewayInviteCreateOptional(TypedDict, total=False): diff --git a/discord/types/member.py b/discord/types/member.py index a663d591e6..c6fdf6183a 100644 --- a/discord/types/member.py +++ b/discord/types/member.py @@ -36,8 +36,8 @@ class Nickname(TypedDict): class PartialMember(TypedDict): roles: SnowflakeList joined_at: str - deaf: str - mute: str + deaf: bool + mute: bool class Member(PartialMember, total=False): diff --git a/discord/types/message.py b/discord/types/message.py index 9aa0d3ef28..7e0adcd515 100644 --- a/discord/types/message.py +++ b/discord/types/message.py @@ -34,6 +34,7 @@ from .member import Member, UserWithMember from .snowflake import Snowflake, SnowflakeList from .sticker import StickerItem +from .threads import Thread from .user import User if TYPE_CHECKING: @@ -110,9 +111,10 @@ class _MessageOptional(TypedDict, total=False): referenced_message: Optional[Message] interaction: MessageInteraction components: List[Component] + thread: Optional[Thread] -MessageType = Literal[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14, 15, 18, 19, 20, 21] +MessageType = Literal[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14, 15, 18, 19, 20, 21, 22, 23, 24] class Message(_MessageOptional): diff --git a/discord/types/raw_models.py b/discord/types/raw_models.py index 268ef1dba3..94dd780113 100644 --- a/discord/types/raw_models.py +++ b/discord/types/raw_models.py @@ -25,6 +25,7 @@ from typing import List, TypedDict +from .automod import AutoModAction, AutoModTriggerType from .emoji import PartialEmoji from .member import Member from .snowflake import Snowflake @@ -111,3 +112,20 @@ class ScheduledEventSubscription(TypedDict, total=False): event_id: Snowflake user_id: Snowflake guild_id: Snowflake + + +class _AutoModActionExecutionEventOptional(TypedDict, total=False): + channel_id: Snowflake + message_id: Snowflake + alert_system_message_id: Snowflake + matched_keyword: str + matched_content: str + + +class AutoModActionExecutionEvent(_AutoModActionExecutionEventOptional): + guild_id: Snowflake + action: AutoModAction + rule_id: Snowflake + rule_trigger_type: AutoModTriggerType + user_id: Snowflake + content: str diff --git a/discord/types/webhook.py b/discord/types/webhook.py index f88e58d838..a3c5a03acd 100644 --- a/discord/types/webhook.py +++ b/discord/types/webhook.py @@ -70,4 +70,4 @@ class _FullWebhook(TypedDict, total=False): class Webhook(PartialWebhook, _FullWebhook): - ... + pass diff --git a/discord/ui/modal.py b/discord/ui/modal.py index 84ac3a0e42..e5a8880393 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -4,8 +4,10 @@ import os import sys import traceback +import time +from functools import partial from itertools import groupby -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Callable from .input_text import InputText @@ -37,9 +39,14 @@ class Modal: custom_id: Optional[:class:`str`] The ID of the modal dialog that gets received during an interaction. Must be 100 characters or fewer. + timeout: Optional[:class:`float`] + Timeout in seconds from last interaction with the UI before no longer accepting input. + If ``None`` then there is no timeout. """ - def __init__(self, *children: InputText, title: str, custom_id: Optional[str] = None) -> None: + def __init__(self, *children: InputText, title: str, custom_id: Optional[str] = None, + timeout: Optional[float] = None) -> None: + self.timeout: Optional[float] = timeout if not isinstance(custom_id, str) and custom_id is not None: raise TypeError(f"expected custom_id to be str, not {custom_id.__class__.__name__}") self._custom_id: Optional[str] = custom_id or os.urandom(16).hex() @@ -50,6 +57,50 @@ def __init__(self, *children: InputText, title: str, custom_id: Optional[str] = self._weights = _ModalWeights(self._children) loop = asyncio.get_running_loop() self._stopped: asyncio.Future[bool] = loop.create_future() + self.__cancel_callback: Optional[Callable[[Modal], None]] = None + self.__timeout_expiry: Optional[float] = None + self.__timeout_task: Optional[asyncio.Task[None]] = None + self.loop = asyncio.get_event_loop() + + def _start_listening_from_store(self, store: ModalStore) -> None: + self.__cancel_callback = partial(store.remove_modal) + if self.timeout: + loop = asyncio.get_running_loop() + if self.__timeout_task is not None: + self.__timeout_task.cancel() + + self.__timeout_expiry = time.monotonic() + self.timeout + self.__timeout_task = loop.create_task(self.__timeout_task_impl()) + + async def __timeout_task_impl(self) -> None: + while True: + # Guard just in case someone changes the value of the timeout at runtime + if self.timeout is None: + return + + if self.__timeout_expiry is None: + return self._dispatch_timeout() + + # Check if we've elapsed our currently set timeout + now = time.monotonic() + if now >= self.__timeout_expiry: + return self._dispatch_timeout() + + # Wait N seconds to see if timeout data has been refreshed + await asyncio.sleep(self.__timeout_expiry - now) + + @property + def _expires_at(self) -> Optional[float]: + if self.timeout: + return time.monotonic() + self.timeout + return None + + def _dispatch_timeout(self): + if self._stopped.done(): + return + + self._stopped.set_result(True) + self.loop.create_task(self.on_timeout(), name=f"discord-ui-view-timeout-{self.id}") @property def title(self) -> str: @@ -158,6 +209,10 @@ def stop(self) -> None: """Stops listening to interaction events from the modal dialog.""" if not self._stopped.done(): self._stopped.set_result(True) + self.__timeout_expiry = None + if self.__timeout_task is not None: + self.__timeout_task.cancel() + self.__timeout_task = None async def wait(self) -> bool: """Waits for the modal dialog to be submitted.""" @@ -187,6 +242,13 @@ async def on_error(self, error: Exception, interaction: Interaction) -> None: print(f"Ignoring exception in modal {self}:", file=sys.stderr) traceback.print_exception(error.__class__, error, error.__traceback__, file=sys.stderr) + async def on_timeout(self) -> None: + """|coro| + + A callback that is called when a modal's timeout elapses without being explicitly stopped. + """ + pass + class _ModalWeights: __slots__ = ("weights",) @@ -236,8 +298,10 @@ def __init__(self, state: ConnectionState) -> None: def add_modal(self, modal: Modal, user_id: int): self._modals[(user_id, modal.custom_id)] = modal + modal._start_listening_from_store(self) def remove_modal(self, modal: Modal, user_id): + modal.stop() self._modals.pop((user_id, modal.custom_id)) async def dispatch(self, user_id: int, custom_id: str, interaction: Interaction): diff --git a/discord/ui/view.py b/discord/ui/view.py index 6b3fec5a5a..f06ca7cc1f 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -144,6 +144,9 @@ class View: If ``None`` then there is no timeout. children: List[:class:`Item`] The list of children attached to this view. + message: Optional[:class:`Message`] + The message that this view is attached to. + If ``None`` then the view has not been sent with a message. """ __discord_ui_view__: ClassVar[bool] = True @@ -181,6 +184,7 @@ def __init__(self, *items: Item, timeout: Optional[float] = 180.0): self.__timeout_expiry: Optional[float] = None self.__timeout_task: Optional[asyncio.Task[None]] = None self.__stopped: asyncio.Future[bool] = loop.create_future() + self._message: Optional[Message] = None def __repr__(self) -> str: return f"<{self.__class__.__name__} timeout={self.timeout} children={len(self.children)}>" @@ -491,6 +495,13 @@ def enable_all_items(self, *, exclusions: Optional[List[Item]] = None) -> None: if exclusions is None or child not in exclusions: child.disabled = False + @property + def message(self): + return self._message + + @message.setter + def message(self, value): + self._message = value class ViewStore: def __init__(self, state: ConnectionState): diff --git a/discord/user.py b/discord/user.py index 101a3b1e12..3a51f3afea 100644 --- a/discord/user.py +++ b/discord/user.py @@ -143,6 +143,14 @@ def _to_minimal_user_json(self) -> Dict[str, Any]: "bot": self.bot, } + @property + def jump_url(self) -> str: + """:class:`str`: Returns a URL that allows the client to jump to the user. + + .. versionadded:: 2.0 + """ + return f"https://discord.com/users/{self.id}" + @property def public_flags(self) -> PublicUserFlags: """:class:`PublicUserFlags`: The publicly available flags the user has.""" diff --git a/discord/voice_client.py b/discord/voice_client.py index 51178e3806..3ea63755b1 100644 --- a/discord/voice_client.py +++ b/discord/voice_client.py @@ -882,6 +882,8 @@ def send_audio_packet(self, data: bytes, *, encode: bool = True) -> None: self.checked_add("sequence", 1, 65535) if encode: + if not self.encoder: + self.encoder = opus.Encoder() encoded_data = self.encoder.encode(data, self.encoder.SAMPLES_PER_FRAME) else: encoded_data = data diff --git a/discord/webhook/async_.py b/discord/webhook/async_.py index 7ca41dfd53..bc29218d20 100644 --- a/discord/webhook/async_.py +++ b/discord/webhook/async_.py @@ -291,11 +291,16 @@ def execute_webhook( multipart: Optional[List[Dict[str, Any]]] = None, files: Optional[List[File]] = None, thread_id: Optional[int] = None, + thread_name: Optional[str] = None, wait: bool = False, ) -> Response[Optional[MessagePayload]]: params = {"wait": int(wait)} if thread_id: params["thread_id"] = thread_id + + if thread_name: + payload["thread_name"] = thread_name + route = Route( "POST", "/webhooks/{webhook_id}/{webhook_token}", @@ -1136,7 +1141,7 @@ def from_url( A partial webhook is just a webhook object with an ID and a token. """ m = re.search( - r"discord(?:app)?.com/api/webhooks/(?P[0-9]{17,20})/(?P[A-Za-z0-9\.\-\_]{60,68})", + r"discord(?:app)?.com/api/webhooks/(?P\d{17,20})/(?P[\w\.\-_]{60,68})", url, ) if m is None: @@ -1371,6 +1376,7 @@ async def send( allowed_mentions: AllowedMentions = MISSING, view: View = MISSING, thread: Snowflake = MISSING, + thread_name: Optional[str] = None, wait: Literal[True], ) -> WebhookMessage: ... @@ -1391,6 +1397,7 @@ async def send( allowed_mentions: AllowedMentions = MISSING, view: View = MISSING, thread: Snowflake = MISSING, + thread_name: Optional[str] = None, wait: Literal[False] = ..., ) -> None: ... @@ -1410,6 +1417,7 @@ async def send( allowed_mentions: AllowedMentions = MISSING, view: View = MISSING, thread: Snowflake = MISSING, + thread_name: Optional[str] = None, wait: bool = False, delete_after: float = None, ) -> Optional[WebhookMessage]: @@ -1476,6 +1484,10 @@ async def send( thread: :class:`~discord.abc.Snowflake` The thread to send this webhook to. + .. versionadded:: 2.0 + thread_name: :class:`str` + The name of the thread to create. Only works for forum channels. + .. versionadded:: 2.0 delete_after: :class:`float` If provided, the number of seconds to wait in the background @@ -1494,9 +1506,9 @@ async def send( ValueError The length of ``embeds`` was invalid. InvalidArgument - There was no token associated with this webhook or ``ephemeral`` - was passed with the improper webhook type or there was no state - attached with this webhook when giving it a view. + Either there was no token associated with this webhook, ``ephemeral`` was passed + with the improper webhook type, there was no state attached with this webhook when + giving it a view, or you specified both ``thread_name`` and ``thread``. Returns --------- @@ -1511,6 +1523,9 @@ async def send( if content is None: content = MISSING + if thread and thread_name: + raise InvalidArgument("You cannot specify both a thread and thread_name") + application_webhook = self.type is WebhookType.application if ephemeral and not application_webhook: raise InvalidArgument("ephemeral messages can only be sent from application webhooks") @@ -1551,6 +1566,7 @@ async def send( multipart=params.multipart, files=params.files, thread_id=thread_id, + thread_name=thread_name, wait=wait, ) diff --git a/discord/webhook/sync.py b/discord/webhook/sync.py index 6561c4dd4d..c95b17b1b2 100644 --- a/discord/webhook/sync.py +++ b/discord/webhook/sync.py @@ -308,11 +308,16 @@ def execute_webhook( multipart: Optional[List[Dict[str, Any]]] = None, files: Optional[List[File]] = None, thread_id: Optional[int] = None, + thread_name: Optional[str] = None, wait: bool = False, ): params = {"wait": int(wait)} if thread_id: params["thread_id"] = thread_id + + if thread_name: + payload["thread_name"] = thread_name + route = Route( "POST", "/webhooks/{webhook_id}/{webhook_token}", @@ -705,7 +710,7 @@ def from_url(cls, url: str, *, session: Session = MISSING, bot_token: Optional[s A partial webhook is just a webhook object with an ID and a token. """ m = re.search( - r"discord(?:app)?.com/api/webhooks/(?P[0-9]{17,20})/(?P[A-Za-z0-9\.\-\_]{60,68})", + r"discord(?:app)?.com/api/webhooks/(?P\d{17,20})/(?P[\w\.\-_]{60,68})", url, ) if m is None: @@ -909,6 +914,7 @@ def send( embeds: List[Embed] = MISSING, allowed_mentions: AllowedMentions = MISSING, thread: Snowflake = MISSING, + thread_name: Optional[str] = None, wait: Literal[True], ) -> SyncWebhookMessage: ... @@ -927,6 +933,7 @@ def send( embeds: List[Embed] = MISSING, allowed_mentions: AllowedMentions = MISSING, thread: Snowflake = MISSING, + thread_name: Optional[str] = None, wait: Literal[False] = ..., ) -> None: ... @@ -944,6 +951,7 @@ def send( embeds: List[Embed] = MISSING, allowed_mentions: AllowedMentions = MISSING, thread: Snowflake = MISSING, + thread_name: Optional[str] = None, wait: bool = False, ) -> Optional[SyncWebhookMessage]: """Sends a message using the webhook. @@ -958,7 +966,7 @@ def send( ``embeds`` parameter, which must be a :class:`list` of :class:`Embed` objects to send. Parameters - ------------ + ---------- content: :class:`str` The content of the message to send. wait: :class:`bool` @@ -992,10 +1000,14 @@ def send( thread: :class:`~discord.abc.Snowflake` The thread to send this message to. + .. versionadded:: 2.0 + thread_name: :class:`str` + The name of the thread to create. Only works for forum channels. + .. versionadded:: 2.0 Raises - -------- + ------ HTTPException Sending the message failed. NotFound @@ -1007,10 +1019,11 @@ def send( ValueError The length of ``embeds`` was invalid InvalidArgument - There was no token associated with this webhook. + There was no token associated with this webhook or you specified both + a thread to send to and a thread to create (the ``thread`` and ``thread_name`` parameters). Returns - --------- + ------- Optional[:class:`SyncWebhookMessage`] If ``wait`` is ``True`` then the message that was sent, otherwise ``None``. """ @@ -1022,6 +1035,9 @@ def send( if content is None: content = MISSING + if thread and thread_name: + raise InvalidArgument("You cannot specify both a thread and a thread name") + params = handle_message_parameters( content=content, username=username, @@ -1047,6 +1063,7 @@ def send( multipart=params.multipart, files=params.files, thread_id=thread_id, + thread_name=thread_name, wait=wait, ) if wait: diff --git a/docs/api.rst b/docs/api.rst index 7c6648a8b3..87f87bfd3e 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -152,6 +152,14 @@ Option .. autofunction:: discord.commands.Option :decorator: +ThreadOption +~~~~~~~~~~~~~ + +.. attributetable:: ThreadOption + +.. autoclass:: ThreadOption + :members: + OptionChoice ~~~~~~~~~~~~~ @@ -831,6 +839,15 @@ to handle it, which defaults to print a traceback and ignoring the exception. :param exception: The DiscordException associated to the error. :type exception: :class:`DiscordException` + +.. function:: on_unknown_application_command(interaction) + + Called when an application command was not found in the bot's internal cache. + + .. versionadded:: 2.0 + + :param interaction: The interaction associated to the unknown command. + :type interaction: :class:`Interaction` .. function:: on_private_channel_update(before, after) @@ -1399,6 +1416,46 @@ to handle it, which defaults to print a traceback and ignoring the exception. :param payload: The raw event payload data. :type payload: :class:`RawScheduledEventSubscription` +.. function:: on_auto_moderation_rule_create(rule) + + Called when an auto moderation rule is created. + + The bot must have :attr:`~Permissions.manage_guild` to receive this, and + :attr:`Intents.auto_moderation_configuration` must be enabled. + + :param rule: The newly created rule. + :type rule: :class:`AutoModRule` + +.. function:: on_auto_moderation_rule_update(rule) + + Called when an auto moderation rule is updated. + + The bot must have :attr:`~Permissions.manage_guild` to receive this, and + :attr:`Intents.auto_moderation_configuration` must be enabled. + + :param rule: The updated rule. + :type rule: :class:`AutoModRule` + +.. function:: on_auto_moderation_rule_delete(rule) + + Called when an auto moderation rule is deleted. + + The bot must have :attr:`~Permissions.manage_guild` to receive this, and + :attr:`Intents.auto_moderation_configuration` must be enabled. + + :param rule: The deleted rule. + :type rule: :class:`AutoModRule` + +.. function:: on_auto_moderation_action_execution(guild, action) + + Called when an auto moderation action is executed. + + The bot must have :attr:`~Permissions.manage_guild` to receive this, and + :attr:`Intents.auto_moderation_execution` must be enabled. + + :param payload: The event's data. + :type payload: :class:`AutoModActionExecutionEvent` + .. _discord-api-utils: Utility Functions diff --git a/docs/conf.py b/docs/conf.py index 36e5865cba..eb51c68507 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -57,7 +57,7 @@ intersphinx_mapping = { "py": ("https://docs.python.org/3", None), "aio": ("https://docs.aiohttp.org/en/stable/", None), - "req": ("https://docs.python-requests.org/en/latest/", None), + "req": ("https://requests.readthedocs.io/en/latest/", None), } rst_prolog = """ @@ -100,7 +100,7 @@ release = version # This assumes a tag is available for final releases -branch = "master" if "a" in version or "b" in version else f"v{version}" +branch = "master" if "a" in version or "b" in version or "rc" in version else f"v{version}" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/docs/ext/bridge/api.rst b/docs/ext/bridge/api.rst new file mode 100644 index 0000000000..72d1bef073 --- /dev/null +++ b/docs/ext/bridge/api.rst @@ -0,0 +1,86 @@ +.. currentmodule:: discord + +API Reference +============== + +The reference manual that follows details the API of Pycord's bridge command extension module. + +.. note:: + + Using the prefixed command version (which uses the ``ext.commands`` extension) of bridge + commands in guilds requires :attr:`Intents.message_context` to be enabled. + + +.. _ext_bridge_api: + +Bots +----- + +Bot +~~~~ + +.. attributetable:: discord.ext.bridge.Bot + +.. autoclass:: discord.ext.bridge.Bot + :members: + + .. automethod:: Bot.add_bridge_command() + + .. automethod:: Bot.bridge_command() + :decorator: + +AutoShardedBot +~~~~~~~~~~~~~~~ + +.. attributetable:: discord.ext.bridge.AutoShardedBot + +.. autoclass:: discord.ext.bridge.AutoShardedBot + :members: + +Commands +--------- + +BridgeCommand +~~~~~~~~~~~~~~ + +.. attributetable:: discord.ext.bridge.BridgeCommand + +.. autoclass:: discord.ext.bridge.BridgeCommand + :members: + +.. automethod:: discord.ext.bridge.bridge_command() + :decorator: + +BridgeCommand Subclasses +~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. autoclass:: discord.ext.bridge.BridgeExtCommand + :members: + +.. autoclass:: discord.ext.bridge.BridgeSlashCommand + :members: + +Context +-------- + +BridgeContext +~~~~~~~~~~~~~~ + +.. attributetable:: discord.ext.bridge.BridgeContext + +.. autoclass:: discord.ext.bridge.BridgeContext + :members: + :exclude-members: _respond, _defer, _edit, _get_super + +BridgeContext Subclasses +~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. attributetable:: discord.ext.bridge.BridgeApplicationContext + +.. autoclass:: discord.ext.bridge.BridgeApplicationContext + :members: + +.. attributetable:: discord.ext.bridge.BridgeExtContext + +.. autoclass:: discord.ext.bridge.BridgeExtContext + :members: diff --git a/docs/ext/bridge/index.rst b/docs/ext/bridge/index.rst index ce7dd43730..12c4781d85 100644 --- a/docs/ext/bridge/index.rst +++ b/docs/ext/bridge/index.rst @@ -9,9 +9,6 @@ This module allows using one command callback in order to make both a prefix com This page includes the API reference/documentation for the module, but only contains a short example. For a more detailed guide on how to use this, see our `discord.ext.bridge guide `_. -.. note:: - ``ext.bridge`` requires the message content intent to be enabled, as it uses the ``ext.commands`` extension. - Example usage: .. code-block:: python3 @@ -39,66 +36,8 @@ Example usage: bot.run("TOKEN") -.. _discord_ext_bridge_api: - -API Reference -------------- - -Bots -~~~~ - -.. attributetable:: discord.ext.bridge.Bot - -.. autoclass:: discord.ext.bridge.Bot - :members: - - .. automethod:: Bot.add_bridge_command() - - .. automethod:: Bot.bridge_command() - :decorator: - -.. attributetable:: discord.ext.bridge.AutoShardedBot - -.. autoclass:: discord.ext.bridge.AutoShardedBot - :members: - - .. automethod:: Bot.add_bridge_command() - - .. automethod:: Bot.bridge_command() - :decorator: - -Commands -~~~~~~~~ - -.. attributetable:: discord.ext.bridge.BridgeCommand - -.. autoclass:: discord.ext.bridge.BridgeCommand - :members: - -.. automethod:: discord.ext.bridge.bridge_command() - :decorator: - -.. autoclass:: discord.ext.bridge.BridgeExtCommand - :members: - -.. autoclass:: discord.ext.bridge.BridgeSlashCommand - :members: - -Context -~~~~~~~ - -.. attributetable:: discord.ext.bridge.BridgeContext - -.. autoclass:: discord.ext.bridge.BridgeContext - :members: - :exclude-members: _respond, _defer, _edit, _get_super - -.. attributetable:: discord.ext.bridge.BridgeApplicationContext - -.. autoclass:: discord.ext.bridge.BridgeApplicationContext - :members: -.. attributetable:: discord.ext.bridge.BridgeExtContext +.. toctree:: + :maxdepth: 2 -.. autoclass:: discord.ext.bridge.BridgeExtContext - :members: \ No newline at end of file + api diff --git a/docs/ext/commands/api.rst b/docs/ext/commands/api.rst index 490dbd3a01..8d3b4f7c24 100644 --- a/docs/ext/commands/api.rst +++ b/docs/ext/commands/api.rst @@ -397,6 +397,9 @@ Converters .. autoclass:: discord.ext.commands.CategoryChannelConverter :members: +.. autoclass:: discord.ext.commands.ForumChannelConverter + :members: + .. autoclass:: discord.ext.commands.InviteConverter :members: diff --git a/docs/faq.rst b/docs/faq.rst index 5947c7f821..72c43b7980 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -53,8 +53,8 @@ instead. Similar to this example: :: # good await asyncio.sleep(10) -Another common source of blocking for too long is using HTTP requests with the famous module :doc:`req:index`. -While :doc:`req:index` is an amazing module for non-asynchronous programming, it is not a good choice for +Another common source of blocking for too long is using HTTP requests with the famous module :doc:`requests `. +While :doc:`requests ` is an amazing module for non-asynchronous programming, it is not a good choice for :mod:`asyncio` because certain requests can block the event loop too long. Instead, use the :doc:`aiohttp ` library which is installed on the side with this library. diff --git a/docs/index.rst b/docs/index.rst index 705e76c5a3..d8aa23a112 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -65,7 +65,7 @@ These pages go into great detail about everything the API can do. discord.ext.commands API Reference discord.ext.tasks API Reference discord.ext.pages API Reference - discord.ext.bridge API Reference + discord.ext.bridge API Reference Meta ------ diff --git a/examples/app_commands/context_menus.py b/examples/app_commands/context_menus.py index 8531b2f93d..fede8fdbf4 100644 --- a/examples/app_commands/context_menus.py +++ b/examples/app_commands/context_menus.py @@ -1,16 +1,23 @@ +# This example requires the 'members' privileged intent to use the Member converter. + import discord -bot = discord.Bot() +intents = discord.Intents.default() +intents.members = True + +bot = discord.Bot(debug_guilds=[...], intents=intents) +# Remove debug_guilds and set guild_ids in the slash command decorators +# to restrict specific commands to the supplied guild IDs. -@bot.user_command(guild_ids=[...]) # create a user command for the supplied guilds -async def mention(ctx, member: discord.Member): # user commands return the member +@bot.user_command() # Create a global user command +async def mention(ctx: discord.ApplicationContext, member: discord.Member): # User commands give a member param await ctx.respond(f"{ctx.author.name} just mentioned {member.mention}!") -# user commands and message commands can have spaces in their names -@bot.message_command(name="Show ID") # creates a global message command -async def show_id(ctx, message: discord.Message): # message commands return the message +# User commands and message commands can have spaces in their names +@bot.message_command(name="Show ID") # Creates a global message command +async def show_id(ctx: discord.ApplicationContext, message: discord.Message): # Message commands give a message param await ctx.respond(f"{ctx.author.name}, here's the message id: {message.id}!") diff --git a/examples/app_commands/info.py b/examples/app_commands/info.py index 839130f122..7040279d07 100644 --- a/examples/app_commands/info.py +++ b/examples/app_commands/info.py @@ -1,45 +1,46 @@ -import discord -from discord.ext import commands +# This example requires the 'members' privileged intent to use the Member converter. -# imports +import discord -intents = discord.Intents( - guilds=True, - members=True, - messages=True, -) +intents = discord.Intents.default() +intents.members = True -bot = commands.Bot( - command_prefix="/", - description="An example to showcase how to extract info about users", +bot = discord.Bot( + debug_guilds=[...], + description="An example to showcase how to extract info about users.", intents=intents, ) -@bot.slash_command(name="userinfo", description="gets the info of a user") -async def info(ctx, user: discord.Member = None): - user = user or ctx.author # if no user is provided it'll use the the author of the message - e = discord.Embed() - e.set_author(name=user.name) - e.add_field(name="ID", value=user.id, inline=False) # user ID - e.add_field( - name="Joined", - value=discord.utils.format_dt(user.joined_at, "F"), - inline=False, - ) # When the user joined the server - e.add_field( - name="Created", - value=discord.utils.format_dt(user.created_at, "F"), - inline=False, - ) # When the user's account was created - colour = user.colour - if colour.value: # if user has a role with a color - e.colour = colour - - if isinstance(user, discord.User): # checks if the user in the server - e.set_footer(text="This member is not in this server.") - - await ctx.respond(embed=e) # sends the embed - - -bot.run("your token") +@bot.slash_command(name="userinfo", description="Gets info about a user.") +async def info(ctx: discord.ApplicationContext, user: discord.Member = None): + user = user or ctx.author # If no user is provided it'll use the author of the message + embed = discord.Embed( + fields=[ + discord.EmbedField(name="ID", value=str(user.id), inline=False), # User ID + discord.EmbedField( + name="Created", + value=discord.utils.format_dt(user.created_at, "F"), + inline=False, + ), # When the user's account was created + ], + ) + embed.set_author(name=user.name) + embed.set_thumbnail(url=user.display_avatar.url) + + if user.colour.value: # If user has a role with a color + embed.colour = user.colour + + if isinstance(user, discord.User): # Checks if the user in the server + embed.set_footer(text="This user is not in this server.") + else: # We end up here if the user is a discord.Member object + embed.add_field( + name="Joined", + value=discord.utils.format_dt(user.joined_at, "F"), + inline=False, + ) # When the user joined the server + + await ctx.respond(embeds=[embed]) # Sends the embed + + +bot.run("TOKEN") diff --git a/examples/app_commands/slash_autocomplete.py b/examples/app_commands/slash_autocomplete.py index 2703a2ada0..4c5b93a68b 100644 --- a/examples/app_commands/slash_autocomplete.py +++ b/examples/app_commands/slash_autocomplete.py @@ -1,7 +1,7 @@ import discord from discord.commands import option -bot = discord.Bot() +bot = discord.Bot(debug_guilds=[...]) COLORS = ["red", "orange", "yellow", "green", "blue", "indigo", "violet"] @@ -86,14 +86,19 @@ "yellowgreen", ] -BASIC_ALLOWED = [...] # this would normally be a list of discord user IDs for the purpose of this example +BASIC_ALLOWED = [...] # This would normally be a list of discord user IDs for the purpose of this example async def color_searcher(ctx: discord.AutocompleteContext): - """Returns a list of matching colors from the LOTS_OF_COLORS list - In this example, we've added logic to only display any results in the returned list if the user's ID exists in the BASIC_ALLOWED list. + """ + Returns a list of matching colors from the LOTS_OF_COLORS list. + + In this example, we've added logic to only display any results in the + returned list if the user's ID exists in the BASIC_ALLOWED list. + This is to demonstrate passing a callback in the discord.utils.basic_autocomplete function. """ + return [color for color in LOTS_OF_COLORS if ctx.interaction.user.id in BASIC_ALLOWED] @@ -116,7 +121,7 @@ async def get_animals(ctx: discord.AutocompleteContext): elif picked_color == "blue": return ["blue jay", "blue whale"] elif picked_color == "indigo": - return ["eastern indigo snake"] # needs to return an iterable even if only one item + return ["eastern indigo snake"] # Needs to return an iterable even if only one item elif picked_color == "violet": return ["purple emperor butterfly", "orchid dottyback"] else: @@ -131,9 +136,17 @@ async def autocomplete_example( color: str, animal: str, ): - """Demonstrates using ctx.options to create options that are dependent on the values of other options. - For the `color` option, a callback is passed, where additional logic can be added to determine which values are returned. - For the `animal` option, the callback uses the input from the color option to return an iterable of animals""" + """ + Demonstrates using ctx.options to create options + that are dependent on the values of other options. + + For the `color` option, a callback is passed, where additional + logic can be added to determine which values are returned. + + For the `animal` option, the callback uses the input + from the color option to return an iterable of animals + """ + await ctx.respond(f"You picked {color} for the color, which allowed you to choose {animal} for the animal.") @@ -155,11 +168,20 @@ async def autocomplete_basic_example( color: str, animal: str, ): - """This demonstrates using the discord.utils.basic_autocomplete helper function. - For the `color` option, a callback is passed, where additional logic can be added to determine which values are returned. + """ + This demonstrates using the discord.utils.basic_autocomplete helper function. + + For the `color` option, a callback is passed, where additional + logic can be added to determine which values are returned. + For the `animal` option, a static iterable is passed. - While a small amount of values for `animal` are used in this example, iterables of any length can be passed to discord.utils.basic_autocomplete - Note that the basic_autocomplete function itself will still only return a maximum of 25 items.""" + + While a small amount of values for `animal` are used in this example, + iterables of any length can be passed to discord.utils.basic_autocomplete + + Note that the basic_autocomplete function itself will still only return a maximum of 25 items. + """ + await ctx.respond(f"You picked {color} as your color, and {animal} as your animal!") diff --git a/examples/app_commands/slash_basic.py b/examples/app_commands/slash_basic.py index 1733c938de..1c0c4b3974 100644 --- a/examples/app_commands/slash_basic.py +++ b/examples/app_commands/slash_basic.py @@ -1,33 +1,39 @@ +# This example requires the 'members' privileged intent to use the Member converter. + import discord -bot = discord.Bot() +intents = discord.Intents.default() +intents.members = True +bot = discord.Bot(intents=intents) +# The debug guilds parameter can be used to restrict slash command registration to only the supplied guild IDs. +# This is done like so: discord.Bot(debug_guilds=[...]) +# Without this, all commands are made global unless they have a guild_ids parameter in the command decorator. -# Note: If you want you can use commands.Bot instead of discord.Bot -# Use discord.Bot if you don't want prefixed message commands +# Note: If you want you can use commands.Bot instead of discord.Bot. +# Use discord.Bot if you don't want prefixed message commands. # With discord.Bot you can use @bot.command as an alias -# of @bot.slash_command but this is overridden by commands.Bot +# of @bot.slash_command but this is overridden by commands.Bot. -@bot.slash_command(guild_ids=[...]) # create a slash command for the supplied guilds -async def hello(ctx): - """Say hello to the bot""" # the command description can be supplied as the docstring +@bot.slash_command(guild_ids=[...]) # Create a slash command +async def hello(ctx: discord.ApplicationContext): + """Say hello to the bot""" # The command description can be supplied as the docstring await ctx.respond(f"Hello {ctx.author}!") # Please note that you MUST respond with ctx.respond(), ctx.defer(), or any other # interaction response within 3 seconds in your slash command code, otherwise the # interaction will fail. -@bot.slash_command( - name="hi" -) # Not passing in guild_ids creates a global slash command (might take an hour to register) -async def global_command(ctx, num: int): # Takes one integer parameter +@bot.slash_command(name="hi") +async def global_command(ctx: discord.ApplicationContext, num: int): # Takes one integer parameter await ctx.respond(f"This is a global command, {num}!") @bot.slash_command(guild_ids=[...]) -async def joined(ctx, member: discord.Member = None): # Passing a default value makes the argument optional +async def joined(ctx: discord.ApplicationContext, member: discord.Member = None): + # Setting a default value for the member parameter makes it optional ^ user = member or ctx.author await ctx.respond(f"{user.name} joined at {discord.utils.format_dt(user.joined_at)}") diff --git a/examples/app_commands/slash_cog.py b/examples/app_commands/slash_cog.py index 16c730fd4f..d24de47eb7 100644 --- a/examples/app_commands/slash_cog.py +++ b/examples/app_commands/slash_cog.py @@ -1,6 +1,6 @@ -from discord.commands import ( # Importing the decorator that makes slash commands. - slash_command, -) +# This example demonstrates a standalone cog file with the bot instance in a separate file. + +import discord from discord.ext import commands @@ -8,14 +8,20 @@ class Example(commands.Cog): def __init__(self, bot): self.bot = bot - @slash_command(guild_ids=[...]) # Create a slash command for the supplied guilds. - async def hello(self, ctx): + @commands.slash_command(guild_ids=[...]) # Create a slash command for the supplied guilds. + async def hello(self, ctx: discord.ApplicationContext): await ctx.respond("Hi, this is a slash command from a cog!") - @slash_command() # Not passing in guild_ids creates a global slash command (might take an hour to register). - async def hi(self, ctx): + @commands.slash_command() # Not passing in guild_ids creates a global slash command. + async def hi(self, ctx: discord.ApplicationContext): await ctx.respond("Hi, this is a global slash command from a cog!") def setup(bot): bot.add_cog(Example(bot)) + + +# The basic bot instance in a separate file should look something like this: +# bot = commands.Bot(command_prefix=commands.when_mentioned_or("!")) +# bot.load_extension("slash_cog") +# bot.run("TOKEN") diff --git a/examples/app_commands/slash_cog_groups.py b/examples/app_commands/slash_cog_groups.py index 9312b009a8..2a40bc5ef4 100644 --- a/examples/app_commands/slash_cog_groups.py +++ b/examples/app_commands/slash_cog_groups.py @@ -1,13 +1,18 @@ +# This example requires the 'members' privileged intent to use the Member converter. + import discord -from discord.commands import CommandPermission, SlashCommandGroup +from discord.commands import SlashCommandGroup from discord.ext import commands -bot = discord.Bot(debug_guilds=[...], owner_id=...) # main file +intents = discord.Intents.default() +intents.members = True + +bot = discord.Bot(debug_guilds=[...], intents=intents, owner_id=...) # Main file class Example(commands.Cog): - def __init__(self, bot): - self.bot = bot + def __init__(self, bot_: discord.Bot): + self.bot = bot_ greetings = SlashCommandGroup("greetings", "Various greeting from cogs!") @@ -16,21 +21,28 @@ def __init__(self, bot): secret_greetings = SlashCommandGroup( "secret_greetings", "Secret greetings", - permissions=[CommandPermission("owner", 2, True)], # Ensures the owner_id user can access this, and no one else + checks=[commands.is_owner().predicate], # Ensures the owner_id user can access this group, and no one else ) @greetings.command() - async def hello(self, ctx): + async def hello(self, ctx: discord.ApplicationContext): await ctx.respond("Hello, this is a slash subcommand from a cog!") @international_greetings.command() - async def aloha(self, ctx): + async def aloha(self, ctx: discord.ApplicationContext): await ctx.respond("Aloha, a Hawaiian greeting") @secret_greetings.command() - async def secret_handshake(self, ctx, member: discord.Member): + async def secret_handshake(self, ctx: discord.ApplicationContext, member: discord.Member): await ctx.respond(f"{member.mention} secret handshakes you") + @commands.Cog.listener() + async def on_application_command_error(self, ctx: discord.ApplicationContext, error: discord.DiscordException): + if isinstance(error, commands.NotOwner): + await ctx.respond("You can't use that command!") + else: + raise error # Raise other errors so they aren't ignored + -bot.add_cog(Example(bot)) # put in a setup function for cog files -bot.run("TOKEN") # main file +bot.add_cog(Example(bot)) # Put in a setup function for cog files +bot.run("TOKEN") # Main file diff --git a/examples/app_commands/slash_groups.py b/examples/app_commands/slash_groups.py index 5b2b5adc3f..73baed4e00 100644 --- a/examples/app_commands/slash_groups.py +++ b/examples/app_commands/slash_groups.py @@ -1,31 +1,22 @@ import discord -bot = discord.Bot() +bot = discord.Bot(debug_guilds=[...]) # If you use commands.Bot, @bot.slash_command should be used for -# slash commands. You can use @bot.slash_command with discord.Bot as well +# slash commands. You can use @bot.slash_command with discord.Bot as well. -math = bot.create_group("math", "Commands related to mathematics.") # create a slash command group +math = bot.create_group("math", "Commands related to mathematics.") # Create a slash command group +# Another way, creating the class manually: -@math.command(guild_ids=[...]) # create a slash command -async def add(ctx, num1: int, num2: int): - """Get the sum of 2 integers.""" - await ctx.respond(f"The sum of these numbers is **{num1+num2}**") - - -# another way, creating the class manually - -from discord.commands import SlashCommandGroup +math = discord.SlashCommandGroup("math", "Commands related to mathematics.") -math = SlashCommandGroup("math", "Commands related to mathematics.") - -@math.command(guild_ids=[...]) -async def add(ctx, num1: int, num2: int): - ... +@math.command() # Create a slash command under the math group +async def add(ctx: discord.ApplicationContext, num1: int, num2: int): + """Get the sum of 2 integers.""" + await ctx.respond(f"The sum of these numbers is **{num1+num2}**") bot.add_application_command(math) - bot.run("TOKEN") diff --git a/examples/app_commands/slash_options.py b/examples/app_commands/slash_options.py index 469fefadaa..c7880c5ee7 100644 --- a/examples/app_commands/slash_options.py +++ b/examples/app_commands/slash_options.py @@ -2,16 +2,15 @@ import discord from discord import option -from discord.commands import Option -bot = discord.Bot() +bot = discord.Bot(debug_guilds=[...]) # If you use commands.Bot, @bot.slash_command should be used for -# slash commands. You can use @bot.slash_command with discord.Bot as well +# slash commands. You can use @bot.slash_command with discord.Bot as well. -@bot.slash_command(guild_ids=[...]) +@bot.slash_command() @option("name", description="Enter your name") @option("gender", description="Choose your gender", choices=["Male", "Female", "Other"]) @option( @@ -20,27 +19,27 @@ min_value=1, max_value=99, default=18, - # passing the default value makes an argument optional - # you also can create optional argument using: + # Passing the default value makes an argument optional. + # You also can create optional arguments using: # age: Option(int, "Enter your age") = 18 ) async def hello( ctx: discord.ApplicationContext, name: str, gender: str, - age: str, + age: int, ): await ctx.respond(f"Hello {name}! Your gender is {gender} and you are {age} years old.") -@bot.slash_command(guild_ids=[...]) +@bot.slash_command(name="channel") @option( "channel", - [discord.TextChannel, discord.VoiceChannel], - # you can specify allowed channel types by passing a list of them like this + Union[discord.TextChannel, discord.VoiceChannel], + # You can specify allowed channel types by passing a union of them like this. description="Select a channel", ) -async def channel( +async def select_channel( ctx: discord.ApplicationContext, channel: Union[discord.TextChannel, discord.VoiceChannel], ): @@ -48,14 +47,22 @@ async def channel( @bot.slash_command(name="attach_file") -@option("attachment", discord.Attachment, description="A file to attach to the message", required=False) +@option( + "attachment", + discord.Attachment, + description="A file to attach to the message", + required=False # The default value will be None if the user doesn't provide a file. +) async def say( ctx: discord.ApplicationContext, attachment: discord.Attachment, ): """This demonstrates how to attach a file with a slash command.""" - file = await attachment.to_file() - await ctx.respond("Here's your file!", file=file) + if attachment: + file = await attachment.to_file() + await ctx.respond("Here's your file!", file=file) + else: + await ctx.respond("You didn't give me a file to reply with! :sob:") bot.run("TOKEN") diff --git a/examples/app_commands/slash_perms.py b/examples/app_commands/slash_perms.py index 968be680f7..bf21e04073 100644 --- a/examples/app_commands/slash_perms.py +++ b/examples/app_commands/slash_perms.py @@ -1,21 +1,22 @@ import discord -bot = discord.Bot() +bot = discord.Bot(debug_guilds=[...]) @bot.slash_command() @discord.default_permissions( - administrator=True -) # only members with this permission can use this command -async def admin_only(ctx): + administrator=True, +) # Only members with this permission can use this command. +async def admin_only(ctx: discord.ApplicationContext): await ctx.respond(f"Hello {ctx.author}, you are an administrator.") @bot.slash_command() @discord.default_permissions( - manage_messages=True, ban_members=True -) # you can supply multiple permissions -async def staff_only(ctx): + manage_messages=True, + ban_members=True, +) # You can supply multiple permissions that are required to use the command. +async def staff_only(ctx: discord.ApplicationContext): await ctx.respond(f"Hello {ctx.author}, you can manage messages and ban members.") diff --git a/examples/audio_recording.py b/examples/audio_recording.py index 54e65e1eb2..4f89738fee 100644 --- a/examples/audio_recording.py +++ b/examples/audio_recording.py @@ -1,60 +1,49 @@ -import os +from enum import Enum import discord -from discord.commands import ApplicationContext, option bot = discord.Bot(debug_guilds=[...]) -bot.connections = {} +connections = {} + + +class Sinks(Enum): + mp3 = discord.sinks.MP3Sink() + wav = discord.sinks.WaveSink() + pcm = discord.sinks.PCMSink() + ogg = discord.sinks.OGGSink() + mka = discord.sinks.MKASink() + mkv = discord.sinks.MKVSink() + mp4 = discord.sinks.MP4Sink() + m4a = discord.sinks.M4ASink() + + +async def finished_callback(sink, channel: discord.TextChannel, *args): + recorded_users = [f"<@{user_id}>" for user_id, audio in sink.audio_data.items()] + await sink.vc.disconnect() + files = [ + discord.File(audio.file, f"{user_id}.{sink.encoding}") + for user_id, audio in sink.audio_data.items() + ] + await channel.send( + f"Finished! Recorded audio for {', '.join(recorded_users)}.", files=files + ) @bot.command() -@option( - "encoding", - choices=[ - "mp3", - "wav", - "pcm", - "ogg", - "mka", - "mkv", - "mp4", - "m4a", - ], -) -async def start(ctx: ApplicationContext, encoding: str): +async def start(ctx: discord.ApplicationContext, sink: Sinks): """ Record your voice! """ - voice = ctx.author.voice if not voice: return await ctx.respond("You're not in a vc right now") vc = await voice.channel.connect() - bot.connections.update({ctx.guild.id: vc}) - - if encoding == "mp3": - sink = discord.sinks.MP3Sink() - elif encoding == "wav": - sink = discord.sinks.WaveSink() - elif encoding == "pcm": - sink = discord.sinks.PCMSink() - elif encoding == "ogg": - sink = discord.sinks.OGGSink() - elif encoding == "mka": - sink = discord.sinks.MKASink() - elif encoding == "mkv": - sink = discord.sinks.MKVSink() - elif encoding == "mp4": - sink = discord.sinks.MP4Sink() - elif encoding == "m4a": - sink = discord.sinks.M4ASink() - else: - return await ctx.respond("Invalid encoding.") + connections.update({ctx.guild.id: vc}) vc.start_recording( - sink, + sink.value, finished_callback, ctx.channel, ) @@ -62,22 +51,13 @@ async def start(ctx: ApplicationContext, encoding: str): await ctx.respond("The recording has started!") -async def finished_callback(sink, channel: discord.TextChannel, *args): - recorded_users = [f"<@{user_id}>" for user_id, audio in sink.audio_data.items()] - await sink.vc.disconnect() - files = [discord.File(audio.file, f"{user_id}.{sink.encoding}") for user_id, audio in sink.audio_data.items()] - await channel.send(f"Finished! Recorded audio for {', '.join(recorded_users)}.", files=files) - - @bot.command() -async def stop(ctx): - """ - Stop recording. - """ - if ctx.guild.id in bot.connections: - vc = bot.connections[ctx.guild.id] +async def stop(ctx: discord.ApplicationContext): + """Stop recording.""" + if ctx.guild.id in connections: + vc = connections[ctx.guild.id] vc.stop_recording() - del bot.connections[ctx.guild.id] + del connections[ctx.guild.id] await ctx.delete() else: await ctx.respond("Not recording in this guild.") diff --git a/examples/background_task.py b/examples/background_task.py index b3184dc7af..7f53ec7262 100644 --- a/examples/background_task.py +++ b/examples/background_task.py @@ -23,7 +23,7 @@ async def on_ready(self): async def my_background_task(self): channel = self.get_channel(1234567) # Your Channel ID goes here self.counter += 1 - await channel.send(self.counter) + await channel.send(str(self.counter)) @tasks.loop(time=time(3, 0, tzinfo=timezone.utc)) # Task that runs every day at 3 AM UTC async def time_task(self): @@ -37,4 +37,4 @@ async def before_my_task(self): client = MyClient() -client.run("token") +client.run("TOKEN") diff --git a/examples/background_task_asyncio.py b/examples/background_task_asyncio.py index 40bbc6feaf..05eadf0516 100644 --- a/examples/background_task_asyncio.py +++ b/examples/background_task_asyncio.py @@ -20,9 +20,9 @@ async def my_background_task(self): channel = self.get_channel(1234567) # Your channel ID goes here while not self.is_closed(): counter += 1 - await channel.send(counter) + await channel.send(str(counter)) await asyncio.sleep(60) # This asyncio task runs every 60 seconds client = MyClient() -client.run("token") +client.run("TOKEN") diff --git a/examples/basic_bot.py b/examples/basic_bot.py index dea9eaa85e..05928ba30f 100644 --- a/examples/basic_bot.py +++ b/examples/basic_bot.py @@ -1,20 +1,21 @@ -# This example requires the 'members' and 'message_content' privileged intents +# This example requires the 'members' privileged intent to use the Member converter +# and the 'message_content' privileged intent for prefixed commands. import random import discord from discord.ext import commands -description = """An example bot to showcase the discord.ext.commands extension -module. - -There are a number of utility commands being showcased here.""" +description = """ +An example bot to showcase the discord.ext.commands extension module. +There are a number of utility commands being showcased here. +""" intents = discord.Intents.default() intents.members = True intents.message_content = True -bot = commands.Bot(command_prefix="?", description=description, intents=intents) +bot = commands.Bot(command_prefix=commands.when_mentioned_or("!"), description=description, intents=intents) @bot.event @@ -24,57 +25,60 @@ async def on_ready(): @bot.command() -async def add(ctx, left: int, right: int): +async def add(ctx: commands.Context, left: int, right: int): """Adds two numbers together.""" - await ctx.send(left + right) + await ctx.send(str(left + right)) @bot.command() -async def roll(ctx, dice: str): - """Rolls a dice in NdN format.""" +async def roll(ctx: commands.Context, dice: str): + """Rolls a die in NdN format.""" try: rolls, limit = map(int, dice.split("d")) - except Exception: + except ValueError: await ctx.send("Format has to be in NdN!") return - result = ", ".join(str(random.randint(1, limit)) for r in range(rolls)) + # _ is used in the generation of our result as we don't need the number that comes from the usage of range(rolls). + result = ", ".join(str(random.randint(1, limit)) for _ in range(rolls)) await ctx.send(result) @bot.command(description="For when you wanna settle the score some other way") -async def choose(ctx, *choices: str): +async def choose(ctx: commands.Context, *choices: str): """Chooses between multiple choices.""" await ctx.send(random.choice(choices)) @bot.command() -async def repeat(ctx, times: int, content="repeating..."): +async def repeat(ctx: commands.Context, times: int, *, content: str = "repeating..."): """Repeats a message multiple times.""" for _ in range(times): await ctx.send(content) @bot.command() -async def joined(ctx, member: discord.Member): +async def joined(ctx: commands.Context, member: discord.Member): """Says when a member joined.""" await ctx.send(f"{member.name} joined in {member.joined_at}") @bot.group() -async def cool(ctx): - """Says if a user is cool. +async def cool(ctx: commands.Context): + """ + Says if a user is cool. In reality this just checks if a subcommand is being invoked. """ + if ctx.invoked_subcommand is None: await ctx.send(f"No, {ctx.subcommand_passed} is not cool") @cool.command(name="bot") -async def _bot(ctx): +async def _bot(ctx: commands.Context): """Is the bot cool?""" await ctx.send("Yes, the bot is cool.") -bot.run("token") +bot.run("TOKEN") diff --git a/examples/basic_voice.py b/examples/basic_voice.py index 49d83fa743..950e833e2f 100644 --- a/examples/basic_voice.py +++ b/examples/basic_voice.py @@ -1,7 +1,6 @@ -# This example requires the 'message_content' privileged intent. +# This example requires the 'message_content' privileged intent for prefixed commands. import asyncio - import youtube_dl import discord @@ -31,7 +30,7 @@ class YTDLSource(discord.PCMVolumeTransformer): - def __init__(self, source, *, data, volume=0.5): + def __init__(self, source: discord.AudioSource, *, data: dict, volume: float = 0.5): super().__init__(source, volume) self.data = data @@ -53,11 +52,11 @@ async def from_url(cls, url, *, loop=None, stream=False): class Music(commands.Cog): - def __init__(self, bot): - self.bot = bot + def __init__(self, bot_: commands.Bot): + self.bot = bot_ @commands.command() - async def join(self, ctx, *, channel: discord.VoiceChannel): + async def join(self, ctx: commands.Context, *, channel: discord.VoiceChannel): """Joins a voice channel""" if ctx.voice_client is not None: @@ -66,7 +65,7 @@ async def join(self, ctx, *, channel: discord.VoiceChannel): await channel.connect() @commands.command() - async def play(self, ctx, *, query): + async def play(self, ctx: commands.Context, *, query: str): """Plays a file from the local filesystem""" source = discord.PCMVolumeTransformer(discord.FFmpegPCMAudio(query)) @@ -75,7 +74,7 @@ async def play(self, ctx, *, query): await ctx.send(f"Now playing: {query}") @commands.command() - async def yt(self, ctx, *, url): + async def yt(self, ctx: commands.Context, *, url: str): """Plays from a url (almost anything youtube_dl supports)""" async with ctx.typing(): @@ -85,7 +84,7 @@ async def yt(self, ctx, *, url): await ctx.send(f"Now playing: {player.title}") @commands.command() - async def stream(self, ctx, *, url): + async def stream(self, ctx: commands.Context, *, url: str): """Streams from a url (same as yt, but doesn't predownload)""" async with ctx.typing(): @@ -95,7 +94,7 @@ async def stream(self, ctx, *, url): await ctx.send(f"Now playing: {player.title}") @commands.command() - async def volume(self, ctx, volume: int): + async def volume(self, ctx: commands.Context, volume: int): """Changes the player's volume""" if ctx.voice_client is None: @@ -105,15 +104,15 @@ async def volume(self, ctx, volume: int): await ctx.send(f"Changed volume to {volume}%") @commands.command() - async def stop(self, ctx): + async def stop(self, ctx: commands.Context): """Stops and disconnects the bot from voice""" - await ctx.voice_client.disconnect() + await ctx.voice_client.disconnect(force=True) @play.before_invoke @yt.before_invoke @stream.before_invoke - async def ensure_voice(self, ctx): + async def ensure_voice(self, ctx: commands.Context): if ctx.voice_client is None: if ctx.author.voice: await ctx.author.voice.channel.connect() @@ -141,4 +140,4 @@ async def on_ready(): bot.add_cog(Music(bot)) -bot.run("token") +bot.run("TOKEN") diff --git a/examples/bridge_commands.py b/examples/bridge_commands.py index a4b4a5eb41..d0a19992b3 100644 --- a/examples/bridge_commands.py +++ b/examples/bridge_commands.py @@ -1,36 +1,47 @@ +# This example requires the `message_content` privileged intent for prefixed commands. + import asyncio import discord -from discord.ext import bridge +from discord.ext import bridge, commands intents = discord.Intents.default() intents.message_content = True -bot = bridge.Bot(command_prefix="!", intents=intents) +bot = bridge.Bot(command_prefix=commands.when_mentioned_or("!"), debug_guilds=[...], intents=intents) @bot.bridge_command() -async def ping(ctx): +async def ping(ctx: bridge.BridgeContext): await ctx.respond("Pong!") @bot.bridge_command() @discord.option(name="value", choices=[1, 2, 3]) -async def choose(ctx, value: int): +async def choose(ctx: bridge.BridgeContext, value: int): await ctx.respond(f"You chose: {value}!") @bot.bridge_command() -async def welcome(ctx, member: discord.Member): +async def welcome(ctx: bridge.BridgeContext, member: discord.Member): await ctx.respond(f"Welcome {member.mention}!") @bot.bridge_command() @discord.option(name="seconds", choices=range(1, 11)) -async def wait(ctx, seconds: int = 5): +async def wait(ctx: bridge.BridgeContext, seconds: int = 5): await ctx.defer() await asyncio.sleep(seconds) await ctx.respond(f"Waited for {seconds} seconds!") +@bot.event +async def on_command_error(ctx: commands.Context, error: commands.CommandError): + # This is raised when a choice outside the given choices is selected on a prefixed command. + if isinstance(error, commands.BadArgument): + await ctx.reply("Hey! The valid choices are 1, 2, or 3!") + else: + raise error # Raise other errors so they aren't ignored + + bot.run("TOKEN") diff --git a/examples/converters.py b/examples/converters.py index 170102bf7b..09a48a00d8 100644 --- a/examples/converters.py +++ b/examples/converters.py @@ -1,6 +1,7 @@ -# This example requires the 'members' privileged intent to use the Member converter, and the 'message_content' privileged intent for prefixed commands. +# This example requires the 'members' privileged intent to use the Member converter, +# and the 'message_content' privileged intent for prefixed commands. -import typing +from typing import Union import discord from discord.ext import commands @@ -9,34 +10,34 @@ intents.members = True intents.message_content = True -bot = commands.Bot("!", intents=intents) +bot = commands.Bot(command_prefix=commands.when_mentioned_or("!"), intents=intents) @bot.command() async def userinfo(ctx: commands.Context, user: discord.User): # In the command signature above, you can see that the `user` - # Parameter is typehinted to `discord.User`. This means that - # During command invocation we will attempt to convert - # The value passed as `user` to a `discord.User` instance. - # The documentation notes what can be converted, in the case of `discord.User` - # You pass an ID, mention or username (discrim optional) + # parameter is type hinted to `discord.User`. This means that + # during command invocation we will attempt to convert + # the value passed as `user` to a `discord.User` instance. + # The documentation notes what can be converted and, in the case of `discord.User`, + # you pass an ID, mention or username (discriminator optional) # E.g. 80088516616269824, @Danny or Danny#0007 - # NOTE: typehinting acts as a converter within the `commands` framework only. - # In standard Python, it is use for documentation and IDE assistance purposes. + # NOTE: Type hinting acts as a converter within the `commands` framework only. + # In standard Python, it is used for documentation and IDE assistance purposes. - # If the conversion is successful, we will have a `discord.User` instance - # And can do the following: + # If the conversion is successful, we will have a `discord.User` + # instance and can do the following: user_id = user.id username = user.name - avatar = user.avatar.url + avatar = user.display_avatar.url await ctx.send(f"User found: {user_id} -- {username}\n{avatar}") @userinfo.error async def userinfo_error(ctx: commands.Context, error: commands.CommandError): - # If the conversion above fails for any reason, it will raise `commands.BadArgument` - # So we handle this in this error handler: + # If the conversion above fails for any reason, it will raise + # `commands.BadArgument`, which we handle this in this error handler: if isinstance(error, commands.BadArgument): return await ctx.send("Couldn't find that user.") @@ -47,8 +48,8 @@ async def convert(self, ctx: commands.Context, argument: str): # In this example we have made a custom converter. # This checks if an input is convertible to a # `discord.Member` or `discord.TextChannel` instance from the - # Input the user has given us using the pre-existing converters - # That the library provides. + # input the user has given us using the pre-existing converters + # that the library provides. member_converter = commands.MemberConverter() try: @@ -71,30 +72,30 @@ async def convert(self, ctx: commands.Context, argument: str): return channel # If the value could not be converted we can raise an error - # So our error handlers can deal with it in one place. + # so our error handlers can deal with it in one place. # The error has to be CommandError derived, so BadArgument works fine here. raise commands.BadArgument(f'No Member or TextChannel could be converted from "{argument}"') @bot.command() async def notify(ctx: commands.Context, target: ChannelOrMemberConverter): - # This command signature utilises the custom converter written above + # This command signature utilises the custom converter written above. # What will happen during command invocation is that the `target` above will be passed to - # The `argument` parameter of the `ChannelOrMemberConverter.convert` method and - # The conversion will go through the process defined there. + # the `argument` parameter of the `ChannelOrMemberConverter.convert` method and + # the conversion will go through the process defined there. await target.send(f"Hello, {target.name}!") @bot.command() -async def ignore(ctx: commands.Context, target: typing.Union[discord.Member, discord.TextChannel]): +async def ignore(ctx: commands.Context, target: Union[discord.Member, discord.TextChannel]): # This command signature utilises the `typing.Union` typehint. # The `commands` framework attempts a conversion of each type in this Union *in order*. # So, it will attempt to convert whatever is passed to `target` to a `discord.Member` instance. # If that fails, it will attempt to convert it to a `discord.TextChannel` instance. - # See: https://docs.pycord.dev/en/latest/ext/commands/commands.html#typing-union + # See: https://docs.pycord.dev/en/master/ext/commands/commands.html#typing-union # NOTE: If a Union typehint converter fails it will raise `commands.BadUnionArgument` - # Instead of `commands.BadArgument`. + # instead of `commands.BadArgument`. # To check the resulting type, `isinstance` is used if isinstance(target, discord.Member): @@ -108,11 +109,11 @@ async def ignore(ctx: commands.Context, target: typing.Union[discord.Member, dis async def multiply(ctx: commands.Context, number: int, maybe: bool): # We want an `int` and a `bool` parameter here. # `bool` is a slightly special case, as shown here: - # See: https://docs.pycord.dev/en/latest/ext/commands/commands.html#bool + # See: https://docs.pycord.dev/en/master/ext/commands/commands.html#bool if maybe: - return await ctx.send(number * 2) - await ctx.send(number * 5) + return await ctx.send(str(number * 2)) + await ctx.send(str(number * 5)) -bot.run("token") +bot.run("TOKEN") diff --git a/examples/cooldown.py b/examples/cooldown.py index 7c2a0a1917..1681804a00 100644 --- a/examples/cooldown.py +++ b/examples/cooldown.py @@ -1,35 +1,44 @@ +# This example requires the `message_content` privileged intent for prefixed commands. + import discord from discord.ext import commands -bot = commands.Bot() +intents = discord.Intents.default() +intents.message_content = True + +bot = commands.Bot(command_prefix=commands.when_mentioned_or("!"), debug_guilds=[...], intents=intents) -# an application command with cooldown +# An application command with cooldown @bot.slash_command() -@commands.cooldown(1, 5, commands.BucketType.user) # the command can only be used once in 5 seconds -async def slash(ctx): - await ctx.respond("You can't use this command again in 5 seconds.") +@commands.cooldown(1, 5, commands.BucketType.user) # The command can only be used once in 5 seconds +async def slash(ctx: discord.ApplicationContext): + await ctx.respond("You can use this command again in 5 seconds.") -# error handler +# Application command error handler @bot.event -async def on_application_command_error(ctx, error): +async def on_application_command_error(ctx: discord.ApplicationContext, error: discord.DiscordException): if isinstance(error, commands.CommandOnCooldown): await ctx.respond("This command is currently on cooldown.") else: - raise error # raise other errors so they aren't ignored + raise error # Raise other errors so they aren't ignored -# a prefixed command with cooldown +# A prefixed command with cooldown @bot.command() @commands.cooldown(1, 5, commands.BucketType.user) -async def prefixed(ctx): - await ctx.send("You can't use this command again in 5 seconds.") +async def prefixed(ctx: commands.Context): + await ctx.send("You can use this command again in 5 seconds.") +# Prefixed command error handler @bot.event -async def on_command_error(ctx, error): +async def on_command_error(ctx: commands.Context, error: commands.CommandError): if isinstance(error, commands.CommandOnCooldown): await ctx.send("This command is currently on cooldown.") else: raise error + + +bot.run("TOKEN") diff --git a/examples/create_private_emoji.py b/examples/create_private_emoji.py new file mode 100644 index 0000000000..21abeb001b --- /dev/null +++ b/examples/create_private_emoji.py @@ -0,0 +1,26 @@ +import discord + +bot = discord.Bot() + +allowed_content_types = ['image/jpeg', 'image/png'] # Setting up allowed attachments types + + +# Discord doesn't support creating private emojis by default, its semi-implemented feature and can be done by bots only. + +# This command is publicly available, to set up command permissions look for other examples in repo +@bot.command(guild_ids=[...]) +async def add_private_emoji( + ctx, name: discord.Option(str), + image: discord.Option(discord.Attachment), + role: discord.Option(discord.Role) +): + if image.content_type not in allowed_content_types: + return await ctx.respond("Invalid attachment type!", ephemeral=True) + + image_file = await image.read() # Reading attachment's content to get bytes + + await ctx.guild.create_custom_emoji(name=name, image=image_file, roles=[role]) # Image argument only takes bytes! + await ctx.respond(content="Private emoji is successfully created!") + + +bot.run("TOKEN") diff --git a/examples/custom_context.py b/examples/custom_context.py index 5d628578a8..fcf8ac377b 100644 --- a/examples/custom_context.py +++ b/examples/custom_context.py @@ -1,72 +1,74 @@ +# This example requires the `message_content` privileged intent for prefixed commands. + import random import discord from discord.ext import commands -class MyContext(commands.Context): # custom context - async def tick(self, value): +class MyContext(commands.Context): # Custom context + async def tick(self, value: bool): # Reacts to the message with an emoji - # Depending on whether value is True or False - # If its True, it'll add a green check mark - # Otherwise, it'll add a red cross mark + # depending on whether value is True or False. + # If it's True, it'll add a green check mark. + # Otherwise, it'll add a red cross mark. emoji = "\N{WHITE HEAVY CHECK MARK}" if value else "\N{CROSS MARK}" try: - # This will react to the command author's message + # This will react to the command author's message. await self.message.add_reaction(emoji) except discord.HTTPException: - # Sometimes errors occur during this, for example - # Maybe you don't have permission to do that - # We don't mind, so we can just ignore them + # Sometimes errors occur during this, for example, + # maybe you don't have permission to add reactions. + # We don't mind, so we can just ignore them. pass -# you can subclass discord.ApplicationContext to create custom application context if needed -class MyApplicationContext(discord.ApplicationContext): # custom application context - async def success(self, message): - try: - await self.respond( - embed=discord.Embed( # respond with a green embed with "Success" title - title="Success", description=message, colour=discord.Colour.green() - ) - ) - except discord.HTTPException: # ignore exceptions +# You can subclass discord.ApplicationContext to create custom application context if needed +class MyApplicationContext(discord.ApplicationContext): # Custom application context + async def success(self, message: str): + try: # Respond with a green embed with a title of "Success" + embed = discord.Embed(title="Success", description=message, colour=discord.Colour.green()) + await self.respond(embeds=[embed]) + except discord.HTTPException: # Ignore exceptions pass class MyBot(commands.Bot): - async def get_context(self, message, *, cls=MyContext): + async def get_context(self, message: discord.Message, *, cls=MyContext): # When you override this method, you pass your new Context - # Subclass to the super() method, which tells the bot to - # Use the new MyContext class + # subclass to the super() method, which tells the bot to + # use the new MyContext class. return await super().get_context(message, cls=cls) - async def get_application_context(self, interaction, cls=MyApplicationContext): - # the same stuff for custom application context + async def get_application_context(self, interaction: discord.Interaction, cls=MyApplicationContext): + # The same method for custom application context. return await super().get_application_context(interaction, cls=cls) -bot = MyBot(command_prefix="!") +intents = discord.Intents.default() +intents.message_content = True + +bot = MyBot(command_prefix=commands.when_mentioned_or("!"), debug_guilds=[...], intents=intents) @bot.command() -async def guess(ctx, number: int): +async def guess(ctx: MyContext, number: int): """Guess a random number from 1 to 6.""" - # explained in a previous example, this gives you - # a random number from 1-6 + # Explained in a previous example, this + # gives you a random number from 1-6. value = random.randint(1, 6) # With your new helper function, you can add a - # Green check mark if the guess was correct, - # Or a red cross mark if it wasn't + # green check mark if the guess was correct, + # or a red cross mark if it wasn't. await ctx.tick(number == value) -@bot.slash_command(guild_ids=[...]) -async def slash_guess(ctx, number: int): +@bot.slash_command() +async def slash_guess(ctx: MyApplicationContext, number: int): """Guess a random number from 1 to 6.""" value = random.randint(1, 6) if number == value: - await ctx.success("Congratulations! You guessed the number.") # use the new helper function + await ctx.success("Congratulations! You guessed the number.") # Use the new helper function else: await ctx.respond("You are wrong! Try again.") @@ -75,5 +77,5 @@ async def slash_guess(ctx, number: int): # These are very important, and leaking them can # let people do very malicious things with your # bot. Try to use a file or something to keep -# them private, and don't commit it to GitHub +# them private, and don't commit it to GitHub. bot.run("TOKEN") diff --git a/examples/deleted.py b/examples/deleted.py index 41f850a4f8..a5174bd6a3 100644 --- a/examples/deleted.py +++ b/examples/deleted.py @@ -1,3 +1,5 @@ +# This example requires the `message_content` privileged intent for access to message content. + import discord @@ -6,18 +8,21 @@ async def on_ready(self): print(f"Logged in as {self.user} (ID: {self.user.id})") print("------") - async def on_message(self, message): + async def on_message(self, message: discord.Message): if message.content.startswith("!deleteme"): msg = await message.channel.send("I will delete myself now...") await msg.delete() - # this also works + # This also works: await message.channel.send("Goodbye in 3 seconds...", delete_after=3.0) - async def on_message_delete(self, message): + async def on_message_delete(self, message: discord.Message): msg = f"{message.author} has deleted the message: {message.content}" await message.channel.send(msg) -client = MyClient() -client.run("token") +intents = discord.Intents.default() +intents.message_content = True + +client = MyClient(intents=intents) +client.run("TOKEN") diff --git a/examples/edits.py b/examples/edits.py index 04a205c533..fba1e344c0 100644 --- a/examples/edits.py +++ b/examples/edits.py @@ -1,3 +1,5 @@ +# This example requires the `message_content` privileged intent for access to message content. + import asyncio import discord @@ -8,16 +10,19 @@ async def on_ready(self): print(f"Logged in as {self.user} (ID: {self.user.id})") print("------") - async def on_message(self, message): + async def on_message(self, message: discord.Message): if message.content.startswith("!editme"): msg = await message.channel.send("10") await asyncio.sleep(3.0) await msg.edit(content="40") - async def on_message_edit(self, before, after): + async def on_message_edit(self, before: discord.Message, after: discord.Message): msg = f"**{before.author}** edited their message:\n{before.content} -> {after.content}" await before.channel.send(msg) -client = MyClient() -client.run("token") +intents = discord.Intents.default() +intents.message_content = True + +client = MyClient(intents=intents) +client.run("TOKEN") diff --git a/examples/guessing_game.py b/examples/guessing_game.py deleted file mode 100644 index 4953dc6c45..0000000000 --- a/examples/guessing_game.py +++ /dev/null @@ -1,37 +0,0 @@ -import asyncio -import random - -import discord - - -class MyClient(discord.Client): - async def on_ready(self): - print(f"Logged in as {self.user} (ID: {self.user.id})") - print("------") - - async def on_message(self, message): - # we do not want the bot to reply to itself - if message.author.id == self.user.id: - return - - if message.content.startswith("$guess"): - await message.channel.send("Guess a number between 1 and 10.") - - def is_correct(m): - return m.author == message.author and m.content.isdigit() - - answer = random.randint(1, 10) - - try: - guess = await self.wait_for("message", check=is_correct, timeout=5.0) - except asyncio.TimeoutError: - return await message.channel.send(f"Sorry, you took too long it was {answer}.") - - if int(guess.content) == answer: - await message.channel.send("You are right!") - else: - await message.channel.send(f"Oops. It is actually {answer}.") - - -client = MyClient() -client.run("token") diff --git a/examples/modal_dialogs.py b/examples/modal_dialogs.py index a1c1667d6d..25139910ee 100644 --- a/examples/modal_dialogs.py +++ b/examples/modal_dialogs.py @@ -1,53 +1,59 @@ +# This example requires the `message_content` privileged intent for prefixed commands. + import discord from discord.ext import commands -from discord.ui import InputText, Modal - - -class Bot(commands.Bot): - def __init__(self): - super().__init__(command_prefix=">") +intents = discord.Intents.default() +intents.message_content = True -bot = Bot() +bot = commands.Bot(command_prefix=commands.when_mentioned_or("!"), debug_guilds=[...], intents=intents) -class MyModal(Modal): +class MyModal(discord.ui.Modal): def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - self.add_item(InputText(label="Short Input", placeholder="Placeholder Test")) - - self.add_item( - InputText( + super().__init__( + discord.ui.InputText( + label="Short Input", + placeholder="Placeholder Test", + ), + discord.ui.InputText( label="Longer Input", value="Longer Value\nSuper Long Value", style=discord.InputTextStyle.long, - ) + ), + *args, + **kwargs, ) async def callback(self, interaction: discord.Interaction): - embed = discord.Embed(title="Your Modal Results", color=discord.Color.random()) - embed.add_field(name="First Input", value=self.children[0].value, inline=False) - embed.add_field(name="Second Input", value=self.children[1].value, inline=False) + embed = discord.Embed( + title="Your Modal Results", + fields=[ + discord.EmbedField(name="First Input", value=self.children[0].value, inline=False), + discord.EmbedField(name="Second Input", value=self.children[1].value, inline=False), + ], + color=discord.Color.random(), + ) await interaction.response.send_message(embeds=[embed]) -@bot.slash_command(name="modaltest", guild_ids=[...]) -async def modal_slash(ctx): +@bot.slash_command(name="modaltest") +async def modal_slash(ctx: discord.ApplicationContext): """Shows an example of a modal dialog being invoked from a slash command.""" modal = MyModal(title="Slash Command Modal") await ctx.send_modal(modal) -@bot.message_command(name="messagemodal", guild_ids=[...]) -async def modal_message(ctx, message): +@bot.message_command(name="messagemodal") +async def modal_message(ctx: discord.ApplicationContext, message: discord.Message): """Shows an example of a modal dialog being invoked from a message command.""" modal = MyModal(title="Message Command Modal") modal.title = f"Modal for Message ID: {message.id}" await ctx.send_modal(modal) -@bot.user_command(name="usermodal", guild_ids=[...]) -async def modal_user(ctx, member): +@bot.user_command(name="usermodal") +async def modal_user(ctx: discord.ApplicationContext, member: discord.Message): """Shows an example of a modal dialog being invoked from a user command.""" modal = MyModal(title="User Command Modal") modal.title = f"Modal for User: {member.display_name}" @@ -55,12 +61,12 @@ async def modal_user(ctx, member): @bot.command() -async def modaltest(ctx): +async def modaltest(ctx: commands.Context): """Shows an example of modals being invoked from an interaction component (e.g. a button or select menu)""" class MyView(discord.ui.View): @discord.ui.button(label="Modal Test", style=discord.ButtonStyle.primary) - async def button_callback(self, button, interaction): + async def button_callback(self, button: discord.ui.Button, interaction: discord.Interaction): modal = MyModal(title="Modal Triggered from Button") await interaction.response.send_modal(modal) @@ -73,7 +79,7 @@ async def button_callback(self, button, interaction): discord.SelectOption(label="Second Modal", description="Shows the second modal"), ], ) - async def select_callback(self, select, interaction): + async def select_callback(self, select: discord.ui.Select, interaction: discord.Interaction): modal = MyModal(title="Temporary Title") modal.title = select.values[0] await interaction.response.send_modal(modal) @@ -82,4 +88,4 @@ async def select_callback(self, select, interaction): await ctx.send("Click Button, Receive Modal", view=view) -bot.run("your token") +bot.run("TOKEN") diff --git a/examples/new_member.py b/examples/new_member.py index 624854d218..c634ddf628 100644 --- a/examples/new_member.py +++ b/examples/new_member.py @@ -1,4 +1,4 @@ -# This example requires the 'members' privileged intent +# This example requires the 'members' privileged intent to use the Member converter. import discord @@ -8,7 +8,7 @@ async def on_ready(self): print(f"Logged in as {self.user} (ID: {self.user.id})") print("------") - async def on_member_join(self, member): + async def on_member_join(self, member: discord.Member): guild = member.guild if guild.system_channel is not None: to_send = f"Welcome {member.mention} to {guild.name}!" @@ -19,4 +19,4 @@ async def on_member_join(self, member): intents.members = True client = MyClient(intents=intents) -client.run("token") +client.run("TOKEN") diff --git a/examples/reaction_roles.py b/examples/reaction_roles.py index 1c4d5f672f..4400a845bb 100644 --- a/examples/reaction_roles.py +++ b/examples/reaction_roles.py @@ -1,4 +1,4 @@ -# This example requires the 'members' privileged intents +# This example requires the 'members' privileged intent for access to .get_member. import discord @@ -7,7 +7,7 @@ class MyClient(discord.Client): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.role_message_id = 0 # ID of the message that can be reacted to to add/remove a role. + self.role_message_id = 0 # ID of the message that can be reacted to for adding/removing a role. self.emoji_to_role = { discord.PartialEmoji(name="🔴"): 0, # ID of the role associated with unicode emoji '🔴'. discord.PartialEmoji(name="🟡"): 0, # ID of the role associated with unicode emoji '🟡'. @@ -22,7 +22,7 @@ async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent): guild = self.get_guild(payload.guild_id) if guild is None: - # Check if we're still in the guild and it's cached. + # Make sure we're still in the guild, and it's cached. return try: @@ -51,7 +51,7 @@ async def on_raw_reaction_remove(self, payload: discord.RawReactionActionEvent): guild = self.get_guild(payload.guild_id) if guild is None: - # Check if we're still in the guild and it's cached. + # Make sure we're still in the guild, and it's cached. return try: @@ -84,4 +84,4 @@ async def on_raw_reaction_remove(self, payload: discord.RawReactionActionEvent): intents.members = True client = MyClient(intents=intents) -client.run("token") +client.run("TOKEN") diff --git a/examples/reply.py b/examples/reply.py index 113747959d..b4b4904010 100644 --- a/examples/reply.py +++ b/examples/reply.py @@ -1,3 +1,5 @@ +# This example requires the `message_content` privileged intent for access to message content. + import discord @@ -6,8 +8,8 @@ async def on_ready(self): print(f"Logged in as {self.user} (ID: {self.user.id})") print("------") - async def on_message(self, message): - # We do not want the bot to reply to itself + async def on_message(self, message: discord.Message): + # Make sure we won't be replying to ourselves. if message.author.id == self.user.id: return @@ -15,5 +17,8 @@ async def on_message(self, message): await message.reply("Hello!", mention_author=True) -client = MyClient() -client.run("token") +intents = discord.Intents.default() +intents.message_content = True + +client = MyClient(intents=intents) +client.run("TOKEN") diff --git a/examples/secret.py b/examples/secret.py index 92b51379ed..82803a7436 100644 --- a/examples/secret.py +++ b/examples/secret.py @@ -1,11 +1,19 @@ -import typing +# This example requires the 'members' privileged intent to use the Member converter, +# and the 'message_content' privileged intent for prefixed commands. + +from typing import Union import discord from discord.ext import commands -bot = commands.Bot(command_prefix=commands.when_mentioned, description="Nothing to see here!") +intents = discord.Intents.default() +intents.members = True +intents.message_content = True + +bot = commands.Bot(command_prefix=commands.when_mentioned_or("!"), description="Nothing to see here!", intents=intents) -# the `hidden` keyword argument hides it from the help command. + +# The `hidden` keyword argument hides it from the help command. @bot.group(hidden=True) async def secret(ctx: commands.Context): """What is this "secret" you speak of?""" @@ -13,8 +21,9 @@ async def secret(ctx: commands.Context): await ctx.send("Shh!", delete_after=5) -def create_overwrites(ctx, *objects): - """This is just a helper function that creates the overwrites for the +def create_overwrites(ctx: commands.Context, *objects: Union[discord.Role, discord.Member]): + """ + This is just a helper function that creates the overwrites for the voice/text channels. A `discord.PermissionOverwrite` allows you to determine the permissions @@ -26,7 +35,7 @@ def create_overwrites(ctx, *objects): """ # A dict comprehension is being utilised here to set the same permission overwrites - # For each `discord.Role` or `discord.Member`. + # for each `discord.Role` or `discord.Member`. overwrites = {obj: discord.PermissionOverwrite(view_channel=True) for obj in objects} # Prevents the default role (@everyone) from viewing the channel @@ -40,16 +49,17 @@ def create_overwrites(ctx, *objects): # Since these commands rely on guild related features, -# It is best to lock it to be guild-only. +# it is best to lock it to be guild-only. @secret.command() @commands.guild_only() async def text( ctx: commands.Context, name: str, - *objects: typing.Union[discord.Role, discord.Member], + *objects: Union[discord.Role, discord.Member], ): - """This makes a text channel with a specified name - that is only visible to roles or members that are specified. + """ + This makes a text channel with the passed name that + is only visible to roles or members that are specified. """ overwrites = create_overwrites(ctx, *objects) @@ -67,9 +77,10 @@ async def text( async def voice( ctx: commands.Context, name: str, - *objects: typing.Union[discord.Role, discord.Member], + *objects: Union[discord.Role, discord.Member], ): - """This does the same thing as the `text` subcommand + """ + This does the same thing as the `text` subcommand but instead creates a voice channel. """ @@ -78,19 +89,35 @@ async def voice( await ctx.guild.create_voice_channel(name, overwrites=overwrites, reason="Very secret business.") -@secret.command() +@secret.command(name="emoji") @commands.guild_only() -async def emoji(ctx: commands.Context, emoji: discord.PartialEmoji, *roles: discord.Role): - """This clones a specified emoji that only specified roles - are allowed to use. +async def clone_emoji( + ctx: commands.Context, + emoji: discord.PartialEmoji, + *roles: discord.Role +): + """ + This clones a specified emoji that only + specified roles are allowed to use. """ # Fetch the emoji asset and read it as bytes. emoji_bytes = await emoji.read() # The key parameter here is `roles`, which controls - # What roles are able to use the emoji. - await ctx.guild.create_custom_emoji(name=emoji.name, image=emoji_bytes, roles=roles, reason="Very secret business.") + # what roles are able to use the emoji. + await ctx.guild.create_custom_emoji( + name=emoji.name, + image=emoji_bytes, + roles=list(roles), # This converts the `roles` argument from a tuple to a list. + reason="Very secret business.", + ) + + +@bot.event +async def on_command_error(ctx: commands.Context, error: commands.CommandError): + if isinstance(error, commands.NoPrivateMessage): + await ctx.send("Hey, you can't use that command here!") -bot.run("token") +bot.run("TOKEN") diff --git a/examples/timeout.py b/examples/timeout.py index 31b4d245a8..25ab34292c 100644 --- a/examples/timeout.py +++ b/examples/timeout.py @@ -2,19 +2,19 @@ import discord -bot = discord.Bot() +bot = discord.Bot(debug_guilds=[...]) -@bot.command() -async def timeout(ctx, member: discord.Member, minutes: int): - """Apply a timeout to a member""" +@bot.slash_command() +async def timeout(ctx: discord.ApplicationContext, member: discord.Member, minutes: int): + """Apply a timeout to a member.""" duration = datetime.timedelta(minutes=minutes) await member.timeout_for(duration) await ctx.respond(f"Member timed out for {minutes} minutes.") """ - The method used above is a shortcut for + The method used above is a shortcut for: until = discord.utils.utcnow() + datetime.timedelta(minutes=minutes) await member.timeout(until) diff --git a/examples/views/button_roles.py b/examples/views/button_roles.py index 4d0bb49516..12284e65bc 100644 --- a/examples/views/button_roles.py +++ b/examples/views/button_roles.py @@ -1,12 +1,11 @@ import discord -from discord.commands.core import slash_command from discord.ext import commands """ Let users assign themselves roles by clicking on Buttons. The view made is persistent, so it will work even when the bot restarts. -See this example for more information about persistent views +See this example for more information about persistent views: https://github.com/Pycord-Development/pycord/blob/master/examples/views/persistent.py Make sure to load this cog when your bot starts! """ @@ -17,24 +16,23 @@ class RoleButton(discord.ui.Button): def __init__(self, role: discord.Role): - """ - A button for one role. `custom_id` is needed for persistent views. - """ + """A button for one role. `custom_id` is needed for persistent views.""" super().__init__( label=role.name, - style=discord.enums.ButtonStyle.primary, + style=discord.ButtonStyle.primary, custom_id=str(role.id), ) async def callback(self, interaction: discord.Interaction): - """This function will be called any time a user clicks on this button. + """ + This function will be called any time a user clicks on this button. Parameters ---------- - interaction : discord.Interaction + interaction: :class:`discord.Interaction` The interaction object that was created when a user clicks on a button. """ - # Figure out who clicked the button. + # Get the user who clicked the button. user = interaction.user # Get the role this button is for (stored in the custom ID). role = interaction.guild.get_role(int(self.custom_id)) @@ -44,30 +42,35 @@ async def callback(self, interaction: discord.Interaction): # Error handling could be done here. return - # Add the role and send a response to the uesr ephemerally (hidden to other users). + # Add the role and send a response to the user ephemerally (hidden to other users). if role not in user.roles: # Give the user the role if they don't already have it. await user.add_roles(role) - await interaction.response.send_message(f"🎉 You have been given the role {role.mention}", ephemeral=True) + await interaction.response.send_message( + f"🎉 You have been given the role {role.mention}!", + ephemeral=True, + ) else: - # Else, Take the role from the user + # Otherwise, take the role away from the user. await user.remove_roles(role) await interaction.response.send_message( - f"❌ The {role.mention} role has been taken from you", ephemeral=True + f"❌ The {role.mention} role has been taken from you!", + ephemeral=True, ) class ButtonRoleCog(commands.Cog): - """A cog with a slash command for posting the message with buttons + """ + A cog with a slash command for posting the message with buttons and to initialize the view again when the bot is restarted. """ def __init__(self, bot): self.bot = bot - # Make sure to provide a list of guild ids in the guild_ids kwarg argument. - @slash_command(guild_ids=[...], description="Post the button role message") - async def post(self, ctx: commands.Context): + # Pass a list of guild IDs to restrict usage to the supplied guild IDs. + @commands.slash_command(guild_ids=[...], description="Post the button role message") + async def post(self, ctx: discord.ApplicationContext): """Slash command to post a new view with a button for each role.""" # timeout is None because we want this view to be persistent. @@ -83,8 +86,9 @@ async def post(self, ctx: commands.Context): @commands.Cog.listener() async def on_ready(self): - """This method is called every time the bot restarts. - If a view was already created before (with the same custom IDs for buttons) + """ + This method is called every time the bot restarts. + If a view was already created before (with the same custom IDs for buttons), it will be loaded and the bot will start watching for button clicks again. """ # We recreate the view as we did in the /post command. @@ -95,9 +99,15 @@ async def on_ready(self): role = guild.get_role(role_id) view.add_item(RoleButton(role)) - # Add the view to the bot so it will watch for button interactions. + # Add the view to the bot so that it will watch for button interactions. self.bot.add_view(view) def setup(bot): bot.add_cog(ButtonRoleCog(bot)) + + +# The basic bot instance in a separate file should look something like this: +# bot = commands.Bot(command_prefix=commands.when_mentioned_or("!")) +# bot.load_extension("button_roles") +# bot.run("TOKEN") diff --git a/examples/views/confirm.py b/examples/views/confirm.py index a85221b4fa..cbf9cf1b8b 100644 --- a/examples/views/confirm.py +++ b/examples/views/confirm.py @@ -1,34 +1,38 @@ +# This example requires the `message_content` privileged intent for prefixed commands. + import discord from discord.ext import commands class Bot(commands.Bot): def __init__(self): - super().__init__(command_prefix=commands.when_mentioned_or("$")) + intents = discord.Intents.default() + intents.message_content = True + super().__init__(command_prefix=commands.when_mentioned_or("!"), intents=intents) async def on_ready(self): print(f"Logged in as {self.user} (ID: {self.user.id})") print("------") -# Define a simple View that gives us a confirmation menu +# Define a simple View that gives us a confirmation menu. class Confirm(discord.ui.View): def __init__(self): super().__init__() self.value = None - # When the confirm button is pressed, set the inner value to `True` and - # Stop the View from listening to more input. + # When the confirm button is pressed, set the inner value + # to `True` and stop the View from listening to more input. # We also send the user an ephemeral message that we're confirming their choice. @discord.ui.button(label="Confirm", style=discord.ButtonStyle.green) - async def confirm(self, button: discord.ui.Button, interaction: discord.Interaction): + async def confirm_callback(self, button: discord.ui.Button, interaction: discord.Interaction): await interaction.response.send_message("Confirming", ephemeral=True) self.value = True self.stop() - # This one is similar to the confirmation button except sets the inner value to `False` + # This one is similar to the confirmation button except sets the inner value to `False`. @discord.ui.button(label="Cancel", style=discord.ButtonStyle.grey) - async def cancel(self, button: discord.ui.Button, interaction: discord.Interaction): + async def cancel_callback(self, button: discord.ui.Button, interaction: discord.Interaction): await interaction.response.send_message("Cancelling", ephemeral=True) self.value = False self.stop() @@ -40,7 +44,7 @@ async def cancel(self, button: discord.ui.Button, interaction: discord.Interacti @bot.command() async def ask(ctx: commands.Context): """Asks the user a question to confirm something.""" - # We create the view and assign it to a variable so we can wait for it later. + # We create the View and assign it to a variable so that we can wait for it later. view = Confirm() await ctx.send("Do you want to continue?", view=view) # Wait for the View to stop listening for input... @@ -53,4 +57,4 @@ async def ask(ctx: commands.Context): print("Cancelled...") -bot.run("token") +bot.run("TOKEN") diff --git a/examples/views/counter.py b/examples/views/counter.py index 834cbfdee1..ddad0b8406 100644 --- a/examples/views/counter.py +++ b/examples/views/counter.py @@ -1,23 +1,26 @@ +# This example requires the `message_content` privileged intent for prefixed commands. + import discord from discord.ext import commands class CounterBot(commands.Bot): def __init__(self): - super().__init__(command_prefix=commands.when_mentioned_or("$")) + intents = discord.Intents.default() + intents.message_content = True + super().__init__(command_prefix=commands.when_mentioned_or("!"), intents=intents) async def on_ready(self): print(f"Logged in as {self.user} (ID: {self.user.id})") print("------") -# Define a simple View that gives us a counter button +# Define a simple View that gives us a counter button. class Counter(discord.ui.View): - # Define the actual button # When pressed, this increments the number displayed until it hits 5. - # When it hits 5, the counter button is disabled and it turns green. - # NOTE: The name of the function does not matter to the library + # When it hits 5, the counter button is disabled, and it turns green. + # NOTE: The name of the function does not matter to the library. @discord.ui.button(label="0", style=discord.ButtonStyle.red) async def count(self, button: discord.ui.Button, interaction: discord.Interaction): number = int(button.label) if button.label else 0 @@ -39,4 +42,4 @@ async def counter(ctx: commands.Context): await ctx.send("Press!", view=Counter()) -bot.run("token") +bot.run("TOKEN") diff --git a/examples/views/dropdown.py b/examples/views/dropdown.py index 1a75ef3235..0929763069 100644 --- a/examples/views/dropdown.py +++ b/examples/views/dropdown.py @@ -1,28 +1,24 @@ -import typing - import discord -from discord.ext import commands # Defines a custom Select containing colour options -# That the user can choose. The callback function -# Of this class is called when the user changes their choice +# that the user can choose. The callback function +# of this class is called when the user changes their choice. class Dropdown(discord.ui.Select): - def __init__(self, bot): - self.bot = ( - bot # For example, you can use self.bot to retrieve a user or perform other functions in the callback. - ) + def __init__(self, bot_: discord.Bot): + # For example, you can use self.bot to retrieve a user or perform other functions in the callback. # Alternatively you can use Interaction.client, so you don't need to pass the bot instance. - # Set the options that will be presented inside the dropdown + self.bot = bot_ + # Set the options that will be presented inside the dropdown: options = [ discord.SelectOption(label="Red", description="Your favourite colour is red", emoji="🟥"), discord.SelectOption(label="Green", description="Your favourite colour is green", emoji="🟩"), discord.SelectOption(label="Blue", description="Your favourite colour is blue", emoji="🟦"), ] - # The placeholder is what will be shown when no option is chosen - # The min and max values indicate we can only pick one of the three options - # The options parameter defines the dropdown options. We defined this above + # The placeholder is what will be shown when no option is selected. + # The min and max values indicate we can only pick one of the three options. + # The options parameter, contents shown above, define the dropdown options. super().__init__( placeholder="Choose your favourite colour...", min_values=1, @@ -32,42 +28,43 @@ def __init__(self, bot): async def callback(self, interaction: discord.Interaction): # Use the interaction object to send a response message containing - # The user's favourite colour or choice. The self object refers to the + # the user's favourite colour or choice. The self object refers to the # Select object, and the values attribute gets a list of the user's # selected options. We only want the first one. await interaction.response.send_message(f"Your favourite colour is {self.values[0]}") +# Defines a simple View that allows the user to use the Select menu. class DropdownView(discord.ui.View): - def __init__(self, bot): - self.bot = bot + def __init__(self, bot_: discord.Bot): + self.bot = bot_ super().__init__() - # Adds the dropdown to our view object. + # Adds the dropdown to our View object self.add_item(Dropdown(self.bot)) + # Initializing the view and adding the dropdown can actually be done in a one-liner if preferred: + # super().__init__(Dropdown(self.bot)) -class Bot(commands.Bot): - def __init__(self): - super().__init__(command_prefix=commands.when_mentioned_or("$")) - - async def on_ready(self): - print(f"Logged in as {self.user} (ID: {self.user.id})") - print("------") +bot = discord.Bot(debug_guilds=[...]) -bot = Bot() - -@bot.command() -async def colour(ctx): - """Sends a message with our dropdown containing colours""" +@bot.slash_command() +async def colour(ctx: discord.ApplicationContext): + """Sends a message with our dropdown that contains colour options.""" # Create the view containing our dropdown view = DropdownView(bot) - # Sending a message containing our view - await ctx.send("Pick your favourite colour:", view=view) + # Sending a message containing our View + await ctx.respond("Pick your favourite colour:", view=view) + + +@bot.event +async def on_ready(): + print(f"Logged in as {bot.user} (ID: {bot.user.id})") + print("------") -bot.run("token") +bot.run("TOKEN") diff --git a/examples/views/ephemeral.py b/examples/views/ephemeral.py index 6af6bafafa..7c3da66297 100644 --- a/examples/views/ephemeral.py +++ b/examples/views/ephemeral.py @@ -1,23 +1,12 @@ import discord -from discord.ext import commands -class EphemeralCounterBot(commands.Bot): - def __init__(self): - super().__init__(command_prefix=commands.when_mentioned_or("$")) - - async def on_ready(self): - print(f"Logged in as {self.user} (ID: {self.user.id})") - print("------") - - -# Define a simple View that gives us a counter button +# Define a simple View that gives us a counter button. class Counter(discord.ui.View): - # Define the actual button # When pressed, this increments the number displayed until it hits 5. - # When it hits 5, the counter button is disabled and it turns green. - # NOTE: The name of the function does not matter to the library + # When it hits 5, the counter button is disabled, and it turns green. + # NOTE: The name of the function does not matter to the library. @discord.ui.button(label="0", style=discord.ButtonStyle.red) async def count(self, button: discord.ui.Button, interaction: discord.Interaction): number = int(button.label) if button.label else 0 @@ -30,23 +19,30 @@ async def count(self, button: discord.ui.Button, interaction: discord.Interactio await interaction.response.edit_message(view=self) -# Define a View that will give us our own personal counter button +# Define a View that will give us our own personal counter button. class EphemeralCounter(discord.ui.View): - # When this button is pressed, it will respond with a Counter view that will + + # When this button is pressed, it will respond with a Counter View that will # give the button presser their own personal button they can press 5 times. @discord.ui.button(label="Click", style=discord.ButtonStyle.blurple) async def receive(self, button: discord.ui.Button, interaction: discord.Interaction): - # ephemeral=True makes the message hidden from everyone except the button presser + # ephemeral=True makes the message hidden from everyone except the button presser. await interaction.response.send_message("Enjoy!", view=Counter(), ephemeral=True) -bot = EphemeralCounterBot() +bot = discord.Bot(debug_guilds=[...]) -@bot.command() -async def counter(ctx: commands.Context): +@bot.slash_command() +async def counter(ctx: discord.ApplicationContext): """Starts a counter for pressing.""" - await ctx.send("Press!", view=EphemeralCounter()) + await ctx.respond("Press!", view=EphemeralCounter()) + + +@bot.event +async def on_ready(): + print(f"Logged in as {bot.user} (ID: {bot.user.id})") + print("------") -bot.run("token") +bot.run("TOKEN") diff --git a/examples/views/link.py b/examples/views/link.py index bb7b05ad4b..c7c463c0ae 100644 --- a/examples/views/link.py +++ b/examples/views/link.py @@ -1,40 +1,39 @@ from urllib.parse import quote_plus import discord -from discord.ext import commands -class GoogleBot(commands.Bot): - def __init__(self): - super().__init__(command_prefix=commands.when_mentioned_or("$")) - - async def on_ready(self): - print(f"Logged in as {self.user} (ID: {self.user.id})") - print("------") - - -# Define a simple View that gives us a google link button. -# We take in `query` as the query that the command author requests for +# Define a simple View that gives us a Google link button. +# We take in `query` as the query that the command author requests for. class Google(discord.ui.View): def __init__(self, query: str): super().__init__() - # we need to quote the query string to make a valid url. Discord will raise an error if it isn't valid. + # We need to quote the query string to make a valid url. Discord will raise an error if it isn't valid. query = quote_plus(query) url = f"https://www.google.com/search?q={query}" - # Link buttons cannot be made with the decorator - # Therefore we have to manually create one. + # Link buttons cannot be made with the + # decorator, so we have to manually create one. # We add the quoted url to the button, and add the button to the view. self.add_item(discord.ui.Button(label="Click Here", url=url)) + # Initializing the view and adding the button can actually be done in a one-liner at the start if preferred: + # super().__init__(discord.ui.Button(label="Click Here", url=url)) + + +bot = discord.Bot(debug_guilds=[...]) + -bot = GoogleBot() +@bot.slash_command() +async def google(ctx: discord.ApplicationContext, query: str): + """Returns a google link for a query.""" + await ctx.respond(f"Google Result for: `{query}`", view=Google(query)) -@bot.command() -async def google(ctx: commands.Context, *, query: str): - """Returns a google link for a query""" - await ctx.send(f"Google Result for: `{query}`", view=Google(query)) +@bot.event +async def on_ready(): + print(f"Logged in as {bot.user} (ID: {bot.user.id})") + print("------") -bot.run("token") +bot.run("TOKEN") diff --git a/examples/views/paginator.py b/examples/views/paginator.py index 500539b49a..a12d24b4af 100644 --- a/examples/views/paginator.py +++ b/examples/views/paginator.py @@ -1,5 +1,10 @@ # Docs: https://docs.pycord.dev/en/master/ext/pages/index.html -# Note that the below examples use a Slash Command Group in a cog for better organization - it's not required for using ext.pages. + +# This example demonstrates a standalone cog file with the bot instance in a separate file. + +# Note that the below examples use a Slash Command Group in a cog for +# better organization and doing so is not required for using ext.pages. + import asyncio import discord @@ -18,14 +23,18 @@ def __init__(self, bot): ], "Page Three", discord.Embed(title="Page Four"), - discord.Embed(title="Page Five"), + discord.Embed( + title="Page Five", + fields=[ + discord.EmbedField(name="Example Field", value="Example Value", inline=False), + ], + ), [ discord.Embed(title="Page Six, Embed 1"), discord.Embed(title="Page Seven, Embed 2"), ], ] self.pages[3].set_image(url="https://c.tenor.com/pPKOYQpTO8AAAAAM/monkey-developer.gif") - self.pages[4].add_field(name="Example Field", value="Example Value", inline=False) self.pages[4].add_field(name="Another Example Field", value="Another Example Value", inline=False) self.more_pages = [ @@ -56,9 +65,8 @@ def __init__(self, bot): def get_pages(self): return self.pages - pagetest = SlashCommandGroup("pagetest", "Commands for testing ext.pages") + pagetest = SlashCommandGroup("pagetest", "Commands for testing ext.pages.") - # These examples use a Slash Command Group in a cog for better organization - it's not required for using ext.pages. @pagetest.command(name="default") async def pagetest_default(self, ctx: discord.ApplicationContext): """Demonstrates using the paginator with the default options.""" @@ -106,7 +114,7 @@ async def pagetest_remove(self, ctx: discord.ApplicationContext): @pagetest.command(name="init") async def pagetest_init(self, ctx: discord.ApplicationContext): """Demonstrates how to pass a list of custom buttons when creating the Paginator instance.""" - pagelist = [ + page_buttons = [ pages.PaginatorButton("first", label="<<-", style=discord.ButtonStyle.green), pages.PaginatorButton("prev", label="<-", style=discord.ButtonStyle.green), pages.PaginatorButton("page_indicator", style=discord.ButtonStyle.gray, disabled=True), @@ -118,7 +126,7 @@ async def pagetest_init(self, ctx: discord.ApplicationContext): show_disabled=True, show_indicator=True, use_default_buttons=False, - custom_buttons=pagelist, + custom_buttons=page_buttons, loop_pages=True, ) await paginator.respond(ctx.interaction, ephemeral=False) @@ -162,8 +170,9 @@ async def pagetest_emoji_buttons(self, ctx: discord.ApplicationContext): @pagetest.command(name="custom_view") async def pagetest_custom_view(self, ctx: discord.ApplicationContext): """Demonstrates passing a custom view to the paginator.""" - view = discord.ui.View() - view.add_item(discord.ui.Button(label="Test Button, Does Nothing", row=1)) + view = discord.ui.View( + discord.ui.Button(label="Test Button, Does Nothing", row=1), + ) view.add_item( discord.ui.Select( placeholder="Test Select Menu, Does Nothing", @@ -194,10 +203,10 @@ async def pagetest_disable(self, ctx: discord.ApplicationContext): @pagetest.command(name="cancel") async def pagetest_cancel(self, ctx: discord.ApplicationContext): - """Demonstrates canceling (stopping) the paginator and showing a custom page when cancelled.""" + """Demonstrates cancelling (stopping) the paginator and showing a custom page when cancelled.""" paginator = pages.Paginator(pages=self.get_pages()) await paginator.respond(ctx.interaction, ephemeral=False) - await ctx.respond("Canceling paginator in 5 seconds...") + await ctx.respond("Cancelling paginator in 5 seconds...") await asyncio.sleep(5) cancel_page = discord.Embed( title="Paginator Cancelled!", @@ -215,8 +224,9 @@ async def pagetest_groups(self, ctx: discord.ApplicationContext): pages.PaginatorButton("next", label="->", style=discord.ButtonStyle.green), pages.PaginatorButton("last", label="->>", style=discord.ButtonStyle.green), ] - view = discord.ui.View() - view.add_item(discord.ui.Button(label="Test Button, Does Nothing", row=2)) + view = discord.ui.View( + discord.ui.Button(label="Test Button, Does Nothing", row=2) + ) view.add_item( discord.ui.Select( placeholder="Test Select Menu, Does Nothing", @@ -276,10 +286,18 @@ async def pagetest_prefix(self, ctx: commands.Context): @commands.command() async def pagetest_target(self, ctx: commands.Context): - """Demonstrates sending the paginator to a different target than where it was invoked (prefix-command version).""" + """Demonstrates sending the paginator to a different target than where it was invoked (prefix version).""" paginator = pages.Paginator(pages=self.get_pages()) await paginator.send(ctx, target=ctx.author, target_message="Paginator sent!") def setup(bot): bot.add_cog(PageTest(bot)) + + +# The basic bot instance in a separate file should look something like this: +# intents = discord.Intents.default() +# intents.message_content = True # required for prefixed commands +# bot = commands.Bot(command_prefix=commands.when_mentioned_or("!"), intents=intents) +# bot.load_extension("paginator") +# bot.run("TOKEN") diff --git a/examples/views/persistent.py b/examples/views/persistent.py index c6e03c3b2b..eff53e55e0 100644 --- a/examples/views/persistent.py +++ b/examples/views/persistent.py @@ -1,9 +1,11 @@ +# This example requires the `message_content` privileged intent for prefixed commands. + import discord from discord.ext import commands -# Define a simple View that persists between bot restarts -# In order a view to persist between restarts it needs to meet the following conditions: +# Define a simple View that persists between bot restarts. +# In order for a View to persist between restarts it needs to meet the following conditions: # 1) The timeout of the View has to be set to None # 2) Every item in the View has to have a custom_id set # It is recommended that the custom_id be sufficiently unique to @@ -14,11 +16,7 @@ class PersistentView(discord.ui.View): def __init__(self): super().__init__(timeout=None) - @discord.ui.button( - label="Green", - style=discord.ButtonStyle.green, - custom_id="persistent_view:green", - ) + @discord.ui.button(label="Green", style=discord.ButtonStyle.green, custom_id="persistent_view:green") async def green(self, button: discord.ui.Button, interaction: discord.Interaction): await interaction.response.send_message("This is green.", ephemeral=True) @@ -33,7 +31,9 @@ async def grey(self, button: discord.ui.Button, interaction: discord.Interaction class PersistentViewBot(commands.Bot): def __init__(self): - super().__init__(command_prefix=commands.when_mentioned_or("$")) + intents = discord.Intents.default() + intents.message_content = True + super().__init__(command_prefix=commands.when_mentioned_or("!"), intents=intents) self.persistent_views_added = False async def on_ready(self): @@ -41,8 +41,8 @@ async def on_ready(self): # Register the persistent view for listening here. # Note that this does not send the view to any message. # In order to do this you need to first send a message with the View, which is shown below. - # If you have the message_id you can also pass it as a keyword argument, but for this example - # we don't have one. + # If you have the message_id you can also pass it as a keyword argument, + # but for this example we don't have one. self.add_view(PersistentView()) self.persistent_views_added = True @@ -60,8 +60,8 @@ async def prepare(ctx: commands.Context): # In order for a persistent view to be listened to, it needs to be sent to an actual message. # Call this method once just to store it somewhere. # In a more complicated program you might fetch the message_id from a database for use later. - # However this is outside of the scope of this simple example. + # However, this is outside the scope of this simple example. await ctx.send("What's your favourite colour?", view=PersistentView()) -bot.run("token") +bot.run("TOKEN") diff --git a/examples/views/tic_tac_toe.py b/examples/views/tic_tac_toe.py index 6b8b0257ca..1d240c2356 100644 --- a/examples/views/tic_tac_toe.py +++ b/examples/views/tic_tac_toe.py @@ -1,3 +1,5 @@ +# This example requires the 'message_content' privileged intent for prefixed commands. + from typing import List import discord @@ -9,7 +11,7 @@ # what the type of `self.view` is. It is not required. class TicTacToeButton(discord.ui.Button["TicTacToe"]): def __init__(self, x: int, y: int): - # A label is required, but we don't need one so a zero-width space is used + # A label is required, but we don't need one so a zero-width space is used. # The row parameter tells the View which row to place the button under. # A View can only contain up to 5 rows -- each row can only have 5 buttons. # Since a Tic Tac Toe grid is 3x3 that means we have 3 rows and 3 columns. @@ -17,8 +19,8 @@ def __init__(self, x: int, y: int): self.x = x self.y = y - # This function is called whenever this particular button is pressed - # This is part of the "meat" of the game logic + # This function is called whenever this particular button is pressed. + # This is part of the "meat" of the game logic. async def callback(self, interaction: discord.Interaction): assert self.view is not None view: TicTacToe = self.view @@ -57,10 +59,10 @@ async def callback(self, interaction: discord.Interaction): await interaction.response.edit_message(content=content, view=view) -# This is our actual board View +# This is our actual board View. class TicTacToe(discord.ui.View): - # This tells the IDE or linter that all our children will be TicTacToeButtons - # This is not required + # This tells the IDE or linter that all our children will be TicTacToeButtons. + # This is not required. children: List[TicTacToeButton] X = -1 O = 1 @@ -75,15 +77,16 @@ def __init__(self): [0, 0, 0], ] - # Our board is made up of 3 by 3 TicTacToeButtons + # Our board is made up of 3 by 3 TicTacToeButtons. # The TicTacToeButton maintains the callbacks and helps steer # the actual game. for x in range(3): for y in range(3): self.add_item(TicTacToeButton(x, y)) - # This method checks for the board winner -- it is used by the TicTacToeButton + # This method checks for the board winner and is used by the TicTacToeButton. def check_board_winner(self): + # Check horizontal for across in self.board: value = sum(across) if value == 3: @@ -94,11 +97,11 @@ def check_board_winner(self): # Check vertical for line in range(3): value = self.board[0][line] + self.board[1][line] + self.board[2][line] - if value == -3: + if value == 3: + return self.O + elif value == -3: return self.X - elif value == 3: - return self.O # Check diagonals diag = self.board[0][2] + self.board[1][1] + self.board[2][0] if diag == 3: @@ -109,32 +112,33 @@ def check_board_winner(self): diag = self.board[0][0] + self.board[1][1] + self.board[2][2] if diag == -3: return self.X - elif diag == 3: return self.O - # If we're here, we need to check if a tie was made + + # If we're here, we need to check if a tie has been reached. if all(i != 0 for row in self.board for i in row): return self.Tie return None -class TicTacToeBot(commands.Bot): - def __init__(self): - super().__init__(command_prefix=commands.when_mentioned_or("$")) - - async def on_ready(self): - print(f"Logged in as {self.user} (ID: {self.user.id})") - print("------") - +intents = discord.Intents.default() +intents.message_content = True -bot = TicTacToeBot() +bot = commands.Bot(command_prefix=commands.when_mentioned_or("!"), intents=intents) @bot.command() async def tic(ctx: commands.Context): """Starts a tic-tac-toe game with yourself.""" - await ctx.send("Tic Tac Toe: X goes first", view=TicTacToe()) + # Setting the reference message to ctx.message makes the bot reply to the member's message. + await ctx.send("Tic Tac Toe: X goes first", view=TicTacToe(), reference=ctx.message) + + +@bot.event +async def on_ready(): + print(f"Logged in as {bot.user} (ID: {bot.user.id})") + print("------") -bot.run("token") +bot.run("TOKEN") diff --git a/examples/wait_for_event.py b/examples/wait_for_event.py new file mode 100644 index 0000000000..e72c28c1a5 --- /dev/null +++ b/examples/wait_for_event.py @@ -0,0 +1,48 @@ +# This example requires the `message_content` privileged intent for access to message content. + +import asyncio +import random + +import discord + + +class MyClient(discord.Client): + async def on_ready(self): + print(f"Logged in as {self.user} (ID: {self.user.id})") + print("------") + + async def on_message(self, message: discord.Message): + # Make sure we won't be replying to ourselves. + if message.author.id == self.user.id: + return + + if message.content.startswith("!guess"): + await message.channel.send("Guess a number between 1 and 10.") + + def is_valid_guess(m: discord.Message): + # This function checks three things at once: + # - The author of the message we've received via + # the wait_for is the same as the first message. + # - The content of the message is a digit. + # - The digit received is within the range of 1-10. + # If any one of these checks fail, we ignore this message. + return m.author == message.author and m.content.isdigit() and 1 <= int(m.content) <= 10 + + answer = random.randint(1, 10) + + try: + guess = await self.wait_for("message", check=is_valid_guess, timeout=5.0) + except asyncio.TimeoutError: + return await message.channel.send(f"Sorry, you took too long it was {answer}.") + + if int(guess.content) == answer: + await message.channel.send("You are right!") + else: + await message.channel.send(f"Oops. It is actually {answer}.") + + +intents = discord.Intents.default() +intents.message_content = True + +client = MyClient(intents=intents) +client.run("TOKEN") diff --git a/requirements-dev.txt b/requirements-dev.txt index 311ddae110..0ab03d9074 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,7 +1,7 @@ -r requirements.txt -pylint~=2.13.9 +pylint~=2.14.4 pytest~=7.1.2 pytest-asyncio~=0.18.3 # pytest-order~=1.0.1 -mypy~=0.950 -coverage~=6.3.3 +mypy~=0.961 +coverage~=6.4