From 4c8e943695cfa9076f4c75d6be2d45177d3caee1 Mon Sep 17 00:00:00 2001 From: shiftinv <8530778+shiftinv@users.noreply.github.com> Date: Wed, 13 Nov 2024 14:44:39 +0100 Subject: [PATCH 1/8] feat(voice): add `aead_xchacha20_poly1305_rtpsize` encryption mode, remove old modes (#1228) --- changelog/1228.feature.rst | 1 + changelog/1228.misc.rst | 1 + disnake/gateway.py | 2 +- disnake/types/voice.py | 5 ++++- disnake/voice_client.py | 43 +++++++++++++++++--------------------- pyproject.toml | 2 +- 6 files changed, 27 insertions(+), 27 deletions(-) create mode 100644 changelog/1228.feature.rst create mode 100644 changelog/1228.misc.rst diff --git a/changelog/1228.feature.rst b/changelog/1228.feature.rst new file mode 100644 index 0000000000..5457283ab9 --- /dev/null +++ b/changelog/1228.feature.rst @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000000..505effd2b8 --- /dev/null +++ b/changelog/1228.misc.rst @@ -0,0 +1 @@ +Raise PyNaCl version requirement to ``v1.5.0``. diff --git a/disnake/gateway.py b/disnake/gateway.py index 97508574c3..b42414b924 100644 --- a/disnake/gateway.py +++ b/disnake/gateway.py @@ -1038,7 +1038,7 @@ async def initial_connection(self, data: VoiceReadyPayload) -> None: state.port = struct.unpack_from(">H", recv, len(recv) - 2)[0] _log.debug("detected ip: %s port: %s", state.ip, state.port) - # there *should* always be at least one supported mode (xsalsa20_poly1305) + # there *should* always be at least one supported mode modes: List[SupportedModes] = [ mode for mode in data["modes"] if mode in self._connection.supported_modes ] diff --git a/disnake/types/voice.py b/disnake/types/voice.py index 8a7d4870a0..8942db224e 100644 --- a/disnake/types/voice.py +++ b/disnake/types/voice.py @@ -7,7 +7,10 @@ from .member import MemberWithUser from .snowflake import Snowflake -SupportedModes = Literal["xsalsa20_poly1305_lite", "xsalsa20_poly1305_suffix", "xsalsa20_poly1305"] +SupportedModes = Literal[ + # "aead_aes256_gcm_rtpsize", # supported in libsodium, but not exposed by pynacl + "aead_xchacha20_poly1305_rtpsize", +] class _VoiceState(TypedDict): diff --git a/disnake/voice_client.py b/disnake/voice_client.py index a6cc13e0ba..e9469af670 100644 --- a/disnake/voice_client.py +++ b/disnake/voice_client.py @@ -228,11 +228,7 @@ def __init__(self, client: Client, channel: abc.Connectable) -> None: self.ws: DiscordVoiceWebSocket = MISSING warn_nacl = not has_nacl - supported_modes: Tuple[SupportedModes, ...] = ( - "xsalsa20_poly1305_lite", - "xsalsa20_poly1305_suffix", - "xsalsa20_poly1305", - ) + supported_modes: Tuple[SupportedModes, ...] = ("aead_xchacha20_poly1305_rtpsize",) @property def guild(self) -> Guild: @@ -512,8 +508,8 @@ def _get_voice_packet(self, data): header = bytearray(12) # Formulate rtp header - header[0] = 0x80 - header[1] = 0x78 + header[0] = 0x80 # version = 2 + header[1] = 0x78 # payload type = 120 (opus) struct.pack_into(">H", header, 2, self.sequence) struct.pack_into(">I", header, 4, self.timestamp) struct.pack_into(">I", header, 8, self.ssrc) @@ -521,27 +517,26 @@ def _get_voice_packet(self, data): encrypt_packet = getattr(self, f"_encrypt_{self.mode}") return encrypt_packet(header, data) - def _encrypt_xsalsa20_poly1305(self, header: bytes, data) -> bytes: - box = nacl.secret.SecretBox(bytes(self.secret_key)) - nonce = bytearray(24) - nonce[:12] = header + def _get_nonce(self, pad: int): + # returns (nonce, padded_nonce). + # n.b. all currently implemented modes use the same nonce size (192 bits / 24 bytes) + nonce = struct.pack(">I", self._lite_nonce) - return header + box.encrypt(bytes(data), bytes(nonce)).ciphertext + self._lite_nonce += 1 + if self._lite_nonce > 4294967295: + self._lite_nonce = 0 - def _encrypt_xsalsa20_poly1305_suffix(self, header: bytes, data) -> bytes: - box = nacl.secret.SecretBox(bytes(self.secret_key)) - nonce = nacl.utils.random(nacl.secret.SecretBox.NONCE_SIZE) + return (nonce, nonce.ljust(pad, b"\0")) - return header + box.encrypt(bytes(data), nonce).ciphertext + nonce + def _encrypt_aead_xchacha20_poly1305_rtpsize(self, header: bytes, data) -> bytes: + box = nacl.secret.Aead(bytes(self.secret_key)) + nonce, padded_nonce = self._get_nonce(nacl.secret.Aead.NONCE_SIZE) - def _encrypt_xsalsa20_poly1305_lite(self, header: bytes, data) -> bytes: - box = nacl.secret.SecretBox(bytes(self.secret_key)) - nonce = bytearray(24) - - nonce[:4] = struct.pack(">I", self._lite_nonce) - self.checked_add("_lite_nonce", 1, 4294967295) - - return header + box.encrypt(bytes(data), bytes(nonce)).ciphertext + nonce[:4] + return ( + header + + box.encrypt(bytes(data), aad=bytes(header), nonce=padded_nonce).ciphertext + + nonce + ) def play( self, source: AudioSource, *, after: Optional[Callable[[Optional[Exception]], Any]] = None diff --git a/pyproject.toml b/pyproject.toml index 5979d16533..9bab9ae822 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,7 @@ speed = [ 'cchardet; python_version < "3.10"', ] voice = [ - "PyNaCl>=1.3.0,<1.6", + "PyNaCl>=1.5.0,<1.6", ] docs = [ "sphinx==7.0.1", From 0961c9d2fa218282de1df1427e7e796263da07cf Mon Sep 17 00:00:00 2001 From: shiftinv <8530778+shiftinv@users.noreply.github.com> Date: Wed, 13 Nov 2024 18:44:47 +0100 Subject: [PATCH 2/8] feat: implement `voice_channel_effect` event (#993) --- changelog/993.feature.rst | 4 ++++ disnake/channel.py | 48 +++++++++++++++++++++++++++++++++++++++ disnake/enums.py | 19 ++++++++++++++++ disnake/flags.py | 2 ++ disnake/raw_models.py | 39 +++++++++++++++++++++++++++++++ disnake/state.py | 22 +++++++++++++++++- disnake/types/gateway.py | 9 +++++++- disnake/types/voice.py | 9 ++++++++ docs/api/events.rst | 30 ++++++++++++++++++++++++ docs/api/voice.rst | 35 ++++++++++++++++++++++++++++ 10 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 changelog/993.feature.rst diff --git a/changelog/993.feature.rst b/changelog/993.feature.rst new file mode 100644 index 0000000000..38905a2a9e --- /dev/null +++ b/changelog/993.feature.rst @@ -0,0 +1,4 @@ +Support voice channel effect events. +- New events: :func:`on_voice_channel_effect`, :func:`on_raw_voice_channel_effect`. +- New types: :class:`VoiceChannelEffect`, :class:`RawVoiceChannelEffectEvent`. +- New enum: :class:`VoiceChannelEffectAnimationType`. diff --git a/disnake/channel.py b/disnake/channel.py index 980a5ea1ce..a1a37a057f 100644 --- a/disnake/channel.py +++ b/disnake/channel.py @@ -35,6 +35,7 @@ ThreadLayout, ThreadSortOrder, VideoQualityMode, + VoiceChannelEffectAnimationType, try_enum, try_enum_to_int, ) @@ -50,6 +51,7 @@ from .utils import MISSING __all__ = ( + "VoiceChannelEffect", "TextChannel", "VoiceChannel", "StageChannel", @@ -90,6 +92,7 @@ ) from .types.snowflake import SnowflakeList from .types.threads import ThreadArchiveDurationLiteral + from .types.voice import VoiceChannelEffect as VoiceChannelEffectPayload from .ui.action_row import Components, MessageUIComponent from .ui.view import View from .user import BaseUser, ClientUser, User @@ -97,6 +100,51 @@ from .webhook import Webhook +class VoiceChannelEffect: + """An effect sent by a member in a voice channel. + + Different sets of attributes will be present, depending on the type of effect. + + .. versionadded:: 2.10 + + Attributes + ---------- + emoji: Optional[Union[:class:`Emoji`, :class:`PartialEmoji`]] + The emoji, for emoji reaction effects. + animation_type: Optional[:class:`VoiceChannelEffectAnimationType`] + The emoji animation type, for emoji reaction effects. + animation_id: Optional[:class:`int`] + The emoji animation ID, for emoji reaction effects. + """ + + __slots__ = ( + "emoji", + "animation_type", + "animation_id", + ) + + def __init__(self, *, data: VoiceChannelEffectPayload, state: ConnectionState) -> None: + self.emoji: Optional[Union[Emoji, PartialEmoji]] = None + if emoji_data := data.get("emoji"): + emoji = state._get_emoji_from_data(emoji_data) + if isinstance(emoji, str): + emoji = PartialEmoji(name=emoji) + self.emoji = emoji + + self.animation_type = ( + try_enum(VoiceChannelEffectAnimationType, value) + if (value := data.get("animation_type")) is not None + else None + ) + self.animation_id: Optional[int] = utils._get_as_snowflake(data, "animation_id") + + def __repr__(self) -> str: + return ( + f"" + ) + + async def _single_delete_strategy(messages: Iterable[Message]) -> None: for m in messages: await m.delete() diff --git a/disnake/enums.py b/disnake/enums.py index 6f81211156..5c917911f0 100644 --- a/disnake/enums.py +++ b/disnake/enums.py @@ -72,6 +72,7 @@ "SKUType", "EntitlementType", "PollLayoutType", + "VoiceChannelEffectAnimationType", ) @@ -1160,6 +1161,19 @@ class Event(Enum): """Called when a `Member` changes their `VoiceState`. Represents the :func:`on_voice_state_update` event. """ + voice_channel_effect = "voice_channel_effect" + """Called when a `Member` sends an effect in a voice channel the bot is connected to. + Represents the :func:`on_voice_channel_effect` event. + + .. versionadded:: 2.10 + """ + raw_voice_channel_effect = "raw_voice_channel_effect" + """Called when a `Member` sends an effect in a voice channel the bot is connected to, + regardless of the member cache. + Represents the :func:`on_raw_voice_channel_effect` event. + + .. versionadded:: 2.10 + """ stage_instance_create = "stage_instance_create" """Called when a `StageInstance` is created for a `StageChannel`. Represents the :func:`on_stage_instance_create` event. @@ -1385,6 +1399,11 @@ class PollLayoutType(Enum): default = 1 +class VoiceChannelEffectAnimationType(Enum): + premium = 0 + basic = 1 + + T = TypeVar("T") diff --git a/disnake/flags.py b/disnake/flags.py index 406095a6d2..da3cba6904 100644 --- a/disnake/flags.py +++ b/disnake/flags.py @@ -1267,6 +1267,8 @@ def voice_states(self): This corresponds to the following events: - :func:`on_voice_state_update` + - :func:`on_voice_channel_effect` + - :func:`on_raw_voice_channel_effect` This also corresponds to the following attributes and classes in terms of cache: diff --git a/disnake/raw_models.py b/disnake/raw_models.py index 48b8dab56d..5e1ab017f0 100644 --- a/disnake/raw_models.py +++ b/disnake/raw_models.py @@ -9,6 +9,7 @@ from .utils import _get_as_snowflake, get_slots if TYPE_CHECKING: + from .channel import VoiceChannelEffect from .member import Member from .message import Message from .partial_emoji import PartialEmoji @@ -29,6 +30,7 @@ PresenceUpdateEvent, ThreadDeleteEvent, TypingStartEvent, + VoiceChannelEffectSendEvent, ) from .user import User @@ -48,6 +50,7 @@ "RawGuildMemberRemoveEvent", "RawPresenceUpdateEvent", "RawPollVoteActionEvent", + "RawVoiceChannelEffectEvent", ) @@ -534,3 +537,39 @@ def __init__(self, data: PresenceUpdateEvent) -> None: self.user_id: int = int(data["user"]["id"]) self.guild_id: int = int(data["guild_id"]) self.data: PresenceUpdateEvent = data + + +class RawVoiceChannelEffectEvent(_RawReprMixin): + """Represents the event payload for an :func:`on_raw_voice_channel_effect` event. + + .. versionadded:: 2.10 + + Attributes + ---------- + channel_id: :class:`int` + The ID of the channel where the effect was sent. + guild_id: :class:`int` + The ID of the guild where the effect was sent. + user_id: :class:`int` + The ID of the user who sent the effect. + effect: :class:`VoiceChannelEffect` + The effect that was sent. + cached_member: Optional[:class:`Member`] + The member who sent the effect, if they could be found in the internal cache. + """ + + __slots__ = ( + "channel_id", + "guild_id", + "user_id", + "effect", + "cached_member", + ) + + def __init__(self, data: VoiceChannelEffectSendEvent, effect: VoiceChannelEffect) -> None: + self.channel_id: int = int(data["channel_id"]) + self.guild_id: int = int(data["guild_id"]) + self.user_id: int = int(data["user_id"]) + self.effect: VoiceChannelEffect = effect + + self.cached_member: Optional[Member] = None diff --git a/disnake/state.py b/disnake/state.py index a467ed6b0c..c3263976c6 100644 --- a/disnake/state.py +++ b/disnake/state.py @@ -42,6 +42,7 @@ StageChannel, TextChannel, VoiceChannel, + VoiceChannelEffect, _guild_channel_factory, _threaded_channel_factory, ) @@ -79,6 +80,7 @@ RawThreadDeleteEvent, RawThreadMemberRemoveEvent, RawTypingEvent, + RawVoiceChannelEffectEvent, ) from .role import Role from .stage_instance import StageInstance @@ -1809,7 +1811,6 @@ def parse_voice_state_update(self, data: gateway.VoiceStateUpdateEvent) -> None: if flags.voice: if channel_id is None and flags._voice_only and member.id != self_id: # Only remove from cache if we only have the voice flag enabled - # Member doesn't meet the Snowflake protocol currently guild._remove_member(member) elif channel_id is not None: guild._add_member(member) @@ -1831,6 +1832,25 @@ def parse_voice_server_update(self, data: gateway.VoiceServerUpdateEvent) -> Non logging_coroutine(coro, info="Voice Protocol voice server update handler") ) + def parse_voice_channel_effect_send(self, data: gateway.VoiceChannelEffectSendEvent) -> None: + guild = self._get_guild(int(data["guild_id"])) + if guild is None: + _log.debug( + "VOICE_CHANNEL_EFFECT_SEND referencing an unknown guild ID: %s. Discarding.", + data["guild_id"], + ) + return + + effect = VoiceChannelEffect(data=data, state=self) + raw = RawVoiceChannelEffectEvent(data, effect) + + channel = guild.get_channel(raw.channel_id) + raw.cached_member = member = guild.get_member(raw.user_id) + self.dispatch("raw_voice_channel_effect", raw) + + if channel and member: + self.dispatch("voice_channel_effect", channel, member, effect) + # FIXME: this should be refactored. The `GroupChannel` path will never be hit, # `raw.timestamp` exists so no need to parse it twice, and `.get_user` should be used before falling back def parse_typing_start(self, data: gateway.TypingStartEvent) -> None: diff --git a/disnake/types/gateway.py b/disnake/types/gateway.py index 2926786e5d..736634c944 100644 --- a/disnake/types/gateway.py +++ b/disnake/types/gateway.py @@ -25,7 +25,7 @@ from .sticker import GuildSticker from .threads import Thread, ThreadMember, ThreadMemberWithPresence, ThreadType from .user import AvatarDecorationData, User -from .voice import GuildVoiceState, SupportedModes +from .voice import GuildVoiceState, SupportedModes, VoiceChannelEffect class SessionStartLimit(TypedDict): @@ -612,6 +612,13 @@ class VoiceServerUpdateEvent(TypedDict): endpoint: Optional[str] +# https://discord.com/developers/docs/topics/gateway-events#voice-channel-effect-send +class VoiceChannelEffectSendEvent(VoiceChannelEffect): + channel_id: Snowflake + guild_id: Snowflake + user_id: Snowflake + + # https://discord.com/developers/docs/topics/gateway-events#typing-start class TypingStartEvent(TypedDict): channel_id: Snowflake diff --git a/disnake/types/voice.py b/disnake/types/voice.py index 8942db224e..4ad9bc36b9 100644 --- a/disnake/types/voice.py +++ b/disnake/types/voice.py @@ -4,6 +4,7 @@ from typing_extensions import NotRequired +from .emoji import PartialEmoji from .member import MemberWithUser from .snowflake import Snowflake @@ -12,6 +13,8 @@ "aead_xchacha20_poly1305_rtpsize", ] +VoiceChannelEffectAnimationType = Literal[0, 1] + class _VoiceState(TypedDict): user_id: Snowflake @@ -57,3 +60,9 @@ class VoiceReady(TypedDict): port: int modes: List[SupportedModes] heartbeat_interval: int + + +class VoiceChannelEffect(TypedDict, total=False): + emoji: Optional[PartialEmoji] + animation_type: Optional[VoiceChannelEffectAnimationType] + animation_id: int diff --git a/docs/api/events.rst b/docs/api/events.rst index fb0059f1fa..c5683e456c 100644 --- a/docs/api/events.rst +++ b/docs/api/events.rst @@ -1048,6 +1048,36 @@ Voice :param after: The voice state after the changes. :type after: :class:`VoiceState` +.. function:: on_voice_channel_effect(channel, member, effect) + + Called when a :class:`Member` sends an effect in a voice channel the bot is connected to. + + This requires :attr:`Intents.voice_states` and :attr:`Intents.members` to be enabled. + + If the member is not found in the internal member cache, then this + event will not be called. Consider using :func:`on_raw_voice_channel_effect` instead. + + .. versionadded:: 2.10 + + :param channel: The voice channel where the effect was sent. + :type channel: :class:`VoiceChannel` + :param member: The member that sent the effect. + :type member: :class:`Member` + :param effect: The effect that was sent. + :type effect: :class:`VoiceChannelEffect` + +.. function:: on_raw_voice_channel_effect(payload) + + Called when a :class:`Member` sends an effect in a voice channel the bot is connected to. + Unlike :func:`on_voice_channel_effect`, this is called regardless of the member cache. + + This requires :attr:`Intents.voice_states` to be enabled. + + .. versionadded:: 2.10 + + :param payload: The raw event payload data. + :type payload: :class:`RawVoiceChannelEffectEvent` + Interactions ~~~~~~~~~~~~ diff --git a/docs/api/voice.rst b/docs/api/voice.rst index 457473a926..e56d1a2eb9 100644 --- a/docs/api/voice.rst +++ b/docs/api/voice.rst @@ -101,10 +101,43 @@ VoiceRegion .. autoclass:: VoiceRegion() :members: +VoiceChannelEffect +~~~~~~~~~~~~~~~~~~ + +.. attributetable:: VoiceChannelEffect + +.. autoclass:: VoiceChannelEffect() + :members: + +RawVoiceChannelEffectEvent +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. attributetable:: RawVoiceChannelEffectEvent + +.. autoclass:: RawVoiceChannelEffectEvent() + :members: + Enumerations ------------ +VoiceChannelEffectAnimationType +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. class:: VoiceChannelEffectAnimationType + + The type of an emoji reaction effect animation in a voice channel. + + .. versionadded:: 2.10 + + .. attribute:: premium + + A fun animation, sent by a Nitro subscriber. + + .. attribute:: basic + + A standard animation. + PartyType ~~~~~~~~~ @@ -168,3 +201,5 @@ Events ------ - :func:`on_voice_state_update(member, before, after) ` +- :func:`on_voice_channel_effect(channel, member, effect) ` +- :func:`on_raw_voice_channel_effect(payload) ` From ef7d82f087e201e297625f2b135b4b7d61089f91 Mon Sep 17 00:00:00 2001 From: shiftinv <8530778+shiftinv@users.noreply.github.com> Date: Thu, 14 Nov 2024 15:03:21 +0100 Subject: [PATCH 3/8] feat: select menu default values (#1115) --- changelog/1115.feature.rst | 1 + disnake/abc.py | 8 ++- disnake/channel.py | 1 + disnake/components.py | 83 +++++++++++++++++++++++++++++++- disnake/enums.py | 10 ++++ disnake/types/components.py | 9 ++++ disnake/types/interactions.py | 6 ++- disnake/types/message.py | 4 +- disnake/ui/action_row.py | 47 ++++++++++++++++-- disnake/ui/button.py | 6 +-- disnake/ui/item.py | 2 +- disnake/ui/select/base.py | 76 +++++++++++++++++++++++++++-- disnake/ui/select/channel.py | 64 ++++++++++++++++++++---- disnake/ui/select/mentionable.py | 67 ++++++++++++++++++++++---- disnake/ui/select/role.py | 50 ++++++++++++++++--- disnake/ui/select/string.py | 17 +++++-- disnake/ui/select/user.py | 53 +++++++++++++++++--- docs/api/components.rst | 48 +++++++++++++----- docs/api/ui.rst | 9 ++-- tests/ui/test_select.py | 47 ++++++++++++++++++ 20 files changed, 532 insertions(+), 76 deletions(-) create mode 100644 changelog/1115.feature.rst create mode 100644 tests/ui/test_select.py 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/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/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/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/enums.py b/disnake/enums.py index 5c917911f0..8c587ca902 100644 --- a/disnake/enums.py +++ b/disnake/enums.py @@ -47,6 +47,7 @@ "ComponentType", "ButtonStyle", "TextInputStyle", + "SelectDefaultValueType", "StagePrivacyLevel", "InteractionType", "InteractionResponseType", @@ -694,6 +695,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/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/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..8d8431864e 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 @@ -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/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/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/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 From 1cd840a1c3f11173d2e7bbb4c0e9a87625e1b523 Mon Sep 17 00:00:00 2001 From: shiftinv <8530778+shiftinv@users.noreply.github.com> Date: Thu, 14 Nov 2024 18:23:37 +0100 Subject: [PATCH 4/8] fix: attempt resume on websocket closure with `close_code = 1000` in edge cases (#1241) --- changelog/1241.bugfix.rst | 1 + disnake/gateway.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 changelog/1241.bugfix.rst diff --git a/changelog/1241.bugfix.rst b/changelog/1241.bugfix.rst new file mode 100644 index 0000000000..0765a61e23 --- /dev/null +++ b/changelog/1241.bugfix.rst @@ -0,0 +1 @@ +Attempt to handle abrupt websocket closures on ``aiohttp >= 3.9.0`` and ``python < 3.11.0`` gracefully. 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) From fbfc814ff77e2c6d9178817b3323a7dfbb75a36f Mon Sep 17 00:00:00 2001 From: shiftinv Date: Thu, 14 Nov 2024 20:38:24 +0100 Subject: [PATCH 5/8] docs: add v2.9.3 changelog to latest docs --- changelog/1180.doc.rst | 1 - changelog/1228.feature.rst | 1 - changelog/1228.misc.rst | 1 - changelog/1241.bugfix.rst | 1 - docs/whats_new.rst | 27 +++++++++++++++++++++++++++ 5 files changed, 27 insertions(+), 4 deletions(-) delete mode 100644 changelog/1180.doc.rst delete mode 100644 changelog/1228.feature.rst delete mode 100644 changelog/1228.misc.rst delete mode 100644 changelog/1241.bugfix.rst 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/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/1241.bugfix.rst b/changelog/1241.bugfix.rst deleted file mode 100644 index 0765a61e23..0000000000 --- a/changelog/1241.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Attempt to handle abrupt websocket closures on ``aiohttp >= 3.9.0`` and ``python < 3.11.0`` gracefully. 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 From 179de79ee4fd63facd4adc9c2a9b4171c30adb67 Mon Sep 17 00:00:00 2001 From: Nerd Bear Date: Sat, 16 Nov 2024 13:40:32 +0000 Subject: [PATCH 6/8] docs: Add parameter info to disnake.ext.commands.Bot.run() docstring (#1240) Signed-off-by: Nerd Bear --- disnake/client.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/disnake/client.py b/disnake/client.py index 80b3d67c65..2c4e2c7da4 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 From aedc1d1001bea0bb5153e83bf1f88a1d38597c9a Mon Sep 17 00:00:00 2001 From: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> Date: Sat, 16 Nov 2024 17:23:30 +0100 Subject: [PATCH 7/8] feat(embed): add the ability to pass disnake.File to more Embed methods (#1229) Signed-off-by: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> Co-authored-by: shiftinv <8530778+shiftinv@users.noreply.github.com> Co-authored-by: Eneg <42005170+Enegg@users.noreply.github.com> --- changelog/1184.feature.rst | 1 + disnake/embeds.py | 87 +++++++++++++++++++++++++++++++++----- 2 files changed, 77 insertions(+), 11 deletions(-) create mode 100644 changelog/1184.feature.rst 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/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. From ac5a936d187ff89d6756e5f33bf0b2965e7b7685 Mon Sep 17 00:00:00 2001 From: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> Date: Sat, 16 Nov 2024 17:46:11 +0100 Subject: [PATCH 8/8] feat(member): implement member banners (#1204) Signed-off-by: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> --- changelog/1203.feature.rst | 1 + disnake/asset.py | 13 +++++++++++++ disnake/member.py | 19 +++++++++++++++++++ disnake/types/gateway.py | 1 + 4 files changed, 34 insertions(+) create mode 100644 changelog/1203.feature.rst 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/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/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/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]