diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0198a67c5e..4209dcf8d6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -44,7 +44,7 @@ repos: name: "run black in all files" - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.3.4 + rev: v0.8.0 hooks: - id: ruff args: [--fix, --fixable=I] diff --git a/changelog/1113.feature.rst b/changelog/1113.feature.rst index effdde8e52..079ad452e1 100644 --- a/changelog/1113.feature.rst +++ b/changelog/1113.feature.rst @@ -2,4 +2,4 @@ Support application subscriptions and one-time purchases (see the :ddocs:`offici - New types: :class:`SKU`, :class:`Entitlement`. - New :attr:`Interaction.entitlements` attribute, and :meth:`InteractionResponse.require_premium` response type. - New events: :func:`on_entitlement_create`, :func:`on_entitlement_update`, :func:`on_entitlement_delete`. -- New :class:`Client` methods: :meth:`~Client.skus`, :meth:`~Client.entitlements`, :meth:`~Client.create_entitlement`. +- New :class:`Client` methods: :meth:`~Client.skus`, :meth:`~Client.entitlements`, :meth:`~Client.fetch_entitlement`, :meth:`~Client.create_entitlement`. diff --git a/changelog/1115.feature.rst b/changelog/1115.feature.rst new file mode 100644 index 0000000000..a64c25babb --- /dev/null +++ b/changelog/1115.feature.rst @@ -0,0 +1 @@ +Add :class:`SelectDefaultValue`, and add :attr:`~UserSelectMenu.default_values` to all auto-populated select menu types. diff --git a/changelog/1180.doc.rst b/changelog/1180.doc.rst deleted file mode 100644 index 1ef07bb612..0000000000 --- a/changelog/1180.doc.rst +++ /dev/null @@ -1 +0,0 @@ -Adding some clarifying documentation around the type of :attr:`AuditLogEntry.extra` when the action is :attr:`~AuditLogAction.overwrite_create`. diff --git a/changelog/1184.feature.rst b/changelog/1184.feature.rst new file mode 100644 index 0000000000..32aae25dc1 --- /dev/null +++ b/changelog/1184.feature.rst @@ -0,0 +1 @@ +Add the possibility to pass :class:`disnake.File` objects to :meth:`Embed.set_author` and :meth:`~Embed.set_footer`. diff --git a/changelog/1186.feature.rst b/changelog/1186.feature.rst index effdde8e52..079ad452e1 100644 --- a/changelog/1186.feature.rst +++ b/changelog/1186.feature.rst @@ -2,4 +2,4 @@ Support application subscriptions and one-time purchases (see the :ddocs:`offici - New types: :class:`SKU`, :class:`Entitlement`. - New :attr:`Interaction.entitlements` attribute, and :meth:`InteractionResponse.require_premium` response type. - New events: :func:`on_entitlement_create`, :func:`on_entitlement_update`, :func:`on_entitlement_delete`. -- New :class:`Client` methods: :meth:`~Client.skus`, :meth:`~Client.entitlements`, :meth:`~Client.create_entitlement`. +- New :class:`Client` methods: :meth:`~Client.skus`, :meth:`~Client.entitlements`, :meth:`~Client.fetch_entitlement`, :meth:`~Client.create_entitlement`. diff --git a/changelog/1203.feature.rst b/changelog/1203.feature.rst new file mode 100644 index 0000000000..d4412fdf31 --- /dev/null +++ b/changelog/1203.feature.rst @@ -0,0 +1 @@ +Implement new :attr:`.Member.guild_banner` property. diff --git a/changelog/1212.feature.rst b/changelog/1212.feature.rst new file mode 100644 index 0000000000..87babbf215 --- /dev/null +++ b/changelog/1212.feature.rst @@ -0,0 +1 @@ +Add new :attr:`~MessageType.poll_result` message type. diff --git a/changelog/1216.feature.rst b/changelog/1216.feature.rst new file mode 100644 index 0000000000..b28073e2b4 --- /dev/null +++ b/changelog/1216.feature.rst @@ -0,0 +1 @@ +Add :meth:`Guild.fetch_voice_state` to fetch the :class:`VoiceState` of a member. diff --git a/changelog/1228.feature.rst b/changelog/1228.feature.rst deleted file mode 100644 index 5457283ab9..0000000000 --- a/changelog/1228.feature.rst +++ /dev/null @@ -1 +0,0 @@ -Add support for ``aead_xchacha20_poly1305_rtpsize`` encryption mode for voice connections, and remove deprecated ``xsalsa20_poly1305*`` modes. diff --git a/changelog/1228.misc.rst b/changelog/1228.misc.rst deleted file mode 100644 index 505effd2b8..0000000000 --- a/changelog/1228.misc.rst +++ /dev/null @@ -1 +0,0 @@ -Raise PyNaCl version requirement to ``v1.5.0``. diff --git a/changelog/1238.feature.rst b/changelog/1238.feature.rst new file mode 100644 index 0000000000..29445fc514 --- /dev/null +++ b/changelog/1238.feature.rst @@ -0,0 +1 @@ +Add support for ``BaseFlags`` to allow comparison with ``flag_values`` and vice versa. diff --git a/changelog/1245.feature.rst b/changelog/1245.feature.rst new file mode 100644 index 0000000000..03bb9cfd27 --- /dev/null +++ b/changelog/1245.feature.rst @@ -0,0 +1 @@ +Implement :attr:`~MemberFlags.is_guest`, :attr:`~MemberFlags.started_home_actions`, :attr:`~MemberFlags.completed_home_actions`, :attr:`~MemberFlags.automod_quarantined_username`, :attr:`~MemberFlags.dm_settings_upsell_acknowledged` new member flags. diff --git a/changelog/1247.feature.rst b/changelog/1247.feature.rst new file mode 100644 index 0000000000..7058c31bad --- /dev/null +++ b/changelog/1247.feature.rst @@ -0,0 +1 @@ +Implement the new :meth:`.Guild.fetch_role` API method. diff --git a/changelog/1249.feature.rst b/changelog/1249.feature.rst new file mode 100644 index 0000000000..079ad452e1 --- /dev/null +++ b/changelog/1249.feature.rst @@ -0,0 +1,5 @@ +Support application subscriptions and one-time purchases (see the :ddocs:`official docs ` for more info). +- New types: :class:`SKU`, :class:`Entitlement`. +- New :attr:`Interaction.entitlements` attribute, and :meth:`InteractionResponse.require_premium` response type. +- New events: :func:`on_entitlement_create`, :func:`on_entitlement_update`, :func:`on_entitlement_delete`. +- New :class:`Client` methods: :meth:`~Client.skus`, :meth:`~Client.entitlements`, :meth:`~Client.fetch_entitlement`, :meth:`~Client.create_entitlement`. diff --git a/changelog/1252.bugfix.rst b/changelog/1252.bugfix.rst new file mode 100644 index 0000000000..f73d8d537d --- /dev/null +++ b/changelog/1252.bugfix.rst @@ -0,0 +1 @@ +|commands| Fix incorrect exception when using the :func:`~ext.commands.default_member_permissions` decorator on a :func:`~ext.commands.user_command` while also using the cog-level ``user_command_attrs`` field. diff --git a/disnake/abc.py b/disnake/abc.py index c6c4c651cf..051931b346 100644 --- a/disnake/abc.py +++ b/disnake/abc.py @@ -43,7 +43,6 @@ from .permissions import PermissionOverwrite, Permissions from .role import Role from .sticker import GuildSticker, StandardSticker, StickerItem -from .ui.action_row import components_to_dict from .utils import _overload_with_permissions from .voice_client import VoiceClient, VoiceProtocol @@ -179,6 +178,7 @@ def avatar(self) -> Optional[Asset]: raise NotImplementedError +# FIXME: this shouldn't be a protocol. isinstance(thread, PrivateChannel) returns true, and issubclass doesn't work. @runtime_checkable class PrivateChannel(Snowflake, Protocol): """An ABC that details the common operations on a private Discord channel. @@ -1719,16 +1719,14 @@ async def send( if view is not None and components is not None: raise TypeError("cannot pass both view and components parameter to send()") - elif view: if not hasattr(view, "__discord_ui_view__"): raise TypeError(f"view parameter must be View not {view.__class__!r}") - components_payload = view.to_components() - elif components: - components_payload = components_to_dict(components) + from .ui.action_row import components_to_dict + components_payload = components_to_dict(components) else: components_payload = None diff --git a/disnake/app_commands.py b/disnake/app_commands.py index 727f35cb93..1da2b8576f 100644 --- a/disnake/app_commands.py +++ b/disnake/app_commands.py @@ -465,7 +465,7 @@ def localize(self, store: LocalizationProtocol) -> None: o.localize(store) -class ApplicationCommand(ABC): +class ApplicationCommand(ABC): # noqa: B024 # this will get refactored eventually """The base class for application commands. The following classes implement this ABC: diff --git a/disnake/asset.py b/disnake/asset.py index edb0d1c7a6..cf3cf5ac66 100644 --- a/disnake/asset.py +++ b/disnake/asset.py @@ -237,6 +237,19 @@ def _from_guild_avatar( animated=animated, ) + @classmethod + def _from_guild_banner( + cls, state: AnyState, guild_id: int, member_id: int, banner: str + ) -> Self: + animated = banner.startswith("a_") + format = "gif" if animated else "png" + return cls( + state, + url=f"{cls.BASE}/guilds/{guild_id}/users/{member_id}/banners/{banner}.{format}?size=1024", + key=banner, + animated=animated, + ) + @classmethod def _from_icon(cls, state: AnyState, object_id: int, icon_hash: str, path: str) -> Self: return cls( diff --git a/disnake/channel.py b/disnake/channel.py index a1a37a057f..90d5792a39 100644 --- a/disnake/channel.py +++ b/disnake/channel.py @@ -5092,6 +5092,7 @@ def _channel_type_factory( cls: Union[Type[disnake.abc.GuildChannel], Type[Thread]] ) -> List[ChannelType]: return { + # FIXME: this includes private channels; improve this once there's a common base type for all channels disnake.abc.GuildChannel: list(ChannelType.__members__.values()), VocalGuildChannel: [ChannelType.voice, ChannelType.stage_voice], disnake.abc.PrivateChannel: [ChannelType.private, ChannelType.group], diff --git a/disnake/client.py b/disnake/client.py index 80b3d67c65..990e19c201 100644 --- a/disnake/client.py +++ b/disnake/client.py @@ -1272,7 +1272,12 @@ def run(self, *args: Any, **kwargs: Any) -> None: This function must be the last function to call due to the fact that it is blocking. That means that registration of events or anything being - called after this function call will not execute until it returns. + called after this function call will not execute until it returns + + Parameters + ---------- + token: :class:`str` + The discord token of the bot that is being ran. """ loop = self.loop @@ -3142,6 +3147,7 @@ def entitlements( guild: Optional[Snowflake] = None, skus: Optional[Sequence[Snowflake]] = None, exclude_ended: bool = False, + exclude_deleted: bool = True, oldest_first: bool = False, ) -> EntitlementIterator: """Retrieves an :class:`.AsyncIterator` that enables receiving entitlements for the application. @@ -3181,6 +3187,8 @@ def entitlements( The SKUs for which entitlements are retrieved. exclude_ended: :class:`bool` Whether to exclude ended/expired entitlements. Defaults to ``False``. + exclude_deleted: :class:`bool` + Whether to exclude deleted entitlements. Defaults to ``True``. oldest_first: :class:`bool` If set to ``True``, return entries in oldest->newest order. Defaults to ``False``. @@ -3204,9 +3212,40 @@ def entitlements( guild_id=guild.id if guild is not None else None, sku_ids=[sku.id for sku in skus] if skus else None, exclude_ended=exclude_ended, + exclude_deleted=exclude_deleted, oldest_first=oldest_first, ) + async def fetch_entitlement(self, entitlement_id: int, /) -> Entitlement: + """|coro| + + Retrieves a :class:`.Entitlement` for the given ID. + + .. note:: + + This method is an API call. To get the entitlements of the invoking user/guild + in interactions, consider using :attr:`.Interaction.entitlements`. + + .. versionadded:: 2.10 + + Parameters + ---------- + entitlement_id: :class:`int` + The ID of the entitlement to retrieve. + + Raises + ------ + HTTPException + Retrieving the entitlement failed. + + Returns + ------- + :class:`.Entitlement` + The retrieved entitlement. + """ + data = await self.http.get_entitlement(self.application_id, entitlement_id=entitlement_id) + return Entitlement(data=data, state=self._connection) + async def create_entitlement( self, sku: Snowflake, owner: Union[abc.User, Guild] ) -> Entitlement: diff --git a/disnake/components.py b/disnake/components.py index 7614fd424b..09854d5ad1 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -18,7 +18,14 @@ cast, ) -from .enums import ButtonStyle, ChannelType, ComponentType, TextInputStyle, try_enum +from .enums import ( + ButtonStyle, + ChannelType, + ComponentType, + SelectDefaultValueType, + TextInputStyle, + try_enum, +) from .partial_emoji import PartialEmoji, _EmojiTag from .utils import MISSING, assert_never, get_slots @@ -35,6 +42,7 @@ Component as ComponentPayload, MentionableSelectMenu as MentionableSelectMenuPayload, RoleSelectMenu as RoleSelectMenuPayload, + SelectDefaultValue as SelectDefaultValuePayload, SelectOption as SelectOptionPayload, StringSelectMenu as StringSelectMenuPayload, TextInput as TextInputPayload, @@ -53,6 +61,7 @@ "MentionableSelectMenu", "ChannelSelectMenu", "SelectOption", + "SelectDefaultValue", "TextInput", ) @@ -264,6 +273,12 @@ class BaseSelectMenu(Component): A list of options that can be selected in this select menu. disabled: :class:`bool` Whether the select menu is disabled or not. + default_values: List[:class:`SelectDefaultValue`] + The list of values (users/roles/channels) that are selected by default. + If set, the number of items must be within the bounds set by ``min_values`` and ``max_values``. + Only available for auto-populated select menus. + + .. versionadded:: 2.10 """ __slots__: Tuple[str, ...] = ( @@ -272,9 +287,11 @@ class BaseSelectMenu(Component): "min_values", "max_values", "disabled", + "default_values", ) - __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ + # FIXME: this isn't pretty; we should decouple __repr__ from slots + __repr_info__: ClassVar[Tuple[str, ...]] = tuple(s for s in __slots__ if s != "default_values") # n.b: ideally this would be `BaseSelectMenuPayload`, # but pyright made TypedDict keys invariant and doesn't @@ -288,6 +305,9 @@ def __init__(self, data: AnySelectMenuPayload) -> None: self.min_values: int = data.get("min_values", 1) self.max_values: int = data.get("max_values", 1) self.disabled: bool = data.get("disabled", False) + self.default_values: List[SelectDefaultValue] = [ + SelectDefaultValue._from_dict(d) for d in (data.get("default_values") or []) + ] def to_dict(self) -> BaseSelectMenuPayload: payload: BaseSelectMenuPayload = { @@ -301,6 +321,9 @@ def to_dict(self) -> BaseSelectMenuPayload: if self.placeholder: payload["placeholder"] = self.placeholder + if self.default_values: + payload["default_values"] = [v.to_dict() for v in self.default_values] + return payload @@ -377,6 +400,11 @@ class UserSelectMenu(BaseSelectMenu): Defaults to 1 and must be between 1 and 25. disabled: :class:`bool` Whether the select menu is disabled or not. + default_values: List[:class:`SelectDefaultValue`] + The list of values (users/members) that are selected by default. + If set, the number of items must be within the bounds set by ``min_values`` and ``max_values``. + + .. versionadded:: 2.10 """ __slots__: Tuple[str, ...] = () @@ -412,6 +440,11 @@ class RoleSelectMenu(BaseSelectMenu): Defaults to 1 and must be between 1 and 25. disabled: :class:`bool` Whether the select menu is disabled or not. + default_values: List[:class:`SelectDefaultValue`] + The list of values (roles) that are selected by default. + If set, the number of items must be within the bounds set by ``min_values`` and ``max_values``. + + .. versionadded:: 2.10 """ __slots__: Tuple[str, ...] = () @@ -447,6 +480,11 @@ class MentionableSelectMenu(BaseSelectMenu): Defaults to 1 and must be between 1 and 25. disabled: :class:`bool` Whether the select menu is disabled or not. + default_values: List[:class:`SelectDefaultValue`] + The list of values (users/roles) that are selected by default. + If set, the number of items must be within the bounds set by ``min_values`` and ``max_values``. + + .. versionadded:: 2.10 """ __slots__: Tuple[str, ...] = () @@ -485,6 +523,11 @@ class ChannelSelectMenu(BaseSelectMenu): channel_types: Optional[List[:class:`ChannelType`]] A list of channel types that can be selected in this select menu. If ``None``, channels of all types may be selected. + default_values: List[:class:`SelectDefaultValue`] + The list of values (channels) that are selected by default. + If set, the number of items must be within the bounds set by ``min_values`` and ``max_values``. + + .. versionadded:: 2.10 """ __slots__: Tuple[str, ...] = ("channel_types",) @@ -613,6 +656,42 @@ def to_dict(self) -> SelectOptionPayload: return payload +class SelectDefaultValue: + """Represents a default value of an auto-populated select menu (currently all + select menu types except :class:`StringSelectMenu`). + + Depending on the :attr:`type` attribute, this can represent different types of objects. + + .. versionadded:: 2.10 + + Attributes + ---------- + id: :class:`int` + The ID of the target object. + type: :class:`SelectDefaultValueType` + The type of the target object. + """ + + __slots__: Tuple[str, ...] = ("id", "type") + + def __init__(self, id: int, type: SelectDefaultValueType) -> None: + self.id: int = id + self.type: SelectDefaultValueType = type + + @classmethod + def _from_dict(cls, data: SelectDefaultValuePayload) -> Self: + return cls(int(data["id"]), try_enum(SelectDefaultValueType, data["type"])) + + def to_dict(self) -> SelectDefaultValuePayload: + return { + "id": self.id, + "type": self.type.value, + } + + def __repr__(self) -> str: + return f"" + + class TextInput(Component): """Represents a text input from the Discord Bot UI Kit. diff --git a/disnake/embeds.py b/disnake/embeds.py index 1866d8d7eb..abbbff53f2 100644 --- a/disnake/embeds.py +++ b/disnake/embeds.py @@ -106,7 +106,7 @@ class _EmbedAuthorProxy(Sized, Protocol): icon_url: Optional[str] proxy_icon_url: Optional[str] - _FileKey = Literal["image", "thumbnail"] + _FileKey = Literal["image", "thumbnail", "footer", "author"] class Embed: @@ -385,12 +385,32 @@ def footer(self) -> _EmbedFooterProxy: """ return cast("_EmbedFooterProxy", EmbedProxy(self._footer)) - def set_footer(self, *, text: Any, icon_url: Optional[Any] = None) -> Self: + @overload + def set_footer(self, *, text: Any, icon_url: Optional[Any] = ...) -> Self: + ... + + @overload + def set_footer(self, *, text: Any, icon_file: File = ...) -> Self: + ... + + def set_footer( + self, *, text: Any, icon_url: Optional[Any] = MISSING, icon_file: File = MISSING + ) -> Self: """Sets the footer for the embed content. This function returns the class instance to allow for fluent-style chaining. + At most one of ``icon_url`` or ``icon_file`` may be passed. + + .. warning:: + Passing a :class:`disnake.File` object will make the embed not + reusable. + + .. warning:: + If used with the other ``set_*`` methods, you must ensure + that the :attr:`.File.filename` is unique to avoid duplication. + Parameters ---------- text: :class:`str` @@ -401,13 +421,18 @@ def set_footer(self, *, text: Any, icon_url: Optional[Any] = None) -> Self: icon_url: Optional[:class:`str`] The URL of the footer icon. Only HTTP(S) is supported. + icon_file: :class:`File` + The file to use as the footer icon. + + .. versionadded:: 2.10 """ self._footer = { "text": str(text), } - if icon_url is not None: - self._footer["icon_url"] = str(icon_url) + result = self._handle_resource(icon_url, icon_file, key="footer", required=False) + if result is not None: + self._footer["icon_url"] = result return self @@ -457,6 +482,10 @@ def set_image(self, url: Optional[Any] = MISSING, *, file: File = MISSING) -> Se Passing a :class:`disnake.File` object will make the embed not reusable. + .. warning:: + If used with the other ``set_*`` methods, you must ensure + that the :attr:`.File.filename` is unique to avoid duplication. + .. versionchanged:: 1.4 Passing ``None`` removes the image. @@ -508,6 +537,10 @@ def set_thumbnail(self, url: Optional[Any] = MISSING, *, file: File = MISSING) - Passing a :class:`disnake.File` object will make the embed not reusable. + .. warning:: + If used with the other ``set_*`` methods, you must ensure + that the :attr:`.File.filename` is unique to avoid duplication. + .. versionchanged:: 1.4 Passing ``None`` removes the thumbnail. @@ -559,18 +592,39 @@ def author(self) -> _EmbedAuthorProxy: """ return cast("_EmbedAuthorProxy", EmbedProxy(self._author)) + @overload + def set_author( + self, *, name: Any, url: Optional[Any] = ..., icon_url: Optional[Any] = ... + ) -> Self: + ... + + @overload + def set_author(self, *, name: Any, url: Optional[Any] = ..., icon_file: File = ...) -> Self: + ... + def set_author( self, *, name: Any, url: Optional[Any] = None, - icon_url: Optional[Any] = None, + icon_url: Optional[Any] = MISSING, + icon_file: File = MISSING, ) -> Self: """Sets the author for the embed content. This function returns the class instance to allow for fluent-style chaining. + At most one of ``icon_url`` or ``icon_file`` may be passed. + + .. warning:: + Passing a :class:`disnake.File` object will make the embed not + reusable. + + .. warning:: + If used with the other ``set_*`` methods, you must ensure + that the :attr:`.File.filename` is unique to avoid duplication. + Parameters ---------- name: :class:`str` @@ -579,6 +633,10 @@ def set_author( The URL for the author. icon_url: Optional[:class:`str`] The URL of the author icon. Only HTTP(S) is supported. + icon_file: :class:`File` + The file to use as the author icon. + + .. versionadded:: 2.10 """ self._author = { "name": str(name), @@ -587,8 +645,9 @@ def set_author( if url is not None: self._author["url"] = str(url) - if icon_url is not None: - self._author["icon_url"] = str(icon_url) + result = self._handle_resource(icon_url, icon_file, key="author", required=False) + if result is not None: + self._author["icon_url"] = result return self @@ -821,9 +880,15 @@ def get_default_colour(cls) -> Optional[Colour]: get_default_color = get_default_colour - def _handle_resource(self, url: Optional[Any], file: File, *, key: _FileKey) -> Optional[str]: - if not (url is MISSING) ^ (file is MISSING): - raise TypeError("Exactly one of url or file must be provided") + def _handle_resource( + self, url: Optional[Any], file: Optional[File], *, key: _FileKey, required: bool = True + ) -> Optional[str]: + if required: + if not (url is MISSING) ^ (file is MISSING): + raise TypeError("Exactly one of url or file must be provided") + else: + if url is not MISSING and file is not MISSING: + raise TypeError("At most one of url or file may be provided, not both.") if file: if file.filename is None: @@ -832,7 +897,7 @@ def _handle_resource(self, url: Optional[Any], file: File, *, key: _FileKey) -> return f"attachment://{file.filename}" else: self._files.pop(key, None) - return str(url) if url is not None else None + return str(url) if url else None def check_limits(self) -> None: """Checks if this embed fits within the limits dictated by Discord. diff --git a/disnake/enums.py b/disnake/enums.py index 5c917911f0..1cf075c85f 100644 --- a/disnake/enums.py +++ b/disnake/enums.py @@ -47,6 +47,7 @@ "ComponentType", "ButtonStyle", "TextInputStyle", + "SelectDefaultValueType", "StagePrivacyLevel", "InteractionType", "InteractionResponseType", @@ -262,6 +263,7 @@ class MessageType(Enum): guild_incident_alert_mode_disabled = 37 guild_incident_report_raid = 38 guild_incident_report_false_alarm = 39 + poll_result = 46 class PartyType(Enum): @@ -694,6 +696,15 @@ def __int__(self) -> int: return self.value +class SelectDefaultValueType(Enum): + user = "user" + role = "role" + channel = "channel" + + def __str__(self) -> str: + return self.value + + class ApplicationCommandType(Enum): chat_input = 1 user = 2 diff --git a/disnake/ext/commands/converter.py b/disnake/ext/commands/converter.py index 422416fd33..79570b9f8e 100644 --- a/disnake/ext/commands/converter.py +++ b/disnake/ext/commands/converter.py @@ -1187,7 +1187,7 @@ def get_converter(param: inspect.Parameter) -> Any: def is_generic_type(tp: Any, *, _GenericAlias: Type = _GenericAlias) -> bool: - return isinstance(tp, type) and issubclass(tp, Generic) or isinstance(tp, _GenericAlias) + return (isinstance(tp, type) and issubclass(tp, Generic)) or isinstance(tp, _GenericAlias) CONVERTER_MAPPING: Dict[Type[Any], Type[Converter]] = { diff --git a/disnake/ext/commands/ctx_menus_core.py b/disnake/ext/commands/ctx_menus_core.py index 0e84b5a89d..9f59f03fc5 100644 --- a/disnake/ext/commands/ctx_menus_core.py +++ b/disnake/ext/commands/ctx_menus_core.py @@ -86,15 +86,9 @@ def __init__( self.auto_sync: bool = True if auto_sync is None else auto_sync try: - default_perms: int = func.__default_member_permissions__ + default_member_permissions = func.__default_member_permissions__ except AttributeError: pass - else: - if default_member_permissions is not None: - raise ValueError( - "Cannot set `default_member_permissions` in both parameter and decorator" - ) - default_member_permissions = default_perms dm_permission = True if dm_permission is None else dm_permission diff --git a/disnake/ext/commands/params.py b/disnake/ext/commands/params.py index e472c1ae13..e7df5f543d 100644 --- a/disnake/ext/commands/params.py +++ b/disnake/ext/commands/params.py @@ -804,7 +804,7 @@ def parse_parameter(self, param: inspect.Parameter) -> None: self.param_name = param.name def parse_doc(self, doc: disnake.utils._DocstringParam) -> None: - if self.type == str and doc["type"] is not None: + if self.type is str and doc["type"] is not None: self.parse_annotation(doc["type"]) self.description = self.description or doc["description"] diff --git a/disnake/ext/tasks/__init__.py b/disnake/ext/tasks/__init__.py index 6532c3d088..95ff023330 100644 --- a/disnake/ext/tasks/__init__.py +++ b/disnake/ext/tasks/__init__.py @@ -275,7 +275,7 @@ def next_iteration(self) -> Optional[datetime.datetime]: """ if self._task is MISSING: return None - elif self._task and self._task.done() or self._stop_next_iteration: + elif (self._task and self._task.done()) or self._stop_next_iteration: return None return self._next_iteration diff --git a/disnake/flags.py b/disnake/flags.py index da3cba6904..6b2c24e71c 100644 --- a/disnake/flags.py +++ b/disnake/flags.py @@ -55,6 +55,16 @@ def __init__(self, func: Callable[[Any], int]) -> None: self.__doc__ = func.__doc__ self._parent: Type[T] = MISSING + def __eq__(self, other: Any) -> bool: + if isinstance(other, flag_value): + return self.flag == other.flag + if isinstance(other, BaseFlags): + return self._parent is other.__class__ and self.flag == other.value + return False + + def __ne__(self, other: Any) -> bool: + return not self.__eq__(other) + def __or__(self, other: Union[flag_value[T], T]) -> T: if isinstance(other, BaseFlags): if self._parent is not other.__class__: @@ -148,7 +158,11 @@ def _from_value(cls, value: int) -> Self: return self def __eq__(self, other: Any) -> bool: - return isinstance(other, self.__class__) and self.value == other.value + if isinstance(other, self.__class__): + return self.value == other.value + if isinstance(other, flag_value): + return self.__class__ is other._parent and self.value == other.flag + return False def __ne__(self, other: Any) -> bool: return not self.__eq__(other) @@ -2343,9 +2357,14 @@ class MemberFlags(BaseFlags): def __init__( self, *, + automod_quarantined_username: bool = ..., bypasses_verification: bool = ..., + completed_home_actions: bool = ..., completed_onboarding: bool = ..., did_rejoin: bool = ..., + dm_settings_upsell_acknowledged: bool = ..., + is_guest: bool = ..., + started_home_actions: bool = ..., started_onboarding: bool = ..., ) -> None: ... @@ -2370,6 +2389,46 @@ def started_onboarding(self): """:class:`bool`: Returns ``True`` if the member has started onboarding.""" return 1 << 3 + @flag_value + def is_guest(self): + """:class:`bool`: Returns ``True`` if the member is a guest and can only access the voice channel they were invited to. + + .. versionadded:: 2.10 + """ + return 1 << 4 + + @flag_value + def started_home_actions(self): + """:class:`bool`: Returns ``True`` if the member has started the Server Guide actions. + + .. versionadded:: 2.10 + """ + return 1 << 5 + + @flag_value + def completed_home_actions(self): + """:class:`bool`: Returns ``True`` if the member has completed the Server Guide actions. + + .. versionadded:: 2.10 + """ + return 1 << 6 + + @flag_value + def automod_quarantined_username(self): + """:class:`bool`: Returns ``True`` if the member's username, display name, or nickname is blocked by AutoMod. + + .. versionadded:: 2.10 + """ + return 1 << 7 + + @flag_value + def dm_settings_upsell_acknowledged(self): + """:class:`bool`: Returns ``True`` if the member has dismissed the DM settings upsell. + + .. versionadded:: 2.10 + """ + return 1 << 9 + class RoleFlags(BaseFlags): """Wraps up Discord Role flags. diff --git a/disnake/gateway.py b/disnake/gateway.py index b42414b924..ef54d5580b 100644 --- a/disnake/gateway.py +++ b/disnake/gateway.py @@ -687,6 +687,21 @@ def latency(self) -> float: return float("inf") if heartbeat is None else heartbeat.latency def _can_handle_close(self) -> bool: + # bandaid fix for https://github.com/aio-libs/aiohttp/issues/8138 + # tl;dr: on aiohttp >= 3.9.0 and python < 3.11.0, aiohttp returns close code 1000 (OK) + # on abrupt connection loss, not 1006 (ABNORMAL_CLOSURE) like one would expect, ultimately + # due to faulty ssl lifecycle handling in cpython. + # If we end up in a situation where the close code is 1000 but we didn't + # initiate the closure (i.e. `self._close_code` isn't set), assume this has happened and + # try to reconnect. + if self._close_code is None and self.socket.close_code == 1000: + _log.info( + "Websocket remote in shard ID %s closed with %s. Assuming the connection dropped.", + self.shard_id, + self.socket.close_code, + ) + return True # consider this a reconnectable close code + code = self._close_code or self.socket.close_code return code not in (1000, 4004, 4010, 4011, 4012, 4013, 4014) diff --git a/disnake/guild.py b/disnake/guild.py index 97ea1e80ac..a0609063bf 100644 --- a/disnake/guild.py +++ b/disnake/guild.py @@ -3581,6 +3581,37 @@ async def delete_emoji(self, emoji: Snowflake, *, reason: Optional[str] = None) """ await self._state.http.delete_custom_emoji(self.id, emoji.id, reason=reason) + async def fetch_role(self, role_id: int, /) -> Role: + """|coro| + + Retrieve a :class:`Role`. + + .. note:: + + This method is an API call. For general usage, consider :meth:`get_role` or :attr:`roles` instead. + + .. versionadded:: 2.10 + + Parameters + ---------- + role_id: :class:`int` + The ID of the role to retrieve. + + Raises + ------ + NotFound + The role requested could not be found. + HTTPException + Retrieving the role failed. + + Returns + ------- + :class:`Role` + The retrieved role. + """ + data = await self._state.http.get_role(self.id, role_id=role_id) + return Role(guild=self, state=self._state, data=data) + async def fetch_roles(self) -> List[Role]: """|coro| @@ -4642,6 +4673,46 @@ async def fetch_voice_regions(self) -> List[VoiceRegion]: data = await self._state.http.get_guild_voice_regions(self.id) return [VoiceRegion(data=region) for region in data] + async def fetch_voice_state(self, member_id: int, /) -> VoiceState: + """|coro| + + Fetches the :class:`VoiceState` of a member. + + .. note:: + + This method is an API call. For general usage, consider :attr:`Member.voice` instead. + + .. versionadded:: 2.10 + + Parameters + ---------- + member_id: :class:`int` + The ID of the member. + + Raises + ------ + NotFound + The member for which you tried to fetch a voice state is not + connected to a channel in this guild. + Forbidden + You do not have permission to fetch the member's voice state. + HTTPException + Fetching the voice state failed. + + Returns + ------- + :class:`VoiceState` + The voice state of the member. + """ + if member_id == self.me.id: + data = await self._state.http.get_my_voice_state(self.id) + else: + data = await self._state.http.get_voice_state(self.id, member_id) + + channel_id = utils._get_as_snowflake(data, "channel_id") + channel: Optional[VocalGuildChannel] = self.get_channel(channel_id) # type: ignore + return VoiceState(data=data, channel=channel) + async def change_voice_state( self, *, channel: Optional[Snowflake], self_mute: bool = False, self_deaf: bool = False ) -> None: diff --git a/disnake/http.py b/disnake/http.py index f0d157d671..89dfe30724 100644 --- a/disnake/http.py +++ b/disnake/http.py @@ -984,6 +984,17 @@ def change_nickname( } return self.request(r, json=payload, reason=reason) + def get_my_voice_state(self, guild_id: Snowflake) -> Response[voice.GuildVoiceState]: + return self.request(Route("GET", "/guilds/{guild_id}/voice-states/@me", guild_id=guild_id)) + + def get_voice_state( + self, guild_id: Snowflake, user_id: Snowflake + ) -> Response[voice.GuildVoiceState]: + r = Route( + "GET", "/guilds/{guild_id}/voice-states/{user_id}", guild_id=guild_id, user_id=user_id + ) + return self.request(r) + def edit_my_voice_state(self, guild_id: Snowflake, payload: Dict[str, Any]) -> Response[None]: r = Route("PATCH", "/guilds/{guild_id}/voice-states/@me", guild_id=guild_id) return self.request(r, json=payload) @@ -1922,6 +1933,11 @@ def delete_invite(self, invite_id: str, *, reason: Optional[str] = None) -> Resp # Role management + def get_role(self, guild_id: Snowflake, role_id: Snowflake) -> Response[role.Role]: + return self.request( + Route("GET", "/guilds/{guild_id}/roles/{role_id}", guild_id=guild_id, role_id=role_id) + ) + def get_roles(self, guild_id: Snowflake) -> Response[List[role.Role]]: return self.request(Route("GET", "/guilds/{guild_id}/roles", guild_id=guild_id)) @@ -2360,10 +2376,12 @@ def get_entitlements( guild_id: Optional[Snowflake] = None, sku_ids: Optional[SnowflakeList] = None, exclude_ended: bool = False, + exclude_deleted: bool = False, ) -> Response[List[entitlement.Entitlement]]: params: Dict[str, Any] = { "limit": limit, "exclude_ended": int(exclude_ended), + "exclude_deleted": int(exclude_deleted), } if before is not None: params["before"] = before @@ -2381,6 +2399,18 @@ def get_entitlements( ) return self.request(r, params=params) + def get_entitlement( + self, application_id: Snowflake, entitlement_id: int + ) -> Response[entitlement.Entitlement]: + return self.request( + Route( + "GET", + "/applications/{application_id}/entitlements/{entitlement_id}", + application_id=application_id, + entitlement_id=entitlement_id, + ) + ) + def create_test_entitlement( self, application_id: Snowflake, diff --git a/disnake/interactions/base.py b/disnake/interactions/base.py index 585406ed41..09795285d4 100644 --- a/disnake/interactions/base.py +++ b/disnake/interactions/base.py @@ -223,11 +223,10 @@ def __init__(self, *, data: InteractionPayload, state: ConnectionState) -> None: self.author = ( isinstance(guild_fallback, Guild) and guild_fallback.get_member(int(member["user"]["id"])) - or Member( - state=self._state, - guild=guild_fallback, # type: ignore # may be `Object` - data=member, - ) + ) or Member( + state=self._state, + guild=guild_fallback, # type: ignore # may be `Object` + data=member, ) self._permissions = int(member.get("permissions", 0)) elif user := data.get("user"): @@ -1915,15 +1914,11 @@ def __init__( user_id = int(str_id) member = members.get(str_id) if member is not None: - self.members[user_id] = ( - guild - and guild.get_member(user_id) - or Member( - data=member, - user_data=user, - guild=guild_fallback, # type: ignore - state=state, - ) + self.members[user_id] = (guild and guild.get_member(user_id)) or Member( + data=member, + user_data=user, + guild=guild_fallback, # type: ignore + state=state, ) else: self.users[user_id] = User(state=state, data=user) diff --git a/disnake/iterators.py b/disnake/iterators.py index 6d629066af..86c311e39f 100644 --- a/disnake/iterators.py +++ b/disnake/iterators.py @@ -1044,6 +1044,7 @@ def __init__( before: Optional[Union[Snowflake, datetime.datetime]] = None, after: Optional[Union[Snowflake, datetime.datetime]] = None, exclude_ended: bool = False, + exclude_deleted: bool = True, oldest_first: bool = False, ) -> None: if isinstance(before, datetime.datetime): @@ -1059,6 +1060,7 @@ def __init__( self.guild_id: Optional[int] = guild_id self.sku_ids: Optional[List[int]] = sku_ids self.exclude_ended: bool = exclude_ended + self.exclude_deleted: bool = exclude_deleted self.state: ConnectionState = state self.request = state.http.get_entitlements @@ -1116,6 +1118,7 @@ async def _before_strategy(self, retrieve: int) -> List[EntitlementPayload]: user_id=self.user_id, guild_id=self.guild_id, exclude_ended=self.exclude_ended, + exclude_deleted=self.exclude_deleted, ) if len(data): @@ -1133,6 +1136,7 @@ async def _after_strategy(self, retrieve: int) -> List[EntitlementPayload]: user_id=self.user_id, guild_id=self.guild_id, exclude_ended=self.exclude_ended, + exclude_deleted=self.exclude_deleted, ) if len(data): diff --git a/disnake/member.py b/disnake/member.py index 149fc97ecc..7ce63f8c9e 100644 --- a/disnake/member.py +++ b/disnake/member.py @@ -272,6 +272,7 @@ class Member(disnake.abc.Messageable, _UserTag): "_user", "_state", "_avatar", + "_banner", "_communication_disabled_until", "_flags", "_avatar_decoration_data", @@ -340,6 +341,7 @@ def __init__( self.nick: Optional[str] = data.get("nick") self.pending: bool = data.get("pending", False) self._avatar: Optional[str] = data.get("avatar") + self._banner: Optional[str] = data.get("banner") timeout_datetime = utils.parse_time(data.get("communication_disabled_until")) self._communication_disabled_until: Optional[datetime.datetime] = timeout_datetime self._flags: int = data.get("flags", 0) @@ -409,6 +411,7 @@ def _copy(cls, member: Member) -> Self: self.activities = member.activities self._state = member._state self._avatar = member._avatar + self._banner = member._banner self._communication_disabled_until = member.current_timeout self._flags = member._flags @@ -437,6 +440,7 @@ def _update(self, data: GuildMemberUpdateEvent) -> None: self.premium_since = utils.parse_time(data.get("premium_since")) self._roles = utils.SnowflakeList(map(int, data["roles"])) self._avatar = data.get("avatar") + self._banner = data.get("banner") timeout_datetime = utils.parse_time(data.get("communication_disabled_until")) self._communication_disabled_until = timeout_datetime self._flags = data.get("flags", 0) @@ -619,6 +623,21 @@ def guild_avatar(self) -> Optional[Asset]: return None return Asset._from_guild_avatar(self._state, self.guild.id, self.id, self._avatar) + # TODO + # implement a `display_banner` property + # for more info on why this wasn't implemented read this discussion + # https://github.com/DisnakeDev/disnake/pull/1204#discussion_r1685773429 + @property + def guild_banner(self) -> Optional[Asset]: + """Optional[:class:`Asset`]: Returns an :class:`Asset` for the guild banner + the member has. If unavailable, ``None`` is returned. + + .. versionadded:: 2.10 + """ + if self._banner is None: + return None + return Asset._from_guild_banner(self._state, self.guild.id, self.id, self._banner) + @property def activity(self) -> Optional[ActivityTypes]: """Optional[Union[:class:`BaseActivity`, :class:`Spotify`]]: Returns the primary diff --git a/disnake/message.py b/disnake/message.py index f67da56a28..fc9c93539c 100644 --- a/disnake/message.py +++ b/disnake/message.py @@ -1588,6 +1588,35 @@ def system_content(self) -> Optional[str]: if self.type is MessageType.guild_incident_report_false_alarm: return f"{self.author.name} resolved an Activity Alert." + if self.type is MessageType.poll_result: + if not self.embeds: + return + + poll_result_embed = self.embeds[0] + poll_embed_fields: Dict[str, str] = {} + if not poll_result_embed._fields: + return + + for field in poll_result_embed._fields: + poll_embed_fields[field["name"]] = field["value"] + + # should never be none + question = poll_embed_fields["poll_question_text"] + # should never be none + total_votes = poll_embed_fields["total_votes"] + winning_answer = poll_embed_fields.get("victor_answer_text") + winning_answer_votes = poll_embed_fields.get("victor_answer_votes") + msg = f"{self.author.display_name}'s poll {question} has closed." + + if winning_answer and winning_answer_votes: + msg += ( + f"\n\n{winning_answer}" + f"\nWinning answer • {(100 * int(winning_answer_votes)) // int(total_votes)}%" + ) + else: + msg += "\n\nThere was no winner." + return msg + # in the event of an unknown or unsupported message type, we return nothing return None diff --git a/disnake/state.py b/disnake/state.py index c3263976c6..bb9b99a874 100644 --- a/disnake/state.py +++ b/disnake/state.py @@ -458,7 +458,7 @@ def _add_global_application_command( /, ) -> None: if not application_command.id: - AssertionError("The provided application command does not have an ID") + raise AssertionError("The provided application command does not have an ID") self._global_application_commands[application_command.id] = application_command def _remove_global_application_command(self, application_command_id: int, /) -> None: @@ -478,7 +478,7 @@ def _add_guild_application_command( self, guild_id: int, application_command: APIApplicationCommand ) -> None: if not application_command.id: - AssertionError("The provided application command does not have an ID") + raise AssertionError("The provided application command does not have an ID") try: granula = self._guild_application_commands[guild_id] granula[application_command.id] = application_command @@ -2100,14 +2100,10 @@ def _get_partial_interaction_channel( # the factory can't be a DMChannel or GroupChannel here data.setdefault("position", 0) # type: ignore - return ( - isinstance(guild, Guild) - and guild.get_channel_or_thread(channel_id) - or factory( - guild=guild, # type: ignore # FIXME: create proper fallback guild instead of passing Object - state=self, - data=data, # type: ignore # generic payload type - ) + return (isinstance(guild, Guild) and guild.get_channel_or_thread(channel_id)) or factory( + guild=guild, # type: ignore # FIXME: create proper fallback guild instead of passing Object + state=self, + data=data, # type: ignore # generic payload type ) def get_channel(self, id: Optional[int]) -> Optional[Union[Channel, Thread]]: diff --git a/disnake/types/components.py b/disnake/types/components.py index 0ca01cbd1b..14d7c29c55 100644 --- a/disnake/types/components.py +++ b/disnake/types/components.py @@ -8,11 +8,13 @@ from .channel import ChannelType from .emoji import PartialEmoji +from .snowflake import Snowflake ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8] ButtonStyle = Literal[1, 2, 3, 4, 5] TextInputStyle = Literal[1, 2] +SelectDefaultValueType = Literal["user", "role", "channel"] Component = Union["ActionRow", "ButtonComponent", "AnySelectMenu", "TextInput"] @@ -40,12 +42,19 @@ class SelectOption(TypedDict): default: NotRequired[bool] +class SelectDefaultValue(TypedDict): + id: Snowflake + type: SelectDefaultValueType + + class _SelectMenu(TypedDict): custom_id: str placeholder: NotRequired[str] min_values: NotRequired[int] max_values: NotRequired[int] disabled: NotRequired[bool] + # This is technically not applicable to string selects, but for simplicity we'll just have it here + default_values: NotRequired[List[SelectDefaultValue]] class BaseSelectMenu(_SelectMenu): diff --git a/disnake/types/embed.py b/disnake/types/embed.py index 47e6afa59f..a066b084d3 100644 --- a/disnake/types/embed.py +++ b/disnake/types/embed.py @@ -50,7 +50,7 @@ class EmbedAuthor(TypedDict): proxy_icon_url: NotRequired[str] -EmbedType = Literal["rich", "image", "video", "gifv", "article", "link"] +EmbedType = Literal["rich", "image", "video", "gifv", "article", "link", "poll_result"] class Embed(TypedDict, total=False): diff --git a/disnake/types/gateway.py b/disnake/types/gateway.py index 736634c944..7f86cd5959 100644 --- a/disnake/types/gateway.py +++ b/disnake/types/gateway.py @@ -453,6 +453,7 @@ class GuildMemberUpdateEvent(TypedDict): user: User nick: NotRequired[Optional[str]] avatar: Optional[str] + banner: Optional[str] joined_at: Optional[str] premium_since: NotRequired[Optional[str]] deaf: NotRequired[bool] diff --git a/disnake/types/interactions.py b/disnake/types/interactions.py index 9cb8393ea5..5aca5f3cf3 100644 --- a/disnake/types/interactions.py +++ b/disnake/types/interactions.py @@ -105,7 +105,9 @@ class InteractionDataResolved(TypedDict, total=False): members: Dict[Snowflake, Member] roles: Dict[Snowflake, Role] channels: Dict[Snowflake, InteractionChannel] - # only in application commands + + +class ApplicationCommandInteractionDataResolved(InteractionDataResolved, total=False): messages: Dict[Snowflake, Message] attachments: Dict[Snowflake, Attachment] @@ -158,7 +160,7 @@ class ApplicationCommandInteractionData(TypedDict): id: Snowflake name: str type: ApplicationCommandType - resolved: NotRequired[InteractionDataResolved] + resolved: NotRequired[ApplicationCommandInteractionDataResolved] options: NotRequired[List[ApplicationCommandInteractionDataOption]] # this is the guild the command is registered to, not the guild the command was invoked in (see interaction.guild_id) guild_id: NotRequired[Snowflake] diff --git a/disnake/types/message.py b/disnake/types/message.py index 424b7ffd66..1859985460 100644 --- a/disnake/types/message.py +++ b/disnake/types/message.py @@ -10,7 +10,7 @@ from .components import Component from .embed import Embed from .emoji import PartialEmoji -from .interactions import InteractionMessageReference +from .interactions import InteractionDataResolved, InteractionMessageReference from .member import Member, UserWithMember from .poll import Poll from .snowflake import Snowflake, SnowflakeList @@ -80,7 +80,7 @@ class RoleSubscriptionData(TypedDict): # fmt: off -MessageType = Literal[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14, 15, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 36, 37, 38, 39] +MessageType = Literal[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14, 15, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 36, 37, 38, 39, 46] # fmt: on @@ -117,6 +117,8 @@ class Message(TypedDict): position: NotRequired[int] role_subscription_data: NotRequired[RoleSubscriptionData] poll: NotRequired[Poll] + # contains resolved objects for `default_values` of select menus in this message; we currently don't have a use for this + resolved: NotRequired[InteractionDataResolved] # specific to MESSAGE_CREATE/MESSAGE_UPDATE events guild_id: NotRequired[Snowflake] diff --git a/disnake/ui/action_row.py b/disnake/ui/action_row.py index 21ea01cb74..8c5bf769ea 100644 --- a/disnake/ui/action_row.py +++ b/disnake/ui/action_row.py @@ -34,16 +34,21 @@ from .button import Button from .item import WrappedComponent from .select import ChannelSelect, MentionableSelect, RoleSelect, StringSelect, UserSelect -from .select.string import SelectOptionInput, V_co from .text_input import TextInput if TYPE_CHECKING: from typing_extensions import Self + from ..abc import AnyChannel from ..emoji import Emoji + from ..member import Member from ..message import Message from ..partial_emoji import PartialEmoji + from ..role import Role from ..types.components import ActionRow as ActionRowPayload + from ..user import User + from .select.base import SelectDefaultValueInputType, SelectDefaultValueMultiInputType + from .select.string import SelectOptionInput, V_co __all__ = ( "ActionRow", @@ -364,6 +369,7 @@ def add_user_select( min_values: int = 1, max_values: int = 1, disabled: bool = False, + default_values: Optional[Sequence[SelectDefaultValueInputType[Union[User, Member]]]] = None, ) -> SelectCompatibleActionRowT: """Add a user select menu to the action row. Can only be used if the action row holds message components. @@ -389,7 +395,12 @@ def add_user_select( The maximum number of items that must be chosen for this select menu. Defaults to 1 and must be between 1 and 25. disabled: :class:`bool` - Whether the select is disabled or not. + Whether the select is disabled. Defaults to ``False``. + default_values: Optional[Sequence[Union[:class:`~disnake.User`, :class:`.Member`, :class:`.SelectDefaultValue`, :class:`.Object`]]] + The list of values (users/members) that are selected by default. + If set, the number of items must be within the bounds set by ``min_values`` and ``max_values``. + + .. versionadded:: 2.10 Raises ------ @@ -403,6 +414,7 @@ def add_user_select( min_values=min_values, max_values=max_values, disabled=disabled, + default_values=default_values, ), ) return self @@ -415,6 +427,7 @@ def add_role_select( min_values: int = 1, max_values: int = 1, disabled: bool = False, + default_values: Optional[Sequence[SelectDefaultValueInputType[Role]]] = None, ) -> SelectCompatibleActionRowT: """Add a role select menu to the action row. Can only be used if the action row holds message components. @@ -440,7 +453,12 @@ def add_role_select( The maximum number of items that must be chosen for this select menu. Defaults to 1 and must be between 1 and 25. disabled: :class:`bool` - Whether the select is disabled or not. + Whether the select is disabled. Defaults to ``False``. + default_values: Optional[Sequence[Union[:class:`.Role`, :class:`.SelectDefaultValue`, :class:`.Object`]]] + The list of values (roles) that are selected by default. + If set, the number of items must be within the bounds set by ``min_values`` and ``max_values``. + + .. versionadded:: 2.10 Raises ------ @@ -454,6 +472,7 @@ def add_role_select( min_values=min_values, max_values=max_values, disabled=disabled, + default_values=default_values, ), ) return self @@ -466,6 +485,9 @@ def add_mentionable_select( min_values: int = 1, max_values: int = 1, disabled: bool = False, + default_values: Optional[ + Sequence[SelectDefaultValueMultiInputType[Union[User, Member, Role]]] + ] = None, ) -> SelectCompatibleActionRowT: """Add a mentionable (user/member/role) select menu to the action row. Can only be used if the action row holds message components. @@ -491,7 +513,14 @@ def add_mentionable_select( The maximum number of items that must be chosen for this select menu. Defaults to 1 and must be between 1 and 25. disabled: :class:`bool` - Whether the select is disabled or not. + Whether the select is disabled. Defaults to ``False``. + default_values: Optional[Sequence[Union[:class:`~disnake.User`, :class:`.Member`, :class:`.Role`, :class:`.SelectDefaultValue`]]] + The list of values (users/roles) that are selected by default. + If set, the number of items must be within the bounds set by ``min_values`` and ``max_values``. + + Note that unlike other select menu types, this does not support :class:`.Object`\\s due to ambiguities. + + .. versionadded:: 2.10 Raises ------ @@ -505,6 +534,7 @@ def add_mentionable_select( min_values=min_values, max_values=max_values, disabled=disabled, + default_values=default_values, ), ) return self @@ -518,6 +548,7 @@ def add_channel_select( max_values: int = 1, disabled: bool = False, channel_types: Optional[List[ChannelType]] = None, + default_values: Optional[Sequence[SelectDefaultValueInputType[AnyChannel]]] = None, ) -> SelectCompatibleActionRowT: """Add a channel select menu to the action row. Can only be used if the action row holds message components. @@ -543,10 +574,15 @@ def add_channel_select( The maximum number of items that must be chosen for this select menu. Defaults to 1 and must be between 1 and 25. disabled: :class:`bool` - Whether the select is disabled or not. + Whether the select is disabled. Defaults to ``False``. channel_types: Optional[List[:class:`.ChannelType`]] The list of channel types that can be selected in this select menu. Defaults to all types (i.e. ``None``). + default_values: Optional[Sequence[Union[:class:`.abc.GuildChannel`, :class:`.Thread`, :class:`.abc.PrivateChannel`, :class:`.PartialMessageable`, :class:`.SelectDefaultValue`, :class:`.Object`]]] + The list of values (channels) that are selected by default. + If set, the number of items must be within the bounds set by ``min_values`` and ``max_values``. + + .. versionadded:: 2.10 Raises ------ @@ -561,6 +597,7 @@ def add_channel_select( max_values=max_values, disabled=disabled, channel_types=channel_types, + default_values=default_values, ), ) return self diff --git a/disnake/ui/button.py b/disnake/ui/button.py index a961ba29ab..9995013ebb 100644 --- a/disnake/ui/button.py +++ b/disnake/ui/button.py @@ -21,7 +21,7 @@ from ..enums import ButtonStyle, ComponentType from ..partial_emoji import PartialEmoji, _EmojiTag from ..utils import MISSING -from .item import DecoratedItem, Item, Object +from .item import DecoratedItem, Item, ItemShape __all__ = ( "Button", @@ -269,13 +269,13 @@ def button( @overload def button( - cls: Type[Object[B_co, P]], *_: P.args, **kwargs: P.kwargs + cls: Type[ItemShape[B_co, P]], *_: P.args, **kwargs: P.kwargs ) -> Callable[[ItemCallbackType[B_co]], DecoratedItem[B_co]]: ... def button( - cls: Type[Object[B_co, ...]] = Button[Any], **kwargs: Any + cls: Type[ItemShape[B_co, ...]] = Button[Any], **kwargs: Any ) -> Callable[[ItemCallbackType[B_co]], DecoratedItem[B_co]]: """A decorator that attaches a button to a component. diff --git a/disnake/ui/item.py b/disnake/ui/item.py index 464eb4d588..c4d29c6417 100644 --- a/disnake/ui/item.py +++ b/disnake/ui/item.py @@ -180,7 +180,7 @@ def __get__(self, obj: Any, objtype: Any) -> I_co: P = ParamSpec("P") -class Object(Protocol[T_co, P]): +class ItemShape(Protocol[T_co, P]): def __new__(cls) -> T_co: ... diff --git a/disnake/ui/select/base.py b/disnake/ui/select/base.py index cea174000d..912a24ba1f 100644 --- a/disnake/ui/select/base.py +++ b/disnake/ui/select/base.py @@ -9,25 +9,31 @@ TYPE_CHECKING, Any, Callable, + ClassVar, Generic, List, + Mapping, Optional, + Sequence, Tuple, Type, TypeVar, + Union, get_origin, ) -from ...components import AnySelectMenu -from ...enums import ComponentType -from ...utils import MISSING -from ..item import DecoratedItem, Item, Object +from ...components import AnySelectMenu, SelectDefaultValue +from ...enums import ComponentType, SelectDefaultValueType +from ...object import Object +from ...utils import MISSING, humanize_list +from ..item import DecoratedItem, Item, ItemShape __all__ = ("BaseSelect",) if TYPE_CHECKING: from typing_extensions import ParamSpec, Self + from ...abc import Snowflake from ...interactions import MessageInteraction from ..item import ItemCallbackType from ..view import View @@ -42,6 +48,10 @@ SelectValueT = TypeVar("SelectValueT") P = ParamSpec("P") +SelectDefaultValueMultiInputType = Union[SelectValueT, SelectDefaultValue] +# almost the same as above, but with `Object`; used for selects where the type isn't ambiguous (i.e. all except mentionable select) +SelectDefaultValueInputType = Union[SelectDefaultValueMultiInputType[SelectValueT], Object] + class BaseSelect(Generic[SelectMenuT, SelectValueT, V_co], Item[V_co], ABC): """Represents an abstract UI select menu. @@ -68,6 +78,9 @@ class BaseSelect(Generic[SelectMenuT, SelectValueT, V_co], Item[V_co], ABC): # We have to set this to MISSING in order to overwrite the abstract property from WrappedComponent _underlying: SelectMenuT = MISSING + # Subclasses are expected to set this + _default_value_type_map: ClassVar[Mapping[SelectDefaultValueType, Tuple[Type[Snowflake], ...]]] + def __init__( self, underlying_type: Type[SelectMenuT], @@ -78,6 +91,7 @@ def __init__( min_values: int, max_values: int, disabled: bool, + default_values: Optional[Sequence[SelectDefaultValueInputType[SelectValueT]]], row: Optional[int], ) -> None: super().__init__() @@ -91,6 +105,7 @@ def __init__( min_values=min_values, max_values=max_values, disabled=disabled, + default_values=self._transform_default_values(default_values) if default_values else [], ) self.row = row @@ -145,6 +160,19 @@ def disabled(self) -> bool: def disabled(self, value: bool) -> None: self._underlying.disabled = bool(value) + @property + def default_values(self) -> List[SelectDefaultValue]: + """List[:class:`.SelectDefaultValue`]: The list of values that are selected by default. + Only available for auto-populated select menus. + """ + return self._underlying.default_values + + @default_values.setter + def default_values( + self, value: Optional[Sequence[SelectDefaultValueInputType[SelectValueT]]] + ) -> None: + self._underlying.default_values = self._transform_default_values(value) if value else [] + @property def values(self) -> List[SelectValueT]: return self._selected_values @@ -171,9 +199,47 @@ def is_dispatchable(self) -> bool: """ return True + @classmethod + def _transform_default_values( + cls, values: Sequence[SelectDefaultValueInputType[SelectValueT]] + ) -> List[SelectDefaultValue]: + result: List[SelectDefaultValue] = [] + + for value in values: + # If we have a SelectDefaultValue, just use it as-is + if isinstance(value, SelectDefaultValue): + if value.type not in cls._default_value_type_map: + allowed_types = [str(t) for t in cls._default_value_type_map] + raise ValueError( + f"SelectDefaultValue.type should be {humanize_list(allowed_types, 'or')}, not {value.type}" + ) + result.append(value) + continue + + # Otherwise, look through the list of allowed input types and + # get the associated SelectDefaultValueType + for ( + value_type, # noqa: B007 # we use value_type outside of the loop + types, + ) in cls._default_value_type_map.items(): + if isinstance(value, types): + break + else: + allowed_types = [ + t.__name__ for ts in cls._default_value_type_map.values() for t in ts + ] + allowed_types.append(SelectDefaultValue.__name__) + raise TypeError( + f"Expected type of default value to be {humanize_list(allowed_types, 'or')}, not {type(value)!r}" + ) + + result.append(SelectDefaultValue(value.id, value_type)) + + return result + def _create_decorator( - cls: Type[Object[S_co, P]], + cls: Type[ItemShape[S_co, P]], # only for input validation base_cls: Type[BaseSelect[Any, Any, Any]], /, diff --git a/disnake/ui/select/channel.py b/disnake/ui/select/channel.py index 9214b71223..f004308482 100644 --- a/disnake/ui/select/channel.py +++ b/disnake/ui/select/channel.py @@ -2,18 +2,35 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Callable, List, Optional, Tuple, Type, TypeVar, overload +from typing import ( + TYPE_CHECKING, + Any, + Callable, + ClassVar, + List, + Mapping, + Optional, + Sequence, + Tuple, + Type, + TypeVar, + overload, +) +from ...abc import GuildChannel, Snowflake +from ...channel import DMChannel, GroupChannel, PartialMessageable from ...components import ChannelSelectMenu -from ...enums import ChannelType, ComponentType +from ...enums import ChannelType, ComponentType, SelectDefaultValueType +from ...object import Object +from ...threads import Thread from ...utils import MISSING -from .base import BaseSelect, P, V_co, _create_decorator +from .base import BaseSelect, P, SelectDefaultValueInputType, V_co, _create_decorator if TYPE_CHECKING: from typing_extensions import Self from ...abc import AnyChannel - from ..item import DecoratedItem, ItemCallbackType, Object + from ..item import DecoratedItem, ItemCallbackType, ItemShape __all__ = ( @@ -46,15 +63,20 @@ class ChannelSelect(BaseSelect[ChannelSelectMenu, "AnyChannel", V_co]): Defaults to 1 and must be between 1 and 25. disabled: :class:`bool` Whether the select is disabled. + channel_types: Optional[List[:class:`.ChannelType`]] + The list of channel types that can be selected in this select menu. + Defaults to all types (i.e. ``None``). + default_values: Optional[Sequence[Union[:class:`.abc.GuildChannel`, :class:`.Thread`, :class:`.abc.PrivateChannel`, :class:`.PartialMessageable`, :class:`.SelectDefaultValue`, :class:`.Object`]]] + The list of values (channels) that are selected by default. + If set, the number of items must be within the bounds set by ``min_values`` and ``max_values``. + + .. versionadded:: 2.10 row: Optional[:class:`int`] The relative row this select menu belongs to. A Discord component can only have 5 rows. By default, items are arranged automatically into those 5 rows. If you'd like to control the relative positioning of the row then passing an index is advised. For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic ordering. The row number must be between 0 and 4 (i.e. zero indexed). - channel_types: Optional[List[:class:`.ChannelType`]] - The list of channel types that can be selected in this select menu. - Defaults to all types (i.e. ``None``). Attributes ---------- @@ -64,6 +86,19 @@ class ChannelSelect(BaseSelect[ChannelSelectMenu, "AnyChannel", V_co]): __repr_attributes__: Tuple[str, ...] = BaseSelect.__repr_attributes__ + ("channel_types",) + _default_value_type_map: ClassVar[ + Mapping[SelectDefaultValueType, Tuple[Type[Snowflake], ...]] + ] = { + SelectDefaultValueType.channel: ( + GuildChannel, + Thread, + DMChannel, + GroupChannel, + PartialMessageable, + Object, + ), + } + @overload def __init__( self: ChannelSelect[None], @@ -74,6 +109,7 @@ def __init__( max_values: int = 1, disabled: bool = False, channel_types: Optional[List[ChannelType]] = None, + default_values: Optional[Sequence[SelectDefaultValueInputType[AnyChannel]]] = None, row: Optional[int] = None, ) -> None: ... @@ -88,6 +124,7 @@ def __init__( max_values: int = 1, disabled: bool = False, channel_types: Optional[List[ChannelType]] = None, + default_values: Optional[Sequence[SelectDefaultValueInputType[AnyChannel]]] = None, row: Optional[int] = None, ) -> None: ... @@ -101,6 +138,7 @@ def __init__( max_values: int = 1, disabled: bool = False, channel_types: Optional[List[ChannelType]] = None, + default_values: Optional[Sequence[SelectDefaultValueInputType[AnyChannel]]] = None, row: Optional[int] = None, ) -> None: super().__init__( @@ -111,6 +149,7 @@ def __init__( min_values=min_values, max_values=max_values, disabled=disabled, + default_values=default_values, row=row, ) self._underlying.channel_types = channel_types or None @@ -124,6 +163,7 @@ def from_component(cls, component: ChannelSelectMenu) -> Self: max_values=component.max_values, disabled=component.disabled, channel_types=component.channel_types, + default_values=component.default_values, row=None, ) @@ -155,6 +195,7 @@ def channel_select( max_values: int = 1, disabled: bool = False, channel_types: Optional[List[ChannelType]] = None, + default_values: Optional[Sequence[SelectDefaultValueInputType[AnyChannel]]] = None, row: Optional[int] = None, ) -> Callable[[ItemCallbackType[ChannelSelect[V_co]]], DecoratedItem[ChannelSelect[V_co]]]: ... @@ -162,13 +203,13 @@ def channel_select( @overload def channel_select( - cls: Type[Object[S_co, P]], *_: P.args, **kwargs: P.kwargs + cls: Type[ItemShape[S_co, P]], *_: P.args, **kwargs: P.kwargs ) -> Callable[[ItemCallbackType[S_co]], DecoratedItem[S_co]]: ... def channel_select( - cls: Type[Object[S_co, ...]] = ChannelSelect[Any], **kwargs: Any + cls: Type[ItemShape[S_co, ...]] = ChannelSelect[Any], **kwargs: Any ) -> Callable[[ItemCallbackType[S_co]], DecoratedItem[S_co]]: """A decorator that attaches a channel select menu to a component. @@ -209,5 +250,10 @@ def channel_select( channel_types: Optional[List[:class:`.ChannelType`]] The list of channel types that can be selected in this select menu. Defaults to all types (i.e. ``None``). + default_values: Optional[Sequence[Union[:class:`.abc.GuildChannel`, :class:`.Thread`, :class:`.abc.PrivateChannel`, :class:`.PartialMessageable`, :class:`.SelectDefaultValue`, :class:`.Object`]]] + The list of values (channels) that are selected by default. + If set, the number of items must be within the bounds set by ``min_values`` and ``max_values``. + + .. versionadded:: 2.10 """ return _create_decorator(cls, ChannelSelect, **kwargs) diff --git a/disnake/ui/select/mentionable.py b/disnake/ui/select/mentionable.py index 860903f7f1..e98dfb29c9 100644 --- a/disnake/ui/select/mentionable.py +++ b/disnake/ui/select/mentionable.py @@ -2,20 +2,34 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Callable, Optional, Type, TypeVar, Union, overload +from typing import ( + TYPE_CHECKING, + Any, + Callable, + ClassVar, + Mapping, + Optional, + Sequence, + Tuple, + Type, + TypeVar, + Union, + overload, +) +from ...abc import Snowflake from ...components import MentionableSelectMenu -from ...enums import ComponentType +from ...enums import ComponentType, SelectDefaultValueType +from ...member import Member +from ...role import Role +from ...user import ClientUser, User from ...utils import MISSING -from .base import BaseSelect, P, V_co, _create_decorator +from .base import BaseSelect, P, SelectDefaultValueMultiInputType, V_co, _create_decorator if TYPE_CHECKING: from typing_extensions import Self - from ...member import Member - from ...role import Role - from ...user import User - from ..item import DecoratedItem, ItemCallbackType, Object + from ..item import DecoratedItem, ItemCallbackType, ItemShape __all__ = ( @@ -48,6 +62,13 @@ class MentionableSelect(BaseSelect[MentionableSelectMenu, "Union[User, Member, R Defaults to 1 and must be between 1 and 25. disabled: :class:`bool` Whether the select is disabled. + default_values: Optional[Sequence[Union[:class:`~disnake.User`, :class:`.Member`, :class:`.Role`, :class:`.SelectDefaultValue`]]] + The list of values (users/roles) that are selected by default. + If set, the number of items must be within the bounds set by ``min_values`` and ``max_values``. + + Note that unlike other select menu types, this does not support :class:`.Object`\\s due to ambiguities. + + .. versionadded:: 2.10 row: Optional[:class:`int`] The relative row this select menu belongs to. A Discord component can only have 5 rows. By default, items are arranged automatically into those 5 rows. If you'd @@ -61,6 +82,13 @@ class MentionableSelect(BaseSelect[MentionableSelectMenu, "Union[User, Member, R A list of users, members and/or roles that have been selected by the user. """ + _default_value_type_map: ClassVar[ + Mapping[SelectDefaultValueType, Tuple[Type[Snowflake], ...]] + ] = { + SelectDefaultValueType.user: (Member, User, ClientUser), + SelectDefaultValueType.role: (Role,), + } + @overload def __init__( self: MentionableSelect[None], @@ -70,6 +98,9 @@ def __init__( min_values: int = 1, max_values: int = 1, disabled: bool = False, + default_values: Optional[ + Sequence[SelectDefaultValueMultiInputType[Union[User, Member, Role]]] + ] = None, row: Optional[int] = None, ) -> None: ... @@ -83,6 +114,9 @@ def __init__( min_values: int = 1, max_values: int = 1, disabled: bool = False, + default_values: Optional[ + Sequence[SelectDefaultValueMultiInputType[Union[User, Member, Role]]] + ] = None, row: Optional[int] = None, ) -> None: ... @@ -95,6 +129,9 @@ def __init__( min_values: int = 1, max_values: int = 1, disabled: bool = False, + default_values: Optional[ + Sequence[SelectDefaultValueMultiInputType[Union[User, Member, Role]]] + ] = None, row: Optional[int] = None, ) -> None: super().__init__( @@ -105,6 +142,7 @@ def __init__( min_values=min_values, max_values=max_values, disabled=disabled, + default_values=default_values, row=row, ) @@ -116,6 +154,7 @@ def from_component(cls, component: MentionableSelectMenu) -> Self: min_values=component.min_values, max_values=component.max_values, disabled=component.disabled, + default_values=component.default_values, row=None, ) @@ -131,6 +170,9 @@ def mentionable_select( min_values: int = 1, max_values: int = 1, disabled: bool = False, + default_values: Optional[ + Sequence[SelectDefaultValueMultiInputType[Union[User, Member, Role]]] + ] = None, row: Optional[int] = None, ) -> Callable[[ItemCallbackType[MentionableSelect[V_co]]], DecoratedItem[MentionableSelect[V_co]]]: ... @@ -138,13 +180,13 @@ def mentionable_select( @overload def mentionable_select( - cls: Type[Object[S_co, P]], *_: P.args, **kwargs: P.kwargs + cls: Type[ItemShape[S_co, P]], *_: P.args, **kwargs: P.kwargs ) -> Callable[[ItemCallbackType[S_co]], DecoratedItem[S_co]]: ... def mentionable_select( - cls: Type[Object[S_co, ...]] = MentionableSelect[Any], **kwargs: Any + cls: Type[ItemShape[S_co, ...]] = MentionableSelect[Any], **kwargs: Any ) -> Callable[[ItemCallbackType[S_co]], DecoratedItem[S_co]]: """A decorator that attaches a mentionable (user/member/role) select menu to a component. @@ -182,5 +224,12 @@ def mentionable_select( Defaults to 1 and must be between 1 and 25. disabled: :class:`bool` Whether the select is disabled. Defaults to ``False``. + default_values: Optional[Sequence[Union[:class:`~disnake.User`, :class:`.Member`, :class:`.Role`, :class:`.SelectDefaultValue`]]] + The list of values (users/roles) that are selected by default. + If set, the number of items must be within the bounds set by ``min_values`` and ``max_values``. + + Note that unlike other select menu types, this does not support :class:`.Object`\\s due to ambiguities. + + .. versionadded:: 2.10 """ return _create_decorator(cls, MentionableSelect, **kwargs) diff --git a/disnake/ui/select/role.py b/disnake/ui/select/role.py index f3dbec4b17..4cb886168f 100644 --- a/disnake/ui/select/role.py +++ b/disnake/ui/select/role.py @@ -2,18 +2,32 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Callable, Optional, Type, TypeVar, overload +from typing import ( + TYPE_CHECKING, + Any, + Callable, + ClassVar, + Mapping, + Optional, + Sequence, + Tuple, + Type, + TypeVar, + overload, +) +from ...abc import Snowflake from ...components import RoleSelectMenu -from ...enums import ComponentType +from ...enums import ComponentType, SelectDefaultValueType +from ...object import Object +from ...role import Role from ...utils import MISSING -from .base import BaseSelect, P, V_co, _create_decorator +from .base import BaseSelect, P, SelectDefaultValueInputType, V_co, _create_decorator if TYPE_CHECKING: from typing_extensions import Self - from ...role import Role - from ..item import DecoratedItem, ItemCallbackType, Object + from ..item import DecoratedItem, ItemCallbackType, ItemShape __all__ = ( @@ -46,6 +60,11 @@ class RoleSelect(BaseSelect[RoleSelectMenu, "Role", V_co]): Defaults to 1 and must be between 1 and 25. disabled: :class:`bool` Whether the select is disabled. + default_values: Optional[Sequence[Union[:class:`.Role`, :class:`.SelectDefaultValue`, :class:`.Object`]]] + The list of values (roles) that are selected by default. + If set, the number of items must be within the bounds set by ``min_values`` and ``max_values``. + + .. versionadded:: 2.10 row: Optional[:class:`int`] The relative row this select menu belongs to. A Discord component can only have 5 rows. By default, items are arranged automatically into those 5 rows. If you'd @@ -59,6 +78,12 @@ class RoleSelect(BaseSelect[RoleSelectMenu, "Role", V_co]): A list of roles that have been selected by the user. """ + _default_value_type_map: ClassVar[ + Mapping[SelectDefaultValueType, Tuple[Type[Snowflake], ...]] + ] = { + SelectDefaultValueType.role: (Role, Object), + } + @overload def __init__( self: RoleSelect[None], @@ -68,6 +93,7 @@ def __init__( min_values: int = 1, max_values: int = 1, disabled: bool = False, + default_values: Optional[Sequence[SelectDefaultValueInputType[Role]]] = None, row: Optional[int] = None, ) -> None: ... @@ -81,6 +107,7 @@ def __init__( min_values: int = 1, max_values: int = 1, disabled: bool = False, + default_values: Optional[Sequence[SelectDefaultValueInputType[Role]]] = None, row: Optional[int] = None, ) -> None: ... @@ -93,6 +120,7 @@ def __init__( min_values: int = 1, max_values: int = 1, disabled: bool = False, + default_values: Optional[Sequence[SelectDefaultValueInputType[Role]]] = None, row: Optional[int] = None, ) -> None: super().__init__( @@ -103,6 +131,7 @@ def __init__( min_values=min_values, max_values=max_values, disabled=disabled, + default_values=default_values, row=row, ) @@ -114,6 +143,7 @@ def from_component(cls, component: RoleSelectMenu) -> Self: min_values=component.min_values, max_values=component.max_values, disabled=component.disabled, + default_values=component.default_values, row=None, ) @@ -129,6 +159,7 @@ def role_select( min_values: int = 1, max_values: int = 1, disabled: bool = False, + default_values: Optional[Sequence[SelectDefaultValueInputType[Role]]] = None, row: Optional[int] = None, ) -> Callable[[ItemCallbackType[RoleSelect[V_co]]], DecoratedItem[RoleSelect[V_co]]]: ... @@ -136,13 +167,13 @@ def role_select( @overload def role_select( - cls: Type[Object[S_co, P]], *_: P.args, **kwargs: P.kwargs + cls: Type[ItemShape[S_co, P]], *_: P.args, **kwargs: P.kwargs ) -> Callable[[ItemCallbackType[S_co]], DecoratedItem[S_co]]: ... def role_select( - cls: Type[Object[S_co, ...]] = RoleSelect[Any], **kwargs: Any + cls: Type[ItemShape[S_co, ...]] = RoleSelect[Any], **kwargs: Any ) -> Callable[[ItemCallbackType[S_co]], DecoratedItem[S_co]]: """A decorator that attaches a role select menu to a component. @@ -180,5 +211,10 @@ def role_select( Defaults to 1 and must be between 1 and 25. disabled: :class:`bool` Whether the select is disabled. Defaults to ``False``. + default_values: Optional[Sequence[Union[:class:`.Role`, :class:`.SelectDefaultValue`, :class:`.Object`]]] + The list of values (roles) that are selected by default. + If set, the number of items must be within the bounds set by ``min_values`` and ``max_values``. + + .. versionadded:: 2.10 """ return _create_decorator(cls, RoleSelect, **kwargs) diff --git a/disnake/ui/select/string.py b/disnake/ui/select/string.py index 3eeedc1f22..3b12d80388 100644 --- a/disnake/ui/select/string.py +++ b/disnake/ui/select/string.py @@ -6,8 +6,10 @@ TYPE_CHECKING, Any, Callable, + ClassVar, Dict, List, + Mapping, Optional, Tuple, Type, @@ -16,8 +18,9 @@ overload, ) +from ...abc import Snowflake from ...components import SelectOption, StringSelectMenu -from ...enums import ComponentType +from ...enums import ComponentType, SelectDefaultValueType from ...utils import MISSING from .base import BaseSelect, P, V_co, _create_decorator @@ -26,7 +29,7 @@ from ...emoji import Emoji from ...partial_emoji import PartialEmoji - from ..item import DecoratedItem, ItemCallbackType, Object + from ..item import DecoratedItem, ItemCallbackType, ItemShape __all__ = ( @@ -98,6 +101,11 @@ class StringSelect(BaseSelect[StringSelectMenu, str, V_co]): __repr_attributes__: Tuple[str, ...] = BaseSelect.__repr_attributes__ + ("options",) + # In practice this should never be used by anything, might as well have it anyway though. + _default_value_type_map: ClassVar[ + Mapping[SelectDefaultValueType, Tuple[Type[Snowflake], ...]] + ] = {} + @overload def __init__( self: StringSelect[None], @@ -145,6 +153,7 @@ def __init__( min_values=min_values, max_values=max_values, disabled=disabled, + default_values=None, row=row, ) self._underlying.options = [] if options is MISSING else _parse_select_options(options) @@ -262,13 +271,13 @@ def string_select( @overload def string_select( - cls: Type[Object[S_co, P]], *_: P.args, **kwargs: P.kwargs + cls: Type[ItemShape[S_co, P]], *_: P.args, **kwargs: P.kwargs ) -> Callable[[ItemCallbackType[S_co]], DecoratedItem[S_co]]: ... def string_select( - cls: Type[Object[S_co, ...]] = StringSelect[Any], **kwargs: Any + cls: Type[ItemShape[S_co, ...]] = StringSelect[Any], **kwargs: Any ) -> Callable[[ItemCallbackType[S_co]], DecoratedItem[S_co]]: """A decorator that attaches a string select menu to a component. diff --git a/disnake/ui/select/user.py b/disnake/ui/select/user.py index 4868894a83..9ab9b803ce 100644 --- a/disnake/ui/select/user.py +++ b/disnake/ui/select/user.py @@ -2,19 +2,34 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Callable, Optional, Type, TypeVar, Union, overload +from typing import ( + TYPE_CHECKING, + Any, + Callable, + ClassVar, + Mapping, + Optional, + Sequence, + Tuple, + Type, + TypeVar, + Union, + overload, +) +from ...abc import Snowflake from ...components import UserSelectMenu -from ...enums import ComponentType +from ...enums import ComponentType, SelectDefaultValueType +from ...member import Member +from ...object import Object +from ...user import ClientUser, User from ...utils import MISSING -from .base import BaseSelect, P, V_co, _create_decorator +from .base import BaseSelect, P, SelectDefaultValueInputType, V_co, _create_decorator if TYPE_CHECKING: from typing_extensions import Self - from ...member import Member - from ...user import User - from ..item import DecoratedItem, ItemCallbackType, Object + from ..item import DecoratedItem, ItemCallbackType, ItemShape __all__ = ( @@ -47,6 +62,11 @@ class UserSelect(BaseSelect[UserSelectMenu, "Union[User, Member]", V_co]): Defaults to 1 and must be between 1 and 25. disabled: :class:`bool` Whether the select is disabled. + default_values: Optional[Sequence[Union[:class:`~disnake.User`, :class:`.Member`, :class:`.SelectDefaultValue`, :class:`.Object`]]] + The list of values (users/members) that are selected by default. + If set, the number of items must be within the bounds set by ``min_values`` and ``max_values``. + + .. versionadded:: 2.10 row: Optional[:class:`int`] The relative row this select menu belongs to. A Discord component can only have 5 rows. By default, items are arranged automatically into those 5 rows. If you'd @@ -60,6 +80,12 @@ class UserSelect(BaseSelect[UserSelectMenu, "Union[User, Member]", V_co]): A list of users/members that have been selected by the user. """ + _default_value_type_map: ClassVar[ + Mapping[SelectDefaultValueType, Tuple[Type[Snowflake], ...]] + ] = { + SelectDefaultValueType.user: (Member, User, ClientUser, Object), + } + @overload def __init__( self: UserSelect[None], @@ -69,6 +95,7 @@ def __init__( min_values: int = 1, max_values: int = 1, disabled: bool = False, + default_values: Optional[Sequence[SelectDefaultValueInputType[Union[User, Member]]]] = None, row: Optional[int] = None, ) -> None: ... @@ -82,6 +109,7 @@ def __init__( min_values: int = 1, max_values: int = 1, disabled: bool = False, + default_values: Optional[Sequence[SelectDefaultValueInputType[Union[User, Member]]]] = None, row: Optional[int] = None, ) -> None: ... @@ -94,6 +122,7 @@ def __init__( min_values: int = 1, max_values: int = 1, disabled: bool = False, + default_values: Optional[Sequence[SelectDefaultValueInputType[Union[User, Member]]]] = None, row: Optional[int] = None, ) -> None: super().__init__( @@ -104,6 +133,7 @@ def __init__( min_values=min_values, max_values=max_values, disabled=disabled, + default_values=default_values, row=row, ) @@ -115,6 +145,7 @@ def from_component(cls, component: UserSelectMenu) -> Self: min_values=component.min_values, max_values=component.max_values, disabled=component.disabled, + default_values=component.default_values, row=None, ) @@ -130,6 +161,7 @@ def user_select( min_values: int = 1, max_values: int = 1, disabled: bool = False, + default_values: Optional[Sequence[SelectDefaultValueInputType[Union[User, Member]]]] = None, row: Optional[int] = None, ) -> Callable[[ItemCallbackType[UserSelect[V_co]]], DecoratedItem[UserSelect[V_co]]]: ... @@ -137,13 +169,13 @@ def user_select( @overload def user_select( - cls: Type[Object[S_co, P]], *_: P.args, **kwargs: P.kwargs + cls: Type[ItemShape[S_co, P]], *_: P.args, **kwargs: P.kwargs ) -> Callable[[ItemCallbackType[S_co]], DecoratedItem[S_co]]: ... def user_select( - cls: Type[Object[S_co, ...]] = UserSelect[Any], **kwargs: Any + cls: Type[ItemShape[S_co, ...]] = UserSelect[Any], **kwargs: Any ) -> Callable[[ItemCallbackType[S_co]], DecoratedItem[S_co]]: """A decorator that attaches a user select menu to a component. @@ -181,5 +213,10 @@ def user_select( Defaults to 1 and must be between 1 and 25. disabled: :class:`bool` Whether the select is disabled. Defaults to ``False``. + default_values: Optional[Sequence[Union[:class:`~disnake.User`, :class:`.Member`, :class:`.SelectDefaultValue`, :class:`.Object`]]] + The list of values (users/members) that are selected by default. + If set, the number of items must be within the bounds set by ``min_values`` and ``max_values``. + + .. versionadded:: 2.10 """ return _create_decorator(cls, UserSelect, **kwargs) diff --git a/disnake/utils.py b/disnake/utils.py index 9061cd0f61..07754afafc 100644 --- a/disnake/utils.py +++ b/disnake/utils.py @@ -1158,7 +1158,7 @@ def evaluate_annotation( return cache[tp] # this is how annotations are supposed to be unstringifed - evaluated = eval(tp, globals, locals) # noqa: PGH001, S307 + evaluated = eval(tp, globals, locals) # noqa: S307 # recurse to resolve nested args further evaluated = evaluate_annotation(evaluated, globals, locals, cache) diff --git a/docs/api/components.rst b/docs/api/components.rst index 5702e55238..628d6c6430 100644 --- a/docs/api/components.rst +++ b/docs/api/components.rst @@ -98,25 +98,30 @@ UserSelectMenu :members: :inherited-members: -TextInput -~~~~~~~~~ +SelectOption +~~~~~~~~~~~~ -.. attributetable:: TextInput +.. attributetable:: SelectOption -.. autoclass:: TextInput() +.. autoclass:: SelectOption :members: - :inherited-members: -Data Classes -------------- +SelectDefaultValue +~~~~~~~~~~~~~~~~~~ -SelectOption -~~~~~~~~~~~~ +.. attributetable:: SelectDefaultValue -.. attributetable:: SelectOption +.. autoclass:: SelectDefaultValue + :members: -.. autoclass:: SelectOption +TextInput +~~~~~~~~~ + +.. attributetable:: TextInput + +.. autoclass:: TextInput() :members: + :inherited-members: Enumerations ------------ @@ -237,3 +242,24 @@ TextInputStyle .. attribute:: long An alias for :attr:`paragraph`. + +SelectDefaultValueType +~~~~~~~~~~~~~~~~~~~~~~ + +.. class:: SelectDefaultValueType + + Represents the type of a :class:`SelectDefaultValue`. + + .. versionadded:: 2.10 + + .. attribute:: user + + Represents a user/member. + + .. attribute:: role + + Represents a role. + + .. attribute:: channel + + Represents a channel. diff --git a/docs/api/messages.rst b/docs/api/messages.rst index 6c02bc0208..89f92f52f7 100644 --- a/docs/api/messages.rst +++ b/docs/api/messages.rst @@ -401,6 +401,12 @@ MessageType .. versionadded:: 2.10 + .. attribute:: poll_result + + The system message denoting that a poll expired, announcing the most voted answer. + + .. versionadded:: 2.10 + PollLayoutType ~~~~~~~~~~~~~~ @@ -414,6 +420,7 @@ PollLayoutType The default poll layout type. + Events ------ diff --git a/docs/api/ui.rst b/docs/api/ui.rst index c7c061f137..725c65fb77 100644 --- a/docs/api/ui.rst +++ b/docs/api/ui.rst @@ -71,6 +71,7 @@ StringSelect .. autoclass:: StringSelect :members: :inherited-members: + :exclude-members: default_values ChannelSelect ~~~~~~~~~~~~~ @@ -134,14 +135,14 @@ Functions .. autofunction:: string_select(cls=StringSelect, *, custom_id=..., placeholder=None, min_values=1, max_values=1, options=..., disabled=False, row=None) :decorator: -.. autofunction:: channel_select(cls=ChannelSelect, *, custom_id=..., placeholder=None, min_values=1, max_values=1, disabled=False, channel_types=None, row=None) +.. autofunction:: channel_select(cls=ChannelSelect, *, custom_id=..., placeholder=None, min_values=1, max_values=1, disabled=False, channel_types=None, default_values=None, row=None) :decorator: -.. autofunction:: mentionable_select(cls=MentionableSelect, *, custom_id=..., placeholder=None, min_values=1, max_values=1, disabled=False, row=None) +.. autofunction:: mentionable_select(cls=MentionableSelect, *, custom_id=..., placeholder=None, min_values=1, max_values=1, disabled=False, default_values=None, row=None) :decorator: -.. autofunction:: role_select(cls=RoleSelect, *, custom_id=..., placeholder=None, min_values=1, max_values=1, disabled=False, row=None) +.. autofunction:: role_select(cls=RoleSelect, *, custom_id=..., placeholder=None, min_values=1, max_values=1, disabled=False, default_values=None, row=None) :decorator: -.. autofunction:: user_select(cls=UserSelect, *, custom_id=..., placeholder=None, min_values=1, max_values=1, disabled=False, row=None) +.. autofunction:: user_select(cls=UserSelect, *, custom_id=..., placeholder=None, min_values=1, max_values=1, disabled=False, default_values=None, row=None) :decorator: diff --git a/docs/whats_new.rst b/docs/whats_new.rst index 2293c918d7..4564e88651 100644 --- a/docs/whats_new.rst +++ b/docs/whats_new.rst @@ -17,6 +17,33 @@ in specific versions. Please see :ref:`version_guarantees` for more information. .. towncrier release notes start +.. _vp2p9p3: + +v2.9.3 +------ + +This is a maintainance release with several minor bugfixes. +Notably, this includes support for a newer voice encryption mode; +all modes supported in previous versions are scheduled to be :ddocs:`discontinued ` on 18th November 2024, +and voice connections using the builtin :class:`VoiceClient` will fail to connect. + +New Features +~~~~~~~~~~~~ +- Add support for ``aead_xchacha20_poly1305_rtpsize`` encryption mode for voice connections, and remove deprecated ``xsalsa20_poly1305*`` modes. (:issue:`1228`) + +Bug Fixes +~~~~~~~~~ +- Attempt to handle abrupt websocket closures on ``aiohttp >= 3.9.0`` and ``python < 3.11.0`` gracefully. (:issue:`1241`) + +Documentation +~~~~~~~~~~~~~ +- Adding some clarifying documentation around the type of :attr:`AuditLogEntry.extra` when the action is :attr:`~AuditLogAction.overwrite_create`. (:issue:`1180`) + +Miscellaneous +~~~~~~~~~~~~~ +- Raise PyNaCl version requirement to ``v1.5.0``. (:issue:`1228`) + + .. _vp2p9p2: v2.9.2 diff --git a/pyproject.toml b/pyproject.toml index 9bab9ae822..ed9467bfec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,7 +71,7 @@ tools = [ "slotscheck~=0.16.4", "python-dotenv~=1.0.0", "check-manifest==0.49", - "ruff==0.3.4", + "ruff==0.8.0", ] changelog = [ "towncrier==23.6.0", @@ -149,7 +149,7 @@ select = [ # "RET", # flake8-return # "SIM", # flake8-simplify "TID251", # flake8-tidy-imports, replaces S404 - "TCH", # flake8-type-checking + "TC", # flake8-type-checking "RUF", # ruff specific exceptions "PT", # flake8-pytest-style "Q", # flake8-quotes @@ -178,6 +178,10 @@ ignore = [ "RUF005", # might not be actually faster "RUF006", # might not be an issue/very extreme cases + # we keep __all__ and __slots__ (roughly) sorted by source, not alphabetically + "RUF022", + "RUF023", + # calling subprocess with dynamic arguments is generally fine, the only way to avoid this is ignoring it "S603", @@ -202,9 +206,9 @@ ignore = [ # ignore imports that could be moved into type-checking blocks # (no real advantage other than possibly avoiding cycles, # but can be dangerous in places where we need to parse signatures) - "TCH001", - "TCH002", - "TCH003", + "TC001", + "TC002", + "TC003", "S311", # insecure RNG usage, we don't use these for security-related things "PLE0237", # pyright seems to catch this already @@ -259,6 +263,13 @@ mark-parentheses = false [tool.ruff.lint.flake8-tidy-imports.banned-api] "subprocess".msg = "Consider possible security implications associated with the subprocess module." # replaces S404 +[tool.ruff.lint.flake8-bugbear] +extend-immutable-calls = [ + # this is immutable, except for storing locks, which are fine to share across contexts + "disnake.webhook.async_.AsyncWebhookAdapter", +] + + [tool.towncrier] template = "changelog/_template.rst.jinja" package = "disnake" diff --git a/tests/ext/commands/test_base_core.py b/tests/ext/commands/test_base_core.py index 734353c5a4..bdedeb0fe0 100644 --- a/tests/ext/commands/test_base_core.py +++ b/tests/ext/commands/test_base_core.py @@ -7,19 +7,33 @@ from disnake.ext import commands +class DecoratorMeta: + def __init__(self, type: str) -> None: + self.decorator = { + "slash": commands.slash_command, + "user": commands.user_command, + "message": commands.message_command, + }[type] + self.attr_key = f"{type}_command_attrs" + + class TestDefaultPermissions: - def test_decorator(self) -> None: + @pytest.fixture(params=["slash", "user", "message"]) + def meta(self, request): + return DecoratorMeta(request.param) + + def test_decorator(self, meta: DecoratorMeta) -> None: class Cog(commands.Cog): - @commands.slash_command(default_member_permissions=64) + @meta.decorator(default_member_permissions=64) async def cmd(self, _) -> None: ... @commands.default_member_permissions(64) - @commands.slash_command() + @meta.decorator() async def above(self, _) -> None: ... - @commands.slash_command() + @meta.decorator() @commands.default_member_permissions(64) async def below(self, _) -> None: ... @@ -29,22 +43,22 @@ async def below(self, _) -> None: assert c.above.default_member_permissions == Permissions(64) assert c.below.default_member_permissions == Permissions(64) - def test_decorator_overwrite(self) -> None: + def test_decorator_overwrite(self, meta: DecoratorMeta) -> None: # putting the decorator above should fail with pytest.raises(ValueError, match="Cannot set `default_member_permissions`"): class Cog(commands.Cog): @commands.default_member_permissions(32) - @commands.slash_command(default_member_permissions=64) + @meta.decorator(default_member_permissions=64) async def above(self, _) -> None: ... - # putting the decorator below shouldn't fail - # (this is a side effect of how command copying works, + # putting the decorator below shouldn't fail, for now + # FIXME: (this is a side effect of how command copying works, # and while this *should* probably fail, we're just testing # for regressions for now) class Cog2(commands.Cog): - @commands.slash_command(default_member_permissions=64) + @meta.decorator(default_member_permissions=64) @commands.default_member_permissions(32) async def below(self, _) -> None: ... @@ -52,22 +66,24 @@ async def below(self, _) -> None: for c in (Cog2, Cog2()): assert c.below.default_member_permissions == Permissions(32) - def test_attrs(self) -> None: - class Cog(commands.Cog, slash_command_attrs={"default_member_permissions": 32}): - @commands.slash_command() + def test_attrs(self, meta: DecoratorMeta) -> None: + kwargs = {meta.attr_key: {"default_member_permissions": 32}} + + class Cog(commands.Cog, **kwargs): + @meta.decorator() async def no_overwrite(self, _) -> None: ... - @commands.slash_command(default_member_permissions=64) + @meta.decorator(default_member_permissions=64) async def overwrite(self, _) -> None: ... @commands.default_member_permissions(64) - @commands.slash_command() + @meta.decorator() async def overwrite_decorator_above(self, _) -> None: ... - @commands.slash_command() + @meta.decorator() @commands.default_member_permissions(64) async def overwrite_decorator_below(self, _) -> None: ... @@ -75,6 +91,7 @@ async def overwrite_decorator_below(self, _) -> None: assert Cog.no_overwrite.default_member_permissions is None assert Cog().no_overwrite.default_member_permissions == Permissions(32) + # all of these should overwrite the cog-level attr assert Cog.overwrite.default_member_permissions == Permissions(64) assert Cog().overwrite.default_member_permissions == Permissions(64) diff --git a/tests/ext/commands/test_params.py b/tests/ext/commands/test_params.py index 96f2c08c32..fdfaa59002 100644 --- a/tests/ext/commands/test_params.py +++ b/tests/ext/commands/test_params.py @@ -140,10 +140,10 @@ def test_nan(self, value) -> None: def test_valid(self) -> None: x: Any = commands.Range[int, -1, 2] - assert x.underlying_type == int + assert x.underlying_type is int x: Any = commands.Range[float, ..., 23.45] - assert x.underlying_type == float + assert x.underlying_type is float class TestString: @@ -211,7 +211,7 @@ def test_optional(self, annotation_str) -> None: assert info.min_value == 1 assert info.max_value == 2 - assert info.type == int + assert info.type is int class TestIsolateSelf: diff --git a/tests/test_flags.py b/tests/test_flags.py index cb9d64b0ea..575851c26e 100644 --- a/tests/test_flags.py +++ b/tests/test_flags.py @@ -184,6 +184,21 @@ def test__eq__(self) -> None: assert not ins == other assert ins != other + def test__eq__flag_value(self) -> None: + ins = TestFlags(one=True) + other = TestFlags(one=True, two=True) + + assert ins == TestFlags.one + assert TestFlags.one == ins + + assert not ins != TestFlags.one + assert ins != TestFlags.two + + assert other != TestFlags.one + assert other != TestFlags.two + + assert other == TestFlags.three + def test__and__(self) -> None: ins = TestFlags(one=True, two=True) other = TestFlags(one=True, two=True) diff --git a/tests/ui/test_select.py b/tests/ui/test_select.py new file mode 100644 index 0000000000..5c33cd5575 --- /dev/null +++ b/tests/ui/test_select.py @@ -0,0 +1,47 @@ +# SPDX-License-Identifier: MIT + +from unittest import mock + +import pytest + +import disnake +from disnake import ui + + +class TestDefaultValues: + @pytest.mark.parametrize( + "value", + [ + disnake.Object(123), + disnake.SelectDefaultValue(123, disnake.SelectDefaultValueType.channel), + mock.Mock(disnake.TextChannel, id=123), + ], + ) + def test_valid(self, value) -> None: + s = ui.ChannelSelect(default_values=[value]) + assert s.default_values[0].id == 123 + assert s.default_values[0].type == disnake.SelectDefaultValueType.channel + + @pytest.mark.parametrize( + ("select_type", "value_type"), + [ + (ui.ChannelSelect, disnake.Member), + # MentionableSelect in particular should reject `Object` due to ambiguities + (ui.MentionableSelect, disnake.Object), + ], + ) + def test_invalid(self, select_type, value_type) -> None: + with pytest.raises(TypeError, match="Expected type of default value"): + select_type(default_values=[mock.Mock(value_type, id=123)]) + + @pytest.mark.parametrize( + ("value_type", "expected"), + [ + (disnake.Member, disnake.SelectDefaultValueType.user), + (disnake.ClientUser, disnake.SelectDefaultValueType.user), + (disnake.Role, disnake.SelectDefaultValueType.role), + ], + ) + def test_mentionable(self, value_type, expected) -> None: + s = ui.MentionableSelect(default_values=[mock.Mock(value_type, id=123)]) + assert s.default_values[0].type == expected