From ae2410fa3ace0628cd600918539406c6ef9df486 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 18 Feb 2025 18:52:40 +0100 Subject: [PATCH 001/272] feat: Components V2 --- discord/attachment.py | 417 ++++++++++++++++++++++++++++++++++++ discord/components.py | 176 +++++++++++++-- discord/enums.py | 21 ++ discord/flags.py | 8 + discord/message.py | 298 +------------------------- discord/types/attachment.py | 58 +++++ discord/types/components.py | 69 +++++- discord/types/message.py | 26 +-- discord/ui/section.py | 50 +++++ 9 files changed, 781 insertions(+), 342 deletions(-) create mode 100644 discord/attachment.py create mode 100644 discord/types/attachment.py create mode 100644 discord/ui/section.py diff --git a/discord/attachment.py b/discord/attachment.py new file mode 100644 index 000000000000..2be4eac1aa73 --- /dev/null +++ b/discord/attachment.py @@ -0,0 +1,417 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the 'Software'), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" +from __future__ import annotations + +import io +from os import PathLike +from typing import TYPE_CHECKING, Any, Optional, Union + +from .mixins import Hashable +from .file import File +from .state import ConnectionState +from .flags import AttachmentFlags +from . import utils + +if TYPE_CHECKING: + from .types.attachment import Attachment as AttachmentPayload + +MISSING = utils.MISSING + +__all__ = ( + 'Attachment', + 'UnfurledAttachment', +) + + +class AttachmentBase: + url: str + + async def save( + self, + fp: Union[io.BufferedIOBase, PathLike[Any]], + *, + seek_begin: bool = True, + use_cached: bool = False, + ) -> int: + """|coro| + + Saves this attachment into a file-like object. + + Parameters + ---------- + fp: Union[:class:`io.BufferedIOBase`, :class:`os.PathLike`] + The file-like object to save this attachment to or the filename + to use. If a filename is passed then a file is created with that + filename and used instead. + seek_begin: :class:`bool` + Whether to seek to the beginning of the file after saving is + successfully done. + use_cached: :class:`bool` + Whether to use :attr:`proxy_url` rather than :attr:`url` when downloading + the attachment. This will allow attachments to be saved after deletion + more often, compared to the regular URL which is generally deleted right + after the message is deleted. Note that this can still fail to download + deleted attachments if too much time has passed and it does not work + on some types of attachments. + + Raises + -------- + HTTPException + Saving the attachment failed. + NotFound + The attachment was deleted. + + Returns + -------- + :class:`int` + The number of bytes written. + """ + data = await self.read(use_cached=use_cached) + if isinstance(fp, io.BufferedIOBase): + written = fp.write(data) + if seek_begin: + fp.seek(0) + return written + else: + with open(fp, 'wb') as f: + return f.write(data) + + async def read(self, *, use_cached: bool = False) -> bytes: + """|coro| + + Retrieves the content of this attachment as a :class:`bytes` object. + + .. versionadded:: 1.1 + + Parameters + ----------- + use_cached: :class:`bool` + Whether to use :attr:`proxy_url` rather than :attr:`url` when downloading + the attachment. This will allow attachments to be saved after deletion + more often, compared to the regular URL which is generally deleted right + after the message is deleted. Note that this can still fail to download + deleted attachments if too much time has passed and it does not work + on some types of attachments. + + Raises + ------ + HTTPException + Downloading the attachment failed. + Forbidden + You do not have permissions to access this attachment + NotFound + The attachment was deleted. + + Returns + ------- + :class:`bytes` + The contents of the attachment. + """ + url = self.proxy_url if use_cached else self.url + data = await self._http.get_from_cdn(url) + return data + + async def to_file( + self, + *, + filename: Optional[str] = MISSING, + description: Optional[str] = MISSING, + use_cached: bool = False, + spoiler: bool = False, + ) -> File: + """|coro| + + Converts the attachment into a :class:`File` suitable for sending via + :meth:`abc.Messageable.send`. + + .. versionadded:: 1.3 + + Parameters + ----------- + filename: Optional[:class:`str`] + The filename to use for the file. If not specified then the filename + of the attachment is used instead. + + .. versionadded:: 2.0 + description: Optional[:class:`str`] + The description to use for the file. If not specified then the + description of the attachment is used instead. + + .. versionadded:: 2.0 + use_cached: :class:`bool` + Whether to use :attr:`proxy_url` rather than :attr:`url` when downloading + the attachment. This will allow attachments to be saved after deletion + more often, compared to the regular URL which is generally deleted right + after the message is deleted. Note that this can still fail to download + deleted attachments if too much time has passed and it does not work + on some types of attachments. + + .. versionadded:: 1.4 + spoiler: :class:`bool` + Whether the file is a spoiler. + + .. versionadded:: 1.4 + + Raises + ------ + HTTPException + Downloading the attachment failed. + Forbidden + You do not have permissions to access this attachment + NotFound + The attachment was deleted. + + Returns + ------- + :class:`File` + The attachment as a file suitable for sending. + """ + + data = await self.read(use_cached=use_cached) + file_filename = filename if filename is not MISSING else self.filename + file_description = ( + description if description is not MISSING else self.description + ) + return File( + io.BytesIO(data), + filename=file_filename, + description=file_description, + spoiler=spoiler, + ) + + +class Attachment(Hashable, AttachmentBase): + """Represents an attachment from Discord. + + .. container:: operations + + .. describe:: str(x) + + Returns the URL of the attachment. + + .. describe:: x == y + + Checks if the attachment is equal to another attachment. + + .. describe:: x != y + + Checks if the attachment is not equal to another attachment. + + .. describe:: hash(x) + + Returns the hash of the attachment. + + .. versionchanged:: 1.7 + Attachment can now be casted to :class:`str` and is hashable. + + Attributes + ------------ + id: :class:`int` + The attachment ID. + size: :class:`int` + The attachment size in bytes. + height: Optional[:class:`int`] + The attachment's height, in pixels. Only applicable to images and videos. + width: Optional[:class:`int`] + The attachment's width, in pixels. Only applicable to images and videos. + filename: :class:`str` + The attachment's filename. + url: :class:`str` + The attachment URL. If the message this attachment was attached + to is deleted, then this will 404. + proxy_url: :class:`str` + The proxy URL. This is a cached version of the :attr:`~Attachment.url` in the + case of images. When the message is deleted, this URL might be valid for a few + minutes or not valid at all. + content_type: Optional[:class:`str`] + The attachment's `media type `_ + + .. versionadded:: 1.7 + description: Optional[:class:`str`] + The attachment's description. Only applicable to images. + + .. versionadded:: 2.0 + ephemeral: :class:`bool` + Whether the attachment is ephemeral. + + .. versionadded:: 2.0 + duration: Optional[:class:`float`] + The duration of the audio file in seconds. Returns ``None`` if it's not a voice message. + + .. versionadded:: 2.3 + waveform: Optional[:class:`bytes`] + The waveform (amplitudes) of the audio in bytes. Returns ``None`` if it's not a voice message. + + .. versionadded:: 2.3 + title: Optional[:class:`str`] + The normalised version of the attachment's filename. + + .. versionadded:: 2.5 + """ + + __slots__ = ( + 'id', + 'size', + 'height', + 'width', + 'filename', + 'url', + 'proxy_url', + '_http', + 'content_type', + 'description', + 'ephemeral', + 'duration', + 'waveform', + '_flags', + 'title', + ) + + def __init__(self, *, data: AttachmentPayload, state: ConnectionState): + self.id: int = int(data['id']) + self.size: int = data['size'] + self.height: Optional[int] = data.get('height') + self.width: Optional[int] = data.get('width') + self.filename: str = data['filename'] + self.url: str = data['url'] + self.proxy_url: str = data['proxy_url'] + self._http = state.http + self.content_type: Optional[str] = data.get('content_type') + self.description: Optional[str] = data.get('description') + self.ephemeral: bool = data.get('ephemeral', False) + self.duration: Optional[float] = data.get('duration_secs') + self.title: Optional[str] = data.get('title') + + waveform = data.get('waveform') + self.waveform: Optional[bytes] = ( + utils._base64_to_bytes(waveform) if waveform is not None else None + ) + + self._flags: int = data.get('flags', 0) + + @property + def flags(self) -> AttachmentFlags: + """:class:`AttachmentFlags`: The attachment's flags.""" + return AttachmentFlags._from_value(self._flags) + + def is_spoiler(self) -> bool: + """:class:`bool`: Whether this attachment contains a spoiler.""" + return self.filename.startswith('SPOILER_') + + def is_voice_message(self) -> bool: + """:class:`bool`: Whether this attachment is a voice message.""" + return self.duration is not None and 'voice-message' in self.url + + def __repr__(self) -> str: + return f'' + + def __str__(self) -> str: + return self.url or '' + + def to_dict(self) -> AttachmentPayload: + result: AttachmentPayload = { + 'filename': self.filename, + 'id': self.id, + 'proxy_url': self.proxy_url, + 'size': self.size, + 'url': self.url, + 'spoiler': self.is_spoiler(), + } + if self.height: + result['height'] = self.height + if self.width: + result['width'] = self.width + if self.content_type: + result['content_type'] = self.content_type + if self.description is not None: + result['description'] = self.description + return result + + +class UnfurledAttachment(AttachmentBase): + """Represents an unfurled attachment item from a :class:`Component`. + + .. versionadded:: tbd + + .. container:: operations + + .. describe:: str(x) + + Returns the URL of the attachment. + + .. describe:: x == y + + Checks if the unfurled attachment is equal to another unfurled attachment. + + .. describe:: x != y + + Checks if the unfurled attachment is not equal to another unfurled attachment. + + Attributes + ---------- + url: :class:`str` + The unfurled attachment URL. + proxy_url: Optional[:class:`str`] + The proxy URL. This is cached version of the :attr:`~UnfurledAttachment.url` in the + case of images. When the message is deleted, this URL might be valid for a few + minutes or not valid at all. + + .. note:: + + This will be ``None`` if :meth:`.is_resolved` is ``False``. + height: Optional[:class:`int`] + The unfurled attachment's height, in pixels. + + .. note:: + + This will be ``None`` if :meth:`.is_resolved` is ``False``. + width: Optional[:class:`int`] + The unfurled attachment's width, in pixels. + + .. note:: + + This will be ``None`` if :meth:`.is_resolved` is ``False``. + content_type: Optional[:class:`str`] + The attachment's `media type `_ + + .. note:: + + This will be ``None`` if :meth:`.is_resolved` is ``False``. + loading_state: :class:`MediaLoadingState` + The load state of this attachment on Discord side. + description + """ + + __slots__ = ( + 'url', + 'proxy_url', + 'height', + 'width', + 'content_type', + 'loading_state', + '_resolved', + '_state', + ) + + def __init__(self, ) diff --git a/discord/components.py b/discord/components.py index 2af2d6d20d8b..141c03cc21d8 100644 --- a/discord/components.py +++ b/discord/components.py @@ -4,7 +4,7 @@ Copyright (c) 2015-present Rapptz Permission is hereby granted, free of charge, to any person obtaining a -copy of this software and associated documentation files (the "Software"), +copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the @@ -13,7 +13,7 @@ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER @@ -24,8 +24,24 @@ from __future__ import annotations -from typing import ClassVar, List, Literal, Optional, TYPE_CHECKING, Tuple, Union, overload -from .enums import try_enum, ComponentType, ButtonStyle, TextStyle, ChannelType, SelectDefaultValueType +from typing import ( + ClassVar, + List, + Literal, + Optional, + TYPE_CHECKING, + Tuple, + Union, +) +from .enums import ( + try_enum, + ComponentType, + ButtonStyle, + TextStyle, + ChannelType, + SelectDefaultValueType, + DividerSize, +) from .utils import get_slots, MISSING from .partial_emoji import PartialEmoji, _EmojiTag @@ -33,14 +49,21 @@ from typing_extensions import Self from .types.components import ( + ComponentBase as ComponentBasePayload, Component as ComponentPayload, ButtonComponent as ButtonComponentPayload, SelectMenu as SelectMenuPayload, SelectOption as SelectOptionPayload, ActionRow as ActionRowPayload, TextInput as TextInputPayload, - ActionRowChildComponent as ActionRowChildComponentPayload, SelectDefaultValues as SelectDefaultValuesPayload, + SectionComponent as SectionComponentPayload, + TextComponent as TextComponentPayload, + ThumbnailComponent as ThumbnailComponentPayload, + MediaGalleryComponent as MediaGalleryComponentPayload, + FileComponent as FileComponentPayload, + DividerComponent as DividerComponentPayload, + ComponentContainer as ComponentContainerPayload, ) from .emoji import Emoji from .abc import Snowflake @@ -56,6 +79,13 @@ 'SelectOption', 'TextInput', 'SelectDefaultValue', + 'SectionComponent', + 'TextComponent', + 'ThumbnailComponent', + 'MediaGalleryComponent', + 'FileComponent', + 'DividerComponent', + 'ComponentContainer', ) @@ -99,7 +129,7 @@ def _raw_construct(cls, **kwargs) -> Self: setattr(self, slot, value) return self - def to_dict(self) -> ComponentPayload: + def to_dict(self) -> ComponentBasePayload: raise NotImplementedError @@ -290,9 +320,13 @@ def __init__(self, data: SelectMenuPayload, /) -> None: self.placeholder: Optional[str] = data.get('placeholder') self.min_values: int = data.get('min_values', 1) self.max_values: int = data.get('max_values', 1) - self.options: List[SelectOption] = [SelectOption.from_dict(option) for option in data.get('options', [])] + self.options: List[SelectOption] = [ + SelectOption.from_dict(option) for option in data.get('options', []) + ] self.disabled: bool = data.get('disabled', False) - self.channel_types: List[ChannelType] = [try_enum(ChannelType, t) for t in data.get('channel_types', [])] + self.channel_types: List[ChannelType] = [ + try_enum(ChannelType, t) for t in data.get('channel_types', []) + ] self.default_values: List[SelectDefaultValue] = [ SelectDefaultValue.from_dict(d) for d in data.get('default_values', []) ] @@ -312,7 +346,7 @@ def to_dict(self) -> SelectMenuPayload: if self.channel_types: payload['channel_types'] = [t.value for t in self.channel_types] if self.default_values: - payload["default_values"] = [v.to_dict() for v in self.default_values] + payload['default_values'] = [v.to_dict() for v in self.default_values] return payload @@ -408,7 +442,9 @@ def emoji(self, value: Optional[Union[str, Emoji, PartialEmoji]]) -> None: elif isinstance(value, _EmojiTag): self._emoji = value._to_partial() else: - raise TypeError(f'expected str, Emoji, or PartialEmoji, received {value.__class__.__name__} instead') + raise TypeError( + f'expected str, Emoji, or PartialEmoji, received {value.__class__.__name__} instead' + ) else: self._emoji = None @@ -564,7 +600,9 @@ def type(self) -> SelectDefaultValueType: @type.setter def type(self, value: SelectDefaultValueType) -> None: if not isinstance(value, SelectDefaultValueType): - raise TypeError(f'expected SelectDefaultValueType, received {value.__class__.__name__} instead') + raise TypeError( + f'expected SelectDefaultValueType, received {value.__class__.__name__} instead' + ) self._type = value @@ -642,17 +680,105 @@ def from_user(cls, user: Snowflake, /) -> Self: ) -@overload -def _component_factory(data: ActionRowChildComponentPayload) -> Optional[ActionRowChildComponentType]: - ... +class SectionComponent(Component): + """Represents a section from the Discord Bot UI Kit. + + This inherits from :class:`Component`. + + .. note:: + + The user constructible and usable type to create a section is :class:`discord.ui.Section` + not this one. + + .. versionadded:: tbd + + Attributes + ---------- + components: List[Union[:class:`TextDisplay`, :class:`Button`]] + The components on this section. + accessory: Optional[:class:`Component`] + The section accessory. + """ + + def __init__(self, data: SectionComponentPayload) -> None: + self.components: List[Union[TextDisplay, Button]] = [] + + for component_data in data['components']: + component = _component_factory(component_data) + if component is not None: + self.components.append(component) + + try: + self.accessory: Optional[Component] = _component_factory(data['accessory']) + except KeyError: + self.accessory = None + + @property + def type(self) -> Literal[ComponentType.section]: + return ComponentType.section + + def to_dict(self) -> SectionComponentPayload: + payload: SectionComponentPayload = { + 'type': self.type.value, + 'components': [c.to_dict() for c in self.components], + } + if self.accessory: + payload['accessory'] = self.accessory.to_dict() + return payload + +class TextDisplay(Component): + """Represents a text display from the Discord Bot UI Kit. -@overload -def _component_factory(data: ComponentPayload) -> Optional[Union[ActionRow, ActionRowChildComponentType]]: - ... + This inherits from :class:`Component`. + + .. versionadded:: tbd + + Parameters + ---------- + content: :class:`str` + The content that this display shows. + """ + + def __init__(self, content: str) -> None: + self.content: str = content + + @property + def type(self) -> Literal[ComponentType.text_display]: + return ComponentType.text_display + + @classmethod + def _from_data(cls, data: TextComponentPayload) -> TextDisplay: + return cls( + content=data['content'], + ) + + def to_dict(self) -> TextComponentPayload: + return { + 'type': self.type.value, + 'content': self.content, + } + + +class ThumbnailComponent(Component): + """Represents a thumbnail display from the Discord Bot UI Kit. + + This inherits from :class:`Component`. + + .. note:: + + The user constructuble and usable type to create a thumbnail + component is :class:`discord.ui.Thumbnail` not this one. + + .. versionadded:: tbd + + Attributes + ---------- + media: :class:`ComponentMedia` + """ -def _component_factory(data: ComponentPayload) -> Optional[Union[ActionRow, ActionRowChildComponentType]]: +def _component_factory(data: ComponentPayload) -> Optional[Component]: if data['type'] == 1: return ActionRow(data) elif data['type'] == 2: @@ -661,3 +787,17 @@ def _component_factory(data: ComponentPayload) -> Optional[Union[ActionRow, Acti return TextInput(data) elif data['type'] in (3, 5, 6, 7, 8): return SelectMenu(data) + elif data['type'] == 9: + return SectionComponent(data) + elif data['type'] == 10: + return TextDisplay._from_data(data) + elif data['type'] == 11: + return ThumbnailComponent(data) + elif data['type'] == 12: + return MediaGalleryComponent(data) + elif data['type'] == 13: + return FileComponent(data) + elif data['type'] == 14: + return DividerComponent(data) + elif data['type'] == 17: + return ComponentContainer(data) diff --git a/discord/enums.py b/discord/enums.py index ce772cc87285..fc9303d19a69 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -77,6 +77,8 @@ 'VoiceChannelEffectAnimationType', 'SubscriptionStatus', 'MessageReferenceType', + 'DividerSize', + 'MediaLoadingState', ) @@ -641,6 +643,13 @@ class ComponentType(Enum): role_select = 6 mentionable_select = 7 channel_select = 8 + section = 9 + text_display = 10 + thumbnail = 11 + media_gallery = 12 + file = 13 + separator = 14 + container = 17 def __int__(self) -> int: return self.value @@ -863,6 +872,18 @@ class SubscriptionStatus(Enum): inactive = 2 +class DividerSize(Enum): + small = 1 + large = 2 + + +class MediaLoadingState(Enum): + unknown = 0 + loading = 1 + loaded = 2 + not_found = 3 + + def create_unknown_value(cls: Type[E], val: Any) -> E: value_cls = cls._enum_value_cls_ # type: ignore # This is narrowed below name = f'unknown_{val}' diff --git a/discord/flags.py b/discord/flags.py index de806ba9c046..3be3239832dc 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -498,6 +498,14 @@ def forwarded(self): """ return 16384 + @flag_value + def components_v2(self): + """:class:`bool`: Returns ``True`` if the message has Discord's v2 components. + + Does not allow sending any ``content``, ``embed``, or ``embeds``. + """ + return 32768 + @fill_with_flags() class PublicUserFlags(BaseFlags): diff --git a/discord/message.py b/discord/message.py index 3016d2f2945c..1010e1c12a2c 100644 --- a/discord/message.py +++ b/discord/message.py @@ -27,8 +27,6 @@ import asyncio import datetime import re -import io -from os import PathLike from typing import ( Dict, TYPE_CHECKING, @@ -55,7 +53,7 @@ from .components import _component_factory from .embeds import Embed from .member import Member -from .flags import MessageFlags, AttachmentFlags +from .flags import MessageFlags from .file import File from .utils import escape_mentions, MISSING, deprecated from .http import handle_message_parameters @@ -65,6 +63,7 @@ from .threads import Thread from .channel import PartialMessageable from .poll import Poll +from .attachment import Attachment if TYPE_CHECKING: from typing_extensions import Self @@ -108,7 +107,6 @@ __all__ = ( - 'Attachment', 'Message', 'PartialMessage', 'MessageInteraction', @@ -140,298 +138,6 @@ def convert_emoji_reaction(emoji: Union[EmojiInputType, Reaction]) -> str: raise TypeError(f'emoji argument must be str, Emoji, or Reaction not {emoji.__class__.__name__}.') -class Attachment(Hashable): - """Represents an attachment from Discord. - - .. container:: operations - - .. describe:: str(x) - - Returns the URL of the attachment. - - .. describe:: x == y - - Checks if the attachment is equal to another attachment. - - .. describe:: x != y - - Checks if the attachment is not equal to another attachment. - - .. describe:: hash(x) - - Returns the hash of the attachment. - - .. versionchanged:: 1.7 - Attachment can now be casted to :class:`str` and is hashable. - - Attributes - ------------ - id: :class:`int` - The attachment ID. - size: :class:`int` - The attachment size in bytes. - height: Optional[:class:`int`] - The attachment's height, in pixels. Only applicable to images and videos. - width: Optional[:class:`int`] - The attachment's width, in pixels. Only applicable to images and videos. - filename: :class:`str` - The attachment's filename. - url: :class:`str` - The attachment URL. If the message this attachment was attached - to is deleted, then this will 404. - proxy_url: :class:`str` - The proxy URL. This is a cached version of the :attr:`~Attachment.url` in the - case of images. When the message is deleted, this URL might be valid for a few - minutes or not valid at all. - content_type: Optional[:class:`str`] - The attachment's `media type `_ - - .. versionadded:: 1.7 - description: Optional[:class:`str`] - The attachment's description. Only applicable to images. - - .. versionadded:: 2.0 - ephemeral: :class:`bool` - Whether the attachment is ephemeral. - - .. versionadded:: 2.0 - duration: Optional[:class:`float`] - The duration of the audio file in seconds. Returns ``None`` if it's not a voice message. - - .. versionadded:: 2.3 - waveform: Optional[:class:`bytes`] - The waveform (amplitudes) of the audio in bytes. Returns ``None`` if it's not a voice message. - - .. versionadded:: 2.3 - title: Optional[:class:`str`] - The normalised version of the attachment's filename. - - .. versionadded:: 2.5 - """ - - __slots__ = ( - 'id', - 'size', - 'height', - 'width', - 'filename', - 'url', - 'proxy_url', - '_http', - 'content_type', - 'description', - 'ephemeral', - 'duration', - 'waveform', - '_flags', - 'title', - ) - - def __init__(self, *, data: AttachmentPayload, state: ConnectionState): - self.id: int = int(data['id']) - self.size: int = data['size'] - self.height: Optional[int] = data.get('height') - self.width: Optional[int] = data.get('width') - self.filename: str = data['filename'] - self.url: str = data['url'] - self.proxy_url: str = data['proxy_url'] - self._http = state.http - self.content_type: Optional[str] = data.get('content_type') - self.description: Optional[str] = data.get('description') - self.ephemeral: bool = data.get('ephemeral', False) - self.duration: Optional[float] = data.get('duration_secs') - self.title: Optional[str] = data.get('title') - - waveform = data.get('waveform') - self.waveform: Optional[bytes] = utils._base64_to_bytes(waveform) if waveform is not None else None - - self._flags: int = data.get('flags', 0) - - @property - def flags(self) -> AttachmentFlags: - """:class:`AttachmentFlags`: The attachment's flags.""" - return AttachmentFlags._from_value(self._flags) - - def is_spoiler(self) -> bool: - """:class:`bool`: Whether this attachment contains a spoiler.""" - return self.filename.startswith('SPOILER_') - - def is_voice_message(self) -> bool: - """:class:`bool`: Whether this attachment is a voice message.""" - return self.duration is not None and 'voice-message' in self.url - - def __repr__(self) -> str: - return f'' - - def __str__(self) -> str: - return self.url or '' - - async def save( - self, - fp: Union[io.BufferedIOBase, PathLike[Any]], - *, - seek_begin: bool = True, - use_cached: bool = False, - ) -> int: - """|coro| - - Saves this attachment into a file-like object. - - Parameters - ----------- - fp: Union[:class:`io.BufferedIOBase`, :class:`os.PathLike`] - The file-like object to save this attachment to or the filename - to use. If a filename is passed then a file is created with that - filename and used instead. - seek_begin: :class:`bool` - Whether to seek to the beginning of the file after saving is - successfully done. - use_cached: :class:`bool` - Whether to use :attr:`proxy_url` rather than :attr:`url` when downloading - the attachment. This will allow attachments to be saved after deletion - more often, compared to the regular URL which is generally deleted right - after the message is deleted. Note that this can still fail to download - deleted attachments if too much time has passed and it does not work - on some types of attachments. - - Raises - -------- - HTTPException - Saving the attachment failed. - NotFound - The attachment was deleted. - - Returns - -------- - :class:`int` - The number of bytes written. - """ - data = await self.read(use_cached=use_cached) - if isinstance(fp, io.BufferedIOBase): - written = fp.write(data) - if seek_begin: - fp.seek(0) - return written - else: - with open(fp, 'wb') as f: - return f.write(data) - - async def read(self, *, use_cached: bool = False) -> bytes: - """|coro| - - Retrieves the content of this attachment as a :class:`bytes` object. - - .. versionadded:: 1.1 - - Parameters - ----------- - use_cached: :class:`bool` - Whether to use :attr:`proxy_url` rather than :attr:`url` when downloading - the attachment. This will allow attachments to be saved after deletion - more often, compared to the regular URL which is generally deleted right - after the message is deleted. Note that this can still fail to download - deleted attachments if too much time has passed and it does not work - on some types of attachments. - - Raises - ------ - HTTPException - Downloading the attachment failed. - Forbidden - You do not have permissions to access this attachment - NotFound - The attachment was deleted. - - Returns - ------- - :class:`bytes` - The contents of the attachment. - """ - url = self.proxy_url if use_cached else self.url - data = await self._http.get_from_cdn(url) - return data - - async def to_file( - self, - *, - filename: Optional[str] = MISSING, - description: Optional[str] = MISSING, - use_cached: bool = False, - spoiler: bool = False, - ) -> File: - """|coro| - - Converts the attachment into a :class:`File` suitable for sending via - :meth:`abc.Messageable.send`. - - .. versionadded:: 1.3 - - Parameters - ----------- - filename: Optional[:class:`str`] - The filename to use for the file. If not specified then the filename - of the attachment is used instead. - - .. versionadded:: 2.0 - description: Optional[:class:`str`] - The description to use for the file. If not specified then the - description of the attachment is used instead. - - .. versionadded:: 2.0 - use_cached: :class:`bool` - Whether to use :attr:`proxy_url` rather than :attr:`url` when downloading - the attachment. This will allow attachments to be saved after deletion - more often, compared to the regular URL which is generally deleted right - after the message is deleted. Note that this can still fail to download - deleted attachments if too much time has passed and it does not work - on some types of attachments. - - .. versionadded:: 1.4 - spoiler: :class:`bool` - Whether the file is a spoiler. - - .. versionadded:: 1.4 - - Raises - ------ - HTTPException - Downloading the attachment failed. - Forbidden - You do not have permissions to access this attachment - NotFound - The attachment was deleted. - - Returns - ------- - :class:`File` - The attachment as a file suitable for sending. - """ - - data = await self.read(use_cached=use_cached) - file_filename = filename if filename is not MISSING else self.filename - file_description = description if description is not MISSING else self.description - return File(io.BytesIO(data), filename=file_filename, description=file_description, spoiler=spoiler) - - def to_dict(self) -> AttachmentPayload: - result: AttachmentPayload = { - 'filename': self.filename, - 'id': self.id, - 'proxy_url': self.proxy_url, - 'size': self.size, - 'url': self.url, - 'spoiler': self.is_spoiler(), - } - if self.height: - result['height'] = self.height - if self.width: - result['width'] = self.width - if self.content_type: - result['content_type'] = self.content_type - if self.description is not None: - result['description'] = self.description - return result - - class DeletedReferencedMessage: """A special sentinel type given when the resolved message reference points to a deleted message. diff --git a/discord/types/attachment.py b/discord/types/attachment.py new file mode 100644 index 000000000000..38d8ad667cac --- /dev/null +++ b/discord/types/attachment.py @@ -0,0 +1,58 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from typing import Literal, Optional, TypedDict +from typing_extensions import NotRequired + +from .snowflake import Snowflake + +LoadingState = Literal[0, 1, 2, 3] + +class AttachmentBase(TypedDict): + url: str + proxy_url: str + description: NotRequired[str] + spoiler: NotRequired[bool] + height: NotRequired[Optional[int]] + width: NotRequired[Optional[int]] + content_type: NotRequired[str] + flags: NotRequired[int] + + +class Attachment(AttachmentBase): + id: Snowflake + filename: str + size: int + ephemeral: NotRequired[bool] + duration_secs: NotRequired[float] + waveform: NotRequired[str] + + +class UnfurledAttachment(AttachmentBase): + loading_state: LoadingState + src_is_animated: NotRequired[bool] + placeholder: str + placeholder_version: int diff --git a/discord/types/components.py b/discord/types/components.py index 3b1295c1393c..4521f2514bef 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -29,19 +29,27 @@ from .emoji import PartialEmoji from .channel import ChannelType +from .attachment import UnfurledAttachment ComponentType = Literal[1, 2, 3, 4] ButtonStyle = Literal[1, 2, 3, 4, 5, 6] TextStyle = Literal[1, 2] -DefaultValueType = Literal['user', 'role', 'channel'] +DefaultValueType = Literal["user", "role", "channel"] +DividerSize = Literal[1, 2] +MediaItemLoadingState = Literal[0, 1, 2, 3] -class ActionRow(TypedDict): +class ComponentBase(TypedDict): + id: NotRequired[int] + type: int + + +class ActionRow(ComponentBase): type: Literal[1] components: List[ActionRowChildComponent] -class ButtonComponent(TypedDict): +class ButtonComponent(ComponentBase): type: Literal[2] style: ButtonStyle custom_id: NotRequired[str] @@ -52,7 +60,7 @@ class ButtonComponent(TypedDict): sku_id: NotRequired[str] -class SelectOption(TypedDict): +class SelectOption(ComponentBase): label: str value: str default: bool @@ -60,7 +68,7 @@ class SelectOption(TypedDict): emoji: NotRequired[PartialEmoji] -class SelectComponent(TypedDict): +class SelectComponent(ComponentBase): custom_id: str placeholder: NotRequired[str] min_values: NotRequired[int] @@ -99,7 +107,7 @@ class ChannelSelectComponent(SelectComponent): default_values: NotRequired[List[SelectDefaultValues]] -class TextInput(TypedDict): +class TextInput(ComponentBase): type: Literal[4] custom_id: str style: TextStyle @@ -118,5 +126,52 @@ class SelectMenu(SelectComponent): default_values: NotRequired[List[SelectDefaultValues]] +class SectionComponent(ComponentBase): + type: Literal[9] + components: List[Union[TextComponent, ButtonComponent]] + accessory: NotRequired[ComponentBase] + + +class TextComponent(ComponentBase): + type: Literal[10] + content: str + + +class ThumbnailComponent(ComponentBase, UnfurledAttachment): + type: Literal[11] + + +class MediaGalleryComponent(ComponentBase): + type: Literal[12] + items: List[MediaItem] + + +class FileComponent(ComponentBase): + type: Literal[13] + file: MediaItem + spoiler: NotRequired[bool] + + +class DividerComponent(ComponentBase): + type: Literal[14] + divider: NotRequired[bool] + spacing: NotRequired[DividerSize] + + +class ComponentContainer(ComponentBase): + type: Literal[17] + accent_color: NotRequired[int] + spoiler: NotRequired[bool] + components: List[ContainerComponent] + + ActionRowChildComponent = Union[ButtonComponent, SelectMenu, TextInput] -Component = Union[ActionRow, ActionRowChildComponent] +ContainerComponent = Union[ + ActionRow, + TextComponent, + MediaGalleryComponent, + FileComponent, + SectionComponent, + SectionComponent, +] +Component = Union[ActionRowChildComponent, ContainerComponent] diff --git a/discord/types/message.py b/discord/types/message.py index ae38db46f8c0..81bfdd23baed 100644 --- a/discord/types/message.py +++ b/discord/types/message.py @@ -33,11 +33,12 @@ from .emoji import PartialEmoji from .embed import Embed from .channel import ChannelType -from .components import Component +from .components import ComponentBase from .interactions import MessageInteraction, MessageInteractionMetadata from .sticker import StickerItem from .threads import Thread from .poll import Poll +from .attachment import Attachment class PartialMessage(TypedDict): @@ -69,23 +70,6 @@ class Reaction(TypedDict): burst_colors: List[str] -class Attachment(TypedDict): - id: Snowflake - filename: str - size: int - url: str - proxy_url: str - height: NotRequired[Optional[int]] - width: NotRequired[Optional[int]] - description: NotRequired[str] - content_type: NotRequired[str] - spoiler: NotRequired[bool] - ephemeral: NotRequired[bool] - duration_secs: NotRequired[float] - waveform: NotRequired[str] - flags: NotRequired[int] - - MessageActivityType = Literal[1, 2, 3, 5] @@ -189,7 +173,7 @@ class MessageSnapshot(TypedDict): mentions: List[UserWithMember] mention_roles: SnowflakeList sticker_items: NotRequired[List[StickerItem]] - components: NotRequired[List[Component]] + components: NotRequired[List[ComponentBase]] class Message(PartialMessage): @@ -221,7 +205,7 @@ class Message(PartialMessage): referenced_message: NotRequired[Optional[Message]] interaction: NotRequired[MessageInteraction] # deprecated, use interaction_metadata interaction_metadata: NotRequired[MessageInteractionMetadata] - components: NotRequired[List[Component]] + components: NotRequired[List[ComponentBase]] position: NotRequired[int] role_subscription_data: NotRequired[RoleSubscriptionData] thread: NotRequired[Thread] @@ -229,7 +213,7 @@ class Message(PartialMessage): purchase_notification: NotRequired[PurchaseNotificationResponse] -AllowedMentionType = Literal['roles', 'users', 'everyone'] +AllowedMentionType = Literal["roles", "users", "everyone"] class AllowedMentions(TypedDict): diff --git a/discord/ui/section.py b/discord/ui/section.py new file mode 100644 index 000000000000..0f6f76006401 --- /dev/null +++ b/discord/ui/section.py @@ -0,0 +1,50 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" +from __future__ import annotations + +from typing import List, Optional + +from .item import Item + + +class Section(Item): + """Represents a UI section. + + .. versionadded:: tbd + + Parameters + ---------- + accessory: Optional[:class:`Item`] + The accessory to show within this section, displayed on the top right of this section. + """ + + __slots__ = ( + 'accessory', + '_children', + ) + + def __init__(self, *, accessory: Optional[Item]) -> None: + self.accessory: Optional[Item] = accessory + self._children: List[Item] = [] + self._underlying = SectionComponent From 75134562fd1be352994721b1e91577cd2b81f798 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Fri, 28 Feb 2025 22:13:21 +0100 Subject: [PATCH 002/272] feat: First components v2 commit --- discord/attachment.py | 178 +++++++++++++++++++----------------- discord/components.py | 6 +- discord/types/attachment.py | 4 +- discord/types/components.py | 4 +- discord/ui/section.py | 7 +- 5 files changed, 104 insertions(+), 95 deletions(-) diff --git a/discord/attachment.py b/discord/attachment.py index 2be4eac1aa73..45dab6c746f9 100644 --- a/discord/attachment.py +++ b/discord/attachment.py @@ -29,12 +29,19 @@ from .mixins import Hashable from .file import File -from .state import ConnectionState from .flags import AttachmentFlags +from .enums import MediaLoadingState, try_enum from . import utils if TYPE_CHECKING: - from .types.attachment import Attachment as AttachmentPayload + from .types.attachment import ( + AttachmentBase as AttachmentBasePayload, + Attachment as AttachmentPayload, + UnfurledAttachment as UnfurledAttachmentPayload, + ) + + from .http import HTTPClient + from .state import ConnectionState MISSING = utils.MISSING @@ -45,7 +52,40 @@ class AttachmentBase: - url: str + + __slots__ = ( + 'url', + 'proxy_url', + 'description', + 'filename', + 'spoiler', + 'height', + 'width', + 'content_type', + '_flags', + '_http', + '_state', + ) + + def __init__(self, data: AttachmentBasePayload, state: ConnectionState) -> None: + self._state: ConnectionState = state + self._http: HTTPClient = state.http + self.url: str = data['url'] + self.proxy_url: str = data['proxy_url'] + self.description: Optional[str] = data.get('description') + self.spoiler: bool = data.get('spoiler', False) + self.height: Optional[int] = data.get('height') + self.width: Optional[int] = data.get('width') + self.content_type: Optional[str] = data.get('content_type') + self._flags: int = data.get('flags', 0) + + @property + def flags(self) -> AttachmentFlags: + """:class:`AttachmentFlags`: The attachment's flag value.""" + return AttachmentFlags._from_value(self._flags) + + def __str__(self) -> str: + return self.url or '' async def save( self, @@ -200,6 +240,22 @@ async def to_file( spoiler=spoiler, ) + def to_dict(self): + base = { + 'url': self.url, + 'proxy_url': self.proxy_url, + 'spoiler': self.spoiler, + } + + if self.width: + base['width'] = self.width + if self.height: + base['height'] = self.height + if self.description: + base['description'] = self.description + + return base + class Attachment(Hashable, AttachmentBase): """Represents an attachment from Discord. @@ -268,56 +324,34 @@ class Attachment(Hashable, AttachmentBase): The normalised version of the attachment's filename. .. versionadded:: 2.5 + spoiler: :class:`bool` + Whether the attachment is a spoiler or not. Unlike :meth:`.is_spoiler`, this uses the API returned + data. + + .. versionadded:: 2.6 """ __slots__ = ( 'id', 'size', - 'height', - 'width', - 'filename', - 'url', - 'proxy_url', - '_http', - 'content_type', - 'description', 'ephemeral', 'duration', 'waveform', - '_flags', 'title', ) def __init__(self, *, data: AttachmentPayload, state: ConnectionState): self.id: int = int(data['id']) - self.size: int = data['size'] - self.height: Optional[int] = data.get('height') - self.width: Optional[int] = data.get('width') self.filename: str = data['filename'] - self.url: str = data['url'] - self.proxy_url: str = data['proxy_url'] - self._http = state.http - self.content_type: Optional[str] = data.get('content_type') - self.description: Optional[str] = data.get('description') + self.size: int = data['size'] self.ephemeral: bool = data.get('ephemeral', False) self.duration: Optional[float] = data.get('duration_secs') self.title: Optional[str] = data.get('title') - - waveform = data.get('waveform') - self.waveform: Optional[bytes] = ( - utils._base64_to_bytes(waveform) if waveform is not None else None - ) - - self._flags: int = data.get('flags', 0) - - @property - def flags(self) -> AttachmentFlags: - """:class:`AttachmentFlags`: The attachment's flags.""" - return AttachmentFlags._from_value(self._flags) + super().__init__(data, state) def is_spoiler(self) -> bool: """:class:`bool`: Whether this attachment contains a spoiler.""" - return self.filename.startswith('SPOILER_') + return self.spoiler or self.filename.startswith('SPOILER_') def is_voice_message(self) -> bool: """:class:`bool`: Whether this attachment is a voice message.""" @@ -326,33 +360,18 @@ def is_voice_message(self) -> bool: def __repr__(self) -> str: return f'' - def __str__(self) -> str: - return self.url or '' - def to_dict(self) -> AttachmentPayload: - result: AttachmentPayload = { - 'filename': self.filename, - 'id': self.id, - 'proxy_url': self.proxy_url, - 'size': self.size, - 'url': self.url, - 'spoiler': self.is_spoiler(), - } - if self.height: - result['height'] = self.height - if self.width: - result['width'] = self.width - if self.content_type: - result['content_type'] = self.content_type - if self.description is not None: - result['description'] = self.description + result: AttachmentPayload = super().to_dict() # pyright: ignore[reportAssignmentType] + result['id'] = self.id + result['filename'] = self.filename + result['size'] = self.size return result class UnfurledAttachment(AttachmentBase): """Represents an unfurled attachment item from a :class:`Component`. - .. versionadded:: tbd + .. versionadded:: 2.6 .. container:: operations @@ -370,48 +389,35 @@ class UnfurledAttachment(AttachmentBase): Attributes ---------- + height: Optional[:class:`int`] + The attachment's height, in pixels. Only applicable to images and videos. + width: Optional[:class:`int`] + The attachment's width, in pixels. Only applicable to images and videos. url: :class:`str` - The unfurled attachment URL. - proxy_url: Optional[:class:`str`] - The proxy URL. This is cached version of the :attr:`~UnfurledAttachment.url` in the + The attachment URL. If the message this attachment was attached + to is deleted, then this will 404. + proxy_url: :class:`str` + The proxy URL. This is a cached version of the :attr:`~Attachment.url` in the case of images. When the message is deleted, this URL might be valid for a few minutes or not valid at all. - - .. note:: - - This will be ``None`` if :meth:`.is_resolved` is ``False``. - height: Optional[:class:`int`] - The unfurled attachment's height, in pixels. - - .. note:: - - This will be ``None`` if :meth:`.is_resolved` is ``False``. - width: Optional[:class:`int`] - The unfurled attachment's width, in pixels. - - .. note:: - - This will be ``None`` if :meth:`.is_resolved` is ``False``. content_type: Optional[:class:`str`] The attachment's `media type `_ - - .. note:: - - This will be ``None`` if :meth:`.is_resolved` is ``False``. + description: Optional[:class:`str`] + The attachment's description. Only applicable to images. + spoiler: :class:`bool` + Whether the attachment is a spoiler or not. Unlike :meth:`.is_spoiler`, this uses the API returned + data. loading_state: :class:`MediaLoadingState` - The load state of this attachment on Discord side. - description + The cache state of this unfurled attachment. """ __slots__ = ( - 'url', - 'proxy_url', - 'height', - 'width', - 'content_type', 'loading_state', - '_resolved', - '_state', ) - def __init__(self, ) + def __init__(self, data: UnfurledAttachmentPayload, state: ConnectionState) -> None: + self.loading_state: MediaLoadingState = try_enum(MediaLoadingState, data['loading_state']) + super().__init__(data, state) + + def __repr__(self) -> str: + return f'' diff --git a/discord/components.py b/discord/components.py index a0fd1148d448..1a40d3d0bb09 100644 --- a/discord/components.py +++ b/discord/components.py @@ -690,7 +690,7 @@ class SectionComponent(Component): The user constructible and usable type to create a section is :class:`discord.ui.Section` not this one. - .. versionadded:: tbd + .. versionadded:: 2.6 Attributes ---------- @@ -732,7 +732,7 @@ class TextDisplay(Component): This inherits from :class:`Component`. - .. versionadded:: tbd + .. versionadded:: 2.6 Parameters ---------- @@ -770,7 +770,7 @@ class ThumbnailComponent(Component): The user constructuble and usable type to create a thumbnail component is :class:`discord.ui.Thumbnail` not this one. - .. versionadded:: tbd + .. versionadded:: 2.6 Attributes ---------- diff --git a/discord/types/attachment.py b/discord/types/attachment.py index 38d8ad667cac..20fcd8e1b9ae 100644 --- a/discord/types/attachment.py +++ b/discord/types/attachment.py @@ -49,10 +49,8 @@ class Attachment(AttachmentBase): ephemeral: NotRequired[bool] duration_secs: NotRequired[float] waveform: NotRequired[str] + title: NotRequired[str] class UnfurledAttachment(AttachmentBase): loading_state: LoadingState - src_is_animated: NotRequired[bool] - placeholder: str - placeholder_version: int diff --git a/discord/types/components.py b/discord/types/components.py index 4521f2514bef..c169a52861a1 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -143,12 +143,12 @@ class ThumbnailComponent(ComponentBase, UnfurledAttachment): class MediaGalleryComponent(ComponentBase): type: Literal[12] - items: List[MediaItem] + items: List[UnfurledAttachment] class FileComponent(ComponentBase): type: Literal[13] - file: MediaItem + file: UnfurledAttachment spoiler: NotRequired[bool] diff --git a/discord/ui/section.py b/discord/ui/section.py index 0f6f76006401..fc8a9e142217 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -26,6 +26,9 @@ from typing import List, Optional from .item import Item +from ..components import SectionComponent + +__all__ = ('Section',) class Section(Item): @@ -47,4 +50,6 @@ class Section(Item): def __init__(self, *, accessory: Optional[Item]) -> None: self.accessory: Optional[Item] = accessory self._children: List[Item] = [] - self._underlying = SectionComponent + self._underlying = SectionComponent._raw_construct( + accessory=accessory, + ) From 335b3976d86660e1e1ae345cd161dc8556e9236a Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 1 Mar 2025 10:01:43 +0100 Subject: [PATCH 003/272] chore: Update components --- discord/attachment.py | 25 +++- discord/components.py | 286 +++++++++++++++++++++++++++++++----- discord/enums.py | 4 +- discord/message.py | 2 +- discord/types/attachment.py | 6 +- discord/types/components.py | 29 ++-- discord/ui/__init__.py | 1 + discord/ui/container.py | 86 +++++++++++ discord/ui/section.py | 55 ------- discord/ui/view.py | 11 +- 10 files changed, 394 insertions(+), 111 deletions(-) create mode 100644 discord/ui/container.py delete mode 100644 discord/ui/section.py diff --git a/discord/attachment.py b/discord/attachment.py index 45dab6c746f9..195ce30b5d3c 100644 --- a/discord/attachment.py +++ b/discord/attachment.py @@ -27,6 +27,7 @@ from os import PathLike from typing import TYPE_CHECKING, Any, Optional, Union +from .errors import ClientException from .mixins import Hashable from .file import File from .flags import AttachmentFlags @@ -67,9 +68,9 @@ class AttachmentBase: '_state', ) - def __init__(self, data: AttachmentBasePayload, state: ConnectionState) -> None: - self._state: ConnectionState = state - self._http: HTTPClient = state.http + def __init__(self, data: AttachmentBasePayload, state: Optional[ConnectionState]) -> None: + self._state: Optional[ConnectionState] = state + self._http: Optional[HTTPClient] = state.http if state else None self.url: str = data['url'] self.proxy_url: str = data['proxy_url'] self.description: Optional[str] = data.get('description') @@ -162,12 +163,19 @@ async def read(self, *, use_cached: bool = False) -> bytes: You do not have permissions to access this attachment NotFound The attachment was deleted. + ClientException + Cannot read a stateless attachment. Returns ------- :class:`bytes` The contents of the attachment. """ + if not self._http: + raise ClientException( + 'Cannot read a stateless attachment' + ) + url = self.proxy_url if use_cached else self.url data = await self._http.get_from_cdn(url) return data @@ -240,8 +248,8 @@ async def to_file( spoiler=spoiler, ) - def to_dict(self): - base = { + def to_dict(self) -> AttachmentBasePayload: + base: AttachmentBasePayload = { 'url': self.url, 'proxy_url': self.proxy_url, 'spoiler': self.spoiler, @@ -415,9 +423,12 @@ class UnfurledAttachment(AttachmentBase): 'loading_state', ) - def __init__(self, data: UnfurledAttachmentPayload, state: ConnectionState) -> None: - self.loading_state: MediaLoadingState = try_enum(MediaLoadingState, data['loading_state']) + def __init__(self, data: UnfurledAttachmentPayload, state: Optional[ConnectionState]) -> None: + self.loading_state: MediaLoadingState = try_enum(MediaLoadingState, data.get('loading_state', 0)) super().__init__(data, state) def __repr__(self) -> str: return f'' + + def to_object_dict(self): + return {'url': self.url} diff --git a/discord/components.py b/discord/components.py index 1a40d3d0bb09..09f6d54abcb0 100644 --- a/discord/components.py +++ b/discord/components.py @@ -33,6 +33,8 @@ Tuple, Union, ) + +from .attachment import UnfurledAttachment from .enums import ( try_enum, ComponentType, @@ -40,8 +42,9 @@ TextStyle, ChannelType, SelectDefaultValueType, - DividerSize, + SeparatorSize, ) +from .colour import Colour from .utils import get_slots, MISSING from .partial_emoji import PartialEmoji, _EmojiTag @@ -59,16 +62,20 @@ SelectDefaultValues as SelectDefaultValuesPayload, SectionComponent as SectionComponentPayload, TextComponent as TextComponentPayload, - ThumbnailComponent as ThumbnailComponentPayload, MediaGalleryComponent as MediaGalleryComponentPayload, FileComponent as FileComponentPayload, - DividerComponent as DividerComponentPayload, - ComponentContainer as ComponentContainerPayload, + SeparatorComponent as SeparatorComponentPayload, + MediaGalleryItem as MediaGalleryItemPayload, + ThumbnailComponent as ThumbnailComponentPayload, + ContainerComponent as ContainerComponentPayload, ) + from .emoji import Emoji from .abc import Snowflake + from .state import ConnectionState ActionRowChildComponentType = Union['Button', 'SelectMenu', 'TextInput'] + SectionComponentType = Union['TextDisplay', 'Button'] __all__ = ( @@ -80,12 +87,10 @@ 'TextInput', 'SelectDefaultValue', 'SectionComponent', - 'TextComponent', 'ThumbnailComponent', 'MediaGalleryComponent', 'FileComponent', - 'DividerComponent', - 'ComponentContainer', + 'SectionComponent', ) @@ -159,7 +164,7 @@ def __init__(self, data: ActionRowPayload, /) -> None: component = _component_factory(component_data) if component is not None: - self.children.append(component) + self.children.append(component) # type: ignore # should be the correct type here @property def type(self) -> Literal[ComponentType.action_row]: @@ -701,12 +706,12 @@ class SectionComponent(Component): """ def __init__(self, data: SectionComponentPayload) -> None: - self.components: List[Union[TextDisplay, Button]] = [] + self.components: List[SectionComponentType] = [] for component_data in data['components']: component = _component_factory(component_data) if component is not None: - self.components.append(component) + self.components.append(component) # type: ignore # should be the correct type here try: self.accessory: Optional[Component] = _component_factory(data['accessory']) @@ -727,6 +732,43 @@ def to_dict(self) -> SectionComponentPayload: return payload +class ThumbnailComponent(Component): + """Represents a Thumbnail from the Discord Bot UI Kit. + + This inherits from :class:`Component`. + + Attributes + ---------- + media: :class:`UnfurledAttachment` + The media for this thumbnail. + description: Optional[:class:`str`] + The description shown within this thumbnail. + spoiler: :class:`bool` + Whether this thumbnail is flagged as a spoiler. + """ + + def __init__( + self, + data: ThumbnailComponentPayload, + state: ConnectionState, + ) -> None: + self.media: UnfurledAttachment = UnfurledAttachment(data['media'], state) + self.description: Optional[str] = data.get('description') + self.spoiler: bool = data.get('spoiler', False) + + @property + def type(self) -> Literal[ComponentType.thumbnail]: + return ComponentType.thumbnail + + def to_dict(self) -> ThumbnailComponentPayload: + return { + 'media': self.media.to_dict(), # type: ignroe + 'description': self.description, + 'spoiler': self.spoiler, + 'type': self.type.value, + } + + class TextDisplay(Component): """Represents a text display from the Discord Bot UI Kit. @@ -734,51 +776,231 @@ class TextDisplay(Component): .. versionadded:: 2.6 - Parameters + Attributes ---------- content: :class:`str` The content that this display shows. """ - def __init__(self, content: str) -> None: - self.content: str = content + def __init__(self, data: TextComponentPayload) -> None: + self.content: str = data['content'] @property def type(self) -> Literal[ComponentType.text_display]: return ComponentType.text_display + def to_dict(self) -> TextComponentPayload: + return { + 'type': self.type.value, + 'content': self.content, + } + + +class MediaGalleryItem: + """Represents a :class:`MediaGalleryComponent` media item. + + Parameters + ---------- + url: :class:`str` + The url of the media item. This can be a local file uploaded + as an attachment in the message, that can be accessed using + the ``attachment://file-name.extension`` format. + description: Optional[:class:`str`] + The description to show within this item. + spoiler: :class:`bool` + Whether this item should be flagged as a spoiler. + """ + + __slots__ = ( + 'url', + 'description', + 'spoiler', + '_state', + ) + + def __init__( + self, + url: str, + *, + description: Optional[str] = None, + spoiler: bool = False, + ) -> None: + self.url: str = url + self.description: Optional[str] = description + self.spoiler: bool = spoiler + self._state: Optional[ConnectionState] = None + @classmethod - def _from_data(cls, data: TextComponentPayload) -> TextDisplay: - return cls( - content=data['content'], + def _from_data( + cls, data: MediaGalleryItemPayload, state: Optional[ConnectionState] + ) -> MediaGalleryItem: + media = data['media'] + self = cls( + url=media['url'], + description=data.get('description'), + spoiler=data.get('spoiler', False), ) + self._state = state + return self - def to_dict(self) -> TextComponentPayload: + @classmethod + def _from_gallery( + cls, + items: List[MediaGalleryItemPayload], + state: Optional[ConnectionState], + ) -> List[MediaGalleryItem]: + return [cls._from_data(item, state) for item in items] + + def to_dict(self) -> MediaGalleryItemPayload: + return { # type: ignore + 'media': {'url': self.url}, + 'description': self.description, + 'spoiler': self.spoiler, + } + + +class MediaGalleryComponent(Component): + """Represents a Media Gallery component from the Discord Bot UI Kit. + + This inherits from :class:`Component`. + + Attributes + ---------- + items: List[:class:`MediaGalleryItem`] + The items this gallery has. + """ + + __slots__ = ('items', 'id') + + def __init__(self, data: MediaGalleryComponentPayload, state: Optional[ConnectionState]) -> None: + self.items: List[MediaGalleryItem] = MediaGalleryItem._from_gallery(data['items'], state) + + @property + def type(self) -> Literal[ComponentType.media_gallery]: + return ComponentType.media_gallery + + def to_dict(self) -> MediaGalleryComponentPayload: return { + 'id': self.id, 'type': self.type.value, - 'content': self.content, + 'items': [item.to_dict() for item in self.items], } -class ThumbnailComponent(Component): - """Represents a thumbnail display from the Discord Bot UI Kit. +class FileComponent(Component): + """Represents a File component from the Discord Bot UI Kit. This inherits from :class:`Component`. - .. note:: + Attributes + ---------- + media: :class:`UnfurledAttachment` + The unfurled attachment contents of the file. + spoiler: :class:`bool` + Whether this file is flagged as a spoiler. + """ + + __slots__ = ( + 'media', + 'spoiler', + ) - The user constructuble and usable type to create a thumbnail - component is :class:`discord.ui.Thumbnail` not this one. + def __init__(self, data: FileComponentPayload, state: Optional[ConnectionState]) -> None: + self.media: UnfurledAttachment = UnfurledAttachment( + data['file'], state, + ) + self.spoiler: bool = data.get('spoiler', False) - .. versionadded:: 2.6 + @property + def type(self) -> Literal[ComponentType.file]: + return ComponentType.file + + def to_dict(self) -> FileComponentPayload: + return { # type: ignore + 'file': {'url': self.url}, + 'spoiler': self.spoiler, + 'type': self.type.value, + } + + +class SeparatorComponent(Component): + """Represents a Separator from the Discord Bot UI Kit. + + This inherits from :class:`Component`. Attributes ---------- - media: :class:`ComponentMedia` + spacing: :class:`SeparatorSize` + The spacing size of the separator. + divider: :class:`bool` + Whether this separator is a divider. """ + __slots__ = ( + 'spacing', + 'divider', + ) + + def __init__( + self, + data: SeparatorComponentPayload, + ) -> None: + self.spacing: SeparatorSize = try_enum(SeparatorSize, data.get('spacing', 1)) + self.divider: bool = data.get('divider', True) + + @property + def type(self) -> Literal[ComponentType.separator]: + return ComponentType.separator + + def to_dict(self) -> SeparatorComponentPayload: + return { + 'type': self.type.value, + 'divider': self.divider, + 'spacing': self.spacing.value, + } + + +class Container(Component): + """Represents a Container from the Discord Bot UI Kit. + + This inherits from :class:`Component`. + + Attributes + ---------- + children: :class:`Component` + This container's children. + spoiler: :class:`bool` + Whether this container is flagged as a spoiler. + """ + + def __init__(self, data: ContainerComponentPayload, state: Optional[ConnectionState]) -> None: + self.children: List[Component] = [] + + for child in data['components']: + comp = _component_factory(child, state) + + if comp: + self.children.append(comp) + + self.spoiler: bool = data.get('spoiler', False) + self._colour: Optional[Colour] + try: + self._colour = Colour(data['accent_color']) + except KeyError: + self._colour = None + + @property + def accent_colour(self) -> Optional[Colour]: + """Optional[:class:`Colour`]: The container's accent colour.""" + return self._colour + + accent_color = accent_colour + """Optional[:class:`Color`]: The container's accent color.""" + -def _component_factory(data: ComponentPayload) -> Optional[Component]: +def _component_factory( + data: ComponentPayload, state: Optional[ConnectionState] = None +) -> Optional[Component]: if data['type'] == 1: return ActionRow(data) elif data['type'] == 2: @@ -790,14 +1012,12 @@ def _component_factory(data: ComponentPayload) -> Optional[Component]: elif data['type'] == 9: return SectionComponent(data) elif data['type'] == 10: - return TextDisplay._from_data(data) - elif data['type'] == 11: - return ThumbnailComponent(data) + return TextDisplay(data) elif data['type'] == 12: - return MediaGalleryComponent(data) + return MediaGalleryComponent(data, state) elif data['type'] == 13: - return FileComponent(data) + return FileComponent(data, state) elif data['type'] == 14: - return DividerComponent(data) + return SeparatorComponent(data) elif data['type'] == 17: - return ComponentContainer(data) + return Container(data, state) diff --git a/discord/enums.py b/discord/enums.py index 082a1a708247..025f0bf147c1 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -77,7 +77,7 @@ 'VoiceChannelEffectAnimationType', 'SubscriptionStatus', 'MessageReferenceType', - 'DividerSize', + 'SeparatorSize', 'MediaLoadingState', ) @@ -872,7 +872,7 @@ class SubscriptionStatus(Enum): inactive = 2 -class DividerSize(Enum): +class SeparatorSize(Enum): small = 1 large = 2 diff --git a/discord/message.py b/discord/message.py index 8a916083e978..000747e787cf 100644 --- a/discord/message.py +++ b/discord/message.py @@ -238,7 +238,7 @@ def __init__(self, state: ConnectionState, data: MessageSnapshotPayload): self.components: List[MessageComponentType] = [] for component_data in data.get('components', []): - component = _component_factory(component_data) + component = _component_factory(component_data, state) if component is not None: self.components.append(component) diff --git a/discord/types/attachment.py b/discord/types/attachment.py index 20fcd8e1b9ae..0084c334c67e 100644 --- a/discord/types/attachment.py +++ b/discord/types/attachment.py @@ -25,7 +25,7 @@ from __future__ import annotations from typing import Literal, Optional, TypedDict -from typing_extensions import NotRequired +from typing_extensions import NotRequired, Required from .snowflake import Snowflake @@ -52,5 +52,5 @@ class Attachment(AttachmentBase): title: NotRequired[str] -class UnfurledAttachment(AttachmentBase): - loading_state: LoadingState +class UnfurledAttachment(AttachmentBase, total=False): + loading_state: Required[LoadingState] diff --git a/discord/types/components.py b/discord/types/components.py index c169a52861a1..cffb67ead3be 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -24,14 +24,14 @@ from __future__ import annotations -from typing import List, Literal, TypedDict, Union +from typing import List, Literal, Optional, TypedDict, Union from typing_extensions import NotRequired from .emoji import PartialEmoji from .channel import ChannelType from .attachment import UnfurledAttachment -ComponentType = Literal[1, 2, 3, 4] +ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 17] ButtonStyle = Literal[1, 2, 3, 4, 5, 6] TextStyle = Literal[1, 2] DefaultValueType = Literal["user", "role", "channel"] @@ -137,13 +137,22 @@ class TextComponent(ComponentBase): content: str -class ThumbnailComponent(ComponentBase, UnfurledAttachment): +class ThumbnailComponent(ComponentBase): type: Literal[11] + media: UnfurledAttachment + description: NotRequired[Optional[str]] + spoiler: NotRequired[bool] + + +class MediaGalleryItem(TypedDict): + media: UnfurledAttachment + description: NotRequired[Optional[str]] + spoiler: NotRequired[bool] class MediaGalleryComponent(ComponentBase): type: Literal[12] - items: List[UnfurledAttachment] + items: List[MediaGalleryItem] class FileComponent(ComponentBase): @@ -152,26 +161,28 @@ class FileComponent(ComponentBase): spoiler: NotRequired[bool] -class DividerComponent(ComponentBase): +class SeparatorComponent(ComponentBase): type: Literal[14] divider: NotRequired[bool] spacing: NotRequired[DividerSize] -class ComponentContainer(ComponentBase): +class ContainerComponent(ComponentBase): type: Literal[17] accent_color: NotRequired[int] spoiler: NotRequired[bool] - components: List[ContainerComponent] + components: List[ContainerChildComponent] ActionRowChildComponent = Union[ButtonComponent, SelectMenu, TextInput] -ContainerComponent = Union[ +ContainerChildComponent = Union[ ActionRow, TextComponent, MediaGalleryComponent, FileComponent, SectionComponent, SectionComponent, + ContainerComponent, + SeparatorComponent, ] -Component = Union[ActionRowChildComponent, ContainerComponent] +Component = Union[ActionRowChildComponent, ContainerChildComponent] diff --git a/discord/ui/__init__.py b/discord/ui/__init__.py index c5a51777ce3e..029717cb5294 100644 --- a/discord/ui/__init__.py +++ b/discord/ui/__init__.py @@ -16,3 +16,4 @@ from .select import * from .text_input import * from .dynamic import * +from .container import * diff --git a/discord/ui/container.py b/discord/ui/container.py new file mode 100644 index 000000000000..6792c188f089 --- /dev/null +++ b/discord/ui/container.py @@ -0,0 +1,86 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING, List, Optional + +if TYPE_CHECKING: + from ..components import Component + from ..colour import Colour, Color + +__all__ = ('Container',) + + +class Container: + """Represents a Components V2 Container. + + .. versionadded:: 2.6 + + Parameters + ---------- + children: List[:class:`Item`] + The initial children of this container. + accent_colour: Optional[:class:`~discord.Colour`] + The colour of the container. Defaults to ``None``. + accent_color: Optional[:class:`~discord.Color`] + The color of the container. Defaults to ``None``. + spoiler: :class:`bool` + Whether to flag this container as a spoiler. Defaults + to ``False``. + """ + + __discord_ui_container__ = True + + def __init__( + self, + children: List[Component], + *, + accent_colour: Optional[Colour] = None, + accent_color: Optional[Color] = None, + spoiler: bool = False, + ) -> None: + self._children: List[Component] = children + self.spoiler: bool = spoiler + self._colour = accent_colour or accent_color + + @property + def children(self) -> List[Component]: + """List[:class:`~discord.Component`]: The children of this container.""" + return self._children.copy() + + @children.setter + def children(self, value: List[Component]) -> None: + self._children = value + + @property + def accent_colour(self) -> Optional[Colour]: + """Optional[:class:`~discord.Colour`]: The colour of the container, or ``None``.""" + return self._colour + + @accent_colour.setter + def accent_colour(self, value: Optional[Colour]) -> None: + self._colour = value + + accent_color = accent_colour + """Optional[:class:`~discord.Color`]: The color of the container, or ``None``.""" diff --git a/discord/ui/section.py b/discord/ui/section.py deleted file mode 100644 index fc8a9e142217..000000000000 --- a/discord/ui/section.py +++ /dev/null @@ -1,55 +0,0 @@ -""" -The MIT License (MIT) - -Copyright (c) 2015-present Rapptz - -Permission is hereby granted, free of charge, to any person obtaining a -copy of this software and associated documentation files (the "Software"), -to deal in the Software without restriction, including without limitation -the rights to use, copy, modify, merge, publish, distribute, sublicense, -and/or sell copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. -""" -from __future__ import annotations - -from typing import List, Optional - -from .item import Item -from ..components import SectionComponent - -__all__ = ('Section',) - - -class Section(Item): - """Represents a UI section. - - .. versionadded:: tbd - - Parameters - ---------- - accessory: Optional[:class:`Item`] - The accessory to show within this section, displayed on the top right of this section. - """ - - __slots__ = ( - 'accessory', - '_children', - ) - - def __init__(self, *, accessory: Optional[Item]) -> None: - self.accessory: Optional[Item] = accessory - self._children: List[Item] = [] - self._underlying = SectionComponent._raw_construct( - accessory=accessory, - ) diff --git a/discord/ui/view.py b/discord/ui/view.py index dd44944ec0ef..b6262cf22b1c 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -40,6 +40,11 @@ _component_factory, Button as ButtonComponent, SelectMenu as SelectComponent, + SectionComponent, + TextDisplay, + MediaGalleryComponent, + FileComponent, + SeparatorComponent, ) # fmt: off @@ -62,6 +67,7 @@ _log = logging.getLogger(__name__) +V2_COMPONENTS = (SectionComponent, TextDisplay, MediaGalleryComponent, FileComponent, SeparatorComponent) def _walk_all_components(components: List[Component]) -> Iterator[Component]: @@ -81,6 +87,8 @@ def _component_to_item(component: Component) -> Item: from .select import BaseSelect return BaseSelect.from_component(component) + if isinstance(component, V2_COMPONENTS): + return component return Item.from_component(component) @@ -157,6 +165,7 @@ class View: __discord_ui_view__: ClassVar[bool] = True __discord_ui_modal__: ClassVar[bool] = False + __discord_ui_container__: ClassVar[bool] = False __view_children_items__: ClassVar[List[ItemCallbackType[Any, Any]]] = [] def __init_subclass__(cls) -> None: @@ -737,7 +746,7 @@ def update_from_message(self, message_id: int, data: List[ComponentPayload]) -> components: List[Component] = [] for component_data in data: - component = _component_factory(component_data) + component = _component_factory(component_data, self._state) if component is not None: components.append(component) From ce3f48e959662ce409d46042d65514b958461204 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 1 Mar 2025 10:03:04 +0100 Subject: [PATCH 004/272] fix: License quotes --- discord/components.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/components.py b/discord/components.py index 09f6d54abcb0..4b25bcb00156 100644 --- a/discord/components.py +++ b/discord/components.py @@ -4,7 +4,7 @@ Copyright (c) 2015-present Rapptz Permission is hereby granted, free of charge, to any person obtaining a -copy of this software and associated documentation files (the 'Software'), +copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the @@ -13,7 +13,7 @@ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER From eea95d95c917cc3cda267386d2cad623bfef4b20 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 1 Mar 2025 12:55:15 +0100 Subject: [PATCH 005/272] chore: Add more components and some things on weights and so --- discord/components.py | 43 +++++++--- discord/http.py | 6 ++ discord/types/components.py | 1 + discord/ui/container.py | 55 ++++++++++--- discord/ui/item.py | 3 + discord/ui/section.py | 151 ++++++++++++++++++++++++++++++++++++ discord/ui/thumbnail.py | 86 ++++++++++++++++++++ discord/ui/view.py | 55 +++++++------ 8 files changed, 358 insertions(+), 42 deletions(-) create mode 100644 discord/ui/section.py create mode 100644 discord/ui/thumbnail.py diff --git a/discord/components.py b/discord/components.py index 4b25bcb00156..4e0196f7dd92 100644 --- a/discord/components.py +++ b/discord/components.py @@ -97,12 +97,19 @@ class Component: """Represents a Discord Bot UI Kit Component. - Currently, the only components supported by Discord are: + The components supported by Discord are: - :class:`ActionRow` - :class:`Button` - :class:`SelectMenu` - :class:`TextInput` + - :class:`SectionComponent` + - :class:`TextDisplay` + - :class:`ThumbnailComponent` + - :class:`MediaGalleryComponent` + - :class:`FileComponent` + - :class:`SeparatorComponent` + - :class:`Container` This class is abstract and cannot be instantiated. @@ -705,11 +712,18 @@ class SectionComponent(Component): The section accessory. """ - def __init__(self, data: SectionComponentPayload) -> None: + __slots__ = ( + 'components', + 'accessory', + ) + + __repr_info__ = __slots__ + + def __init__(self, data: SectionComponentPayload, state: Optional[ConnectionState]) -> None: self.components: List[SectionComponentType] = [] for component_data in data['components']: - component = _component_factory(component_data) + component = _component_factory(component_data, state) if component is not None: self.components.append(component) # type: ignore # should be the correct type here @@ -737,6 +751,11 @@ class ThumbnailComponent(Component): This inherits from :class:`Component`. + .. note:: + + The user constructible and usable type to create a thumbnail is :class:`discord.ui.Thumbnail` + not this one. + Attributes ---------- media: :class:`UnfurledAttachment` @@ -747,10 +766,12 @@ class ThumbnailComponent(Component): Whether this thumbnail is flagged as a spoiler. """ + __slots__ = () + def __init__( self, data: ThumbnailComponentPayload, - state: ConnectionState, + state: Optional[ConnectionState], ) -> None: self.media: UnfurledAttachment = UnfurledAttachment(data['media'], state) self.description: Optional[str] = data.get('description') @@ -932,13 +953,13 @@ class SeparatorComponent(Component): ---------- spacing: :class:`SeparatorSize` The spacing size of the separator. - divider: :class:`bool` - Whether this separator is a divider. + visible: :class:`bool` + Whether this separator is visible and shows a divider. """ __slots__ = ( 'spacing', - 'divider', + 'visible', ) def __init__( @@ -946,7 +967,7 @@ def __init__( data: SeparatorComponentPayload, ) -> None: self.spacing: SeparatorSize = try_enum(SeparatorSize, data.get('spacing', 1)) - self.divider: bool = data.get('divider', True) + self.visible: bool = data.get('divider', True) @property def type(self) -> Literal[ComponentType.separator]: @@ -955,7 +976,7 @@ def type(self) -> Literal[ComponentType.separator]: def to_dict(self) -> SeparatorComponentPayload: return { 'type': self.type.value, - 'divider': self.divider, + 'divider': self.visible, 'spacing': self.spacing.value, } @@ -1010,9 +1031,11 @@ def _component_factory( elif data['type'] in (3, 5, 6, 7, 8): return SelectMenu(data) elif data['type'] == 9: - return SectionComponent(data) + return SectionComponent(data, state) elif data['type'] == 10: return TextDisplay(data) + elif data['type'] == 11: + return ThumbnailComponent(data, state) elif data['type'] == 12: return MediaGalleryComponent(data, state) elif data['type'] == 13: diff --git a/discord/http.py b/discord/http.py index 6617efa2708b..58b50172234e 100644 --- a/discord/http.py +++ b/discord/http.py @@ -193,6 +193,12 @@ def handle_message_parameters( if view is not MISSING: if view is not None: payload['components'] = view.to_components() + + if view.has_components_v2(): + if flags is not MISSING: + flags.components_v2 = True + else: + flags = MessageFlags(components_v2=True) else: payload['components'] = [] diff --git a/discord/types/components.py b/discord/types/components.py index cffb67ead3be..a50cbdd1ec44 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -184,5 +184,6 @@ class ContainerComponent(ComponentBase): SectionComponent, ContainerComponent, SeparatorComponent, + ThumbnailComponent, ] Component = Union[ActionRowChildComponent, ContainerChildComponent] diff --git a/discord/ui/container.py b/discord/ui/container.py index 6792c188f089..4bd68b724496 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -23,23 +23,32 @@ """ from __future__ import annotations -from typing import TYPE_CHECKING, List, Optional +from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, TypeVar + +from .item import Item +from ..enums import ComponentType if TYPE_CHECKING: - from ..components import Component + from typing_extensions import Self + + from .view import View + from ..colour import Colour, Color + from ..components import Container as ContainerComponent + +V = TypeVar('V', bound='View', covariant=True) __all__ = ('Container',) -class Container: +class Container(Item[V]): """Represents a Components V2 Container. .. versionadded:: 2.6 Parameters ---------- - children: List[:class:`Item`] + children: List[:class:`Item`] The initial children of this container. accent_colour: Optional[:class:`~discord.Colour`] The colour of the container. Defaults to ``None``. @@ -48,29 +57,31 @@ class Container: spoiler: :class:`bool` Whether to flag this container as a spoiler. Defaults to ``False``. + timeout: Optional[:class:`float`] + The timeout to set to this container items. Defaults to ``180``. """ __discord_ui_container__ = True def __init__( self, - children: List[Component], + children: List[Item[Any]], *, accent_colour: Optional[Colour] = None, accent_color: Optional[Color] = None, spoiler: bool = False, ) -> None: - self._children: List[Component] = children + self._children: List[Item[Any]] = children self.spoiler: bool = spoiler self._colour = accent_colour or accent_color @property - def children(self) -> List[Component]: - """List[:class:`~discord.Component`]: The children of this container.""" + def children(self) -> List[Item[Any]]: + """List[:class:`Item`]: The children of this container.""" return self._children.copy() @children.setter - def children(self, value: List[Component]) -> None: + def children(self, value: List[Item[Any]]) -> None: self._children = value @property @@ -84,3 +95,29 @@ def accent_colour(self, value: Optional[Colour]) -> None: accent_color = accent_colour """Optional[:class:`~discord.Color`]: The color of the container, or ``None``.""" + + @property + def type(self) -> Literal[ComponentType.container]: + return ComponentType.container + + def _is_v2(self) -> bool: + return True + + def to_component_dict(self) -> Dict[str, Any]: + base = { + 'type': self.type.value, + 'spoiler': self.spoiler, + 'components': [c.to_component_dict() for c in self._children] + } + if self._colour is not None: + base['accent_color'] = self._colour.value + return base + + @classmethod + def from_component(cls, component: ContainerComponent) -> Self: + from .view import _component_to_item + return cls( + children=[_component_to_item(c) for c in component.children], + accent_colour=component.accent_colour, + spoiler=component.spoiler, + ) diff --git a/discord/ui/item.py b/discord/ui/item.py index 1ee5492836b5..2d2a3aaa6f88 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -80,6 +80,9 @@ def _refresh_component(self, component: Component) -> None: def _refresh_state(self, interaction: Interaction, data: Dict[str, Any]) -> None: return None + def _is_v2(self) -> bool: + return False + @classmethod def from_component(cls: Type[I], component: Component) -> I: return cls() diff --git a/discord/ui/section.py b/discord/ui/section.py new file mode 100644 index 000000000000..81a0e4ba4fc0 --- /dev/null +++ b/discord/ui/section.py @@ -0,0 +1,151 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, TypeVar, Union + +from .item import Item +from .text_display import TextDisplay +from ..enums import ComponentType + +if TYPE_CHECKING: + from typing_extensions import Self + + from .view import View + from ..components import SectionComponent + +V = TypeVar('V', bound='View', covariant=True) + + +class Section(Item[V]): + """Represents a UI section. + + .. versionadded:: 2.6 + + Parameters + ---------- + children: List[Union[:class:`str`, :class:`TextDisplay`]] + The text displays of this section. Up to 3. + accessory: Optional[:class:`Item`] + The section accessory. Defaults to ``None``. + """ + + __slots__ = ( + '_children', + 'accessory', + ) + + def __init__( + self, + children: List[Union[TextDisplay[Any], str]], + *, + accessory: Optional[Item[Any]] = None, + ) -> None: + if len(children) > 3: + raise ValueError('maximum number of children exceeded') + self._children: List[TextDisplay[Any]] = [ + c if isinstance(c, TextDisplay) else TextDisplay(c) for c in children + ] + self.accessory: Optional[Item[Any]] = accessory + + @property + def type(self) -> Literal[ComponentType.section]: + return ComponentType.section + + def _is_v2(self) -> bool: + return True + + def add_item(self, item: Union[str, TextDisplay[Any]]) -> Self: + """Adds an item to this section. + + This function returns the class instance to allow for fluent-style + chaining. + + Parameters + ---------- + item: Union[:class:`str`, :class:`TextDisplay`] + The text display to add. + + Raises + ------ + TypeError + A :class:`TextDisplay` was not passed. + ValueError + Maximum number of children has been exceeded (3). + """ + + if len(self._children) >= 3: + raise ValueError('maximum number of children exceeded') + + if not isinstance(item, (TextDisplay, str)): + raise TypeError(f'expected TextDisplay or str not {item.__class__.__name__}') + + self._children.append( + item if isinstance(item, TextDisplay) else TextDisplay(item), + ) + return self + + def remove_item(self, item: TextDisplay[Any]) -> Self: + """Removes an item from this section. + + This function returns the class instance to allow for fluent-style + chaining. + + Parameters + ---------- + item: :class:`TextDisplay` + The item to remove from the section. + """ + + try: + self._children.remove(item) + except ValueError: + pass + return self + + def clear_items(self) -> Self: + """Removes all the items from the section. + + This function returns the class instance to allow for fluent-style + chaining. + """ + self._children.clear() + return self + + @classmethod + def from_component(cls, component: SectionComponent) -> Self: + from .view import _component_to_item # >circular import< + return cls( + children=[_component_to_item(c) for c in component.components], + accessory=_component_to_item(component.accessory) if component.accessory else None, + ) + + def to_component_dict(self) -> Dict[str, Any]: + data = { + 'components': [c.to_component_dict() for c in self._children], + 'type': self.type.value, + } + if self.accessory: + data['accessory'] = self.accessory.to_component_dict() + return data diff --git a/discord/ui/thumbnail.py b/discord/ui/thumbnail.py new file mode 100644 index 000000000000..a984a1892f9c --- /dev/null +++ b/discord/ui/thumbnail.py @@ -0,0 +1,86 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, TypeVar + +from .item import Item +from ..enums import ComponentType + +if TYPE_CHECKING: + from typing_extensions import Self + + from .view import View + from ..components import ThumbnailComponent + +V = TypeVar('V', bound='View', covariant=True) + +__all__ = ( + 'Thumbnail', +) + +class Thumbnail(Item[V]): + """Represents a UI Thumbnail. + + .. versionadded:: 2.6 + + Parameters + ---------- + url: :class:`str` + The URL of the thumbnail. This can only point to a local attachment uploaded + within this item. URLs must match the ``attachment://file-name.extension`` + structure. + description: Optional[:class:`str`] + The description of this thumbnail. Defaults to ``None``. + spoiler: :class:`bool` + Whether to flag this thumbnail as a spoiler. Defaults to ``False``. + """ + + def __init__(self, url: str, *, description: Optional[str] = None, spoiler: bool = False) -> None: + self.url: str = url + self.description: Optional[str] = description + self.spoiler: bool = spoiler + + @property + def type(self) -> Literal[ComponentType.thumbnail]: + return ComponentType.thumbnail + + def _is_v2(self) -> bool: + return True + + def to_component_dict(self) -> Dict[str, Any]: + return { + 'type': self.type.value, + 'spoiler': self.spoiler, + 'media': {'url': self.url}, + 'description': self.description, + } + + @classmethod + def from_component(cls, component: ThumbnailComponent) -> Self: + return cls( + url=component.media.url, + description=component.description, + spoiler=component.spoiler, + ) diff --git a/discord/ui/view.py b/discord/ui/view.py index b6262cf22b1c..4abac51161f6 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -41,7 +41,7 @@ Button as ButtonComponent, SelectMenu as SelectComponent, SectionComponent, - TextDisplay, + TextDisplay as TextDisplayComponent, MediaGalleryComponent, FileComponent, SeparatorComponent, @@ -67,7 +67,6 @@ _log = logging.getLogger(__name__) -V2_COMPONENTS = (SectionComponent, TextDisplay, MediaGalleryComponent, FileComponent, SeparatorComponent) def _walk_all_components(components: List[Component]) -> Iterator[Component]: @@ -87,8 +86,7 @@ def _component_to_item(component: Component) -> Item: from .select import BaseSelect return BaseSelect.from_component(component) - if isinstance(component, V2_COMPONENTS): - return component + # TODO: convert V2 Components into Item's return Item.from_component(component) @@ -97,11 +95,13 @@ class _ViewWeights: # fmt: off __slots__ = ( 'weights', + 'max_weight', ) # fmt: on - def __init__(self, children: List[Item]): + def __init__(self, children: List[Item], container: bool): self.weights: List[int] = [0, 0, 0, 0, 0] + self.max_weight: int = 5 if container is False else 10 key = lambda i: sys.maxsize if i.row is None else i.row children = sorted(children, key=key) @@ -111,7 +111,7 @@ def __init__(self, children: List[Item]): def find_open_space(self, item: Item) -> int: for index, weight in enumerate(self.weights): - if weight + item.width <= 5: + if weight + item.width <= self.max_weight: return index raise ValueError('could not find open space for item') @@ -119,8 +119,8 @@ def find_open_space(self, item: Item) -> int: def add_item(self, item: Item) -> None: if item.row is not None: total = self.weights[item.row] + item.width - if total > 5: - raise ValueError(f'item would not fit at row {item.row} ({total} > 5 width)') + if total > 10: + raise ValueError(f'item would not fit at row {item.row} ({total} > {self.max_weight} width)') self.weights[item.row] = total item._rendered_row = item.row else: @@ -195,7 +195,7 @@ def _init_children(self) -> List[Item[Self]]: def __init__(self, *, timeout: Optional[float] = 180.0): self.__timeout = timeout self._children: List[Item[Self]] = self._init_children() - self.__weights = _ViewWeights(self._children) + self.__weights = _ViewWeights(self._children, self.__discord_ui_container__) self.id: str = os.urandom(16).hex() self._cache_key: Optional[int] = None self.__cancel_callback: Optional[Callable[[View], None]] = None @@ -228,23 +228,32 @@ def is_dispatchable(self) -> bool: # or not, this simply is, whether a view has a component other than a url button return any(item.is_dispatchable() for item in self.children) - def to_components(self) -> List[Dict[str, Any]]: - def key(item: Item) -> int: - return item._rendered_row or 0 + def has_components_v2(self) -> bool: + return any(c._is_v2() for c in self.children) - children = sorted(self._children, key=key) + def to_components(self) -> List[Dict[str, Any]]: components: List[Dict[str, Any]] = [] - for _, group in groupby(children, key=key): - children = [item.to_component_dict() for item in group] - if not children: - continue + rows_index: Dict[int, int] = {} + # helper mapping to find action rows for items that are not + # v2 components - components.append( - { - 'type': 1, - 'components': children, - } - ) + for child in self._children: + if child._is_v2(): + components.append(child.to_component_dict()) + else: + row = child._rendered_row or 0 + index = rows_index.get(row) + + if index is not None: + components[index]['components'].append(child) + else: + components.append( + { + 'type': 1, + 'components': [child.to_component_dict()], + }, + ) + rows_index[row] = len(components) - 1 return components From 86897182ba406936470467230c7db5cc93e9635d Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 1 Mar 2025 13:48:40 +0100 Subject: [PATCH 006/272] chore: more things to components v2 --- discord/http.py | 2 +- discord/ui/container.py | 46 ++++++++++++++++++-------------- discord/ui/section.py | 16 ++++++------ discord/ui/view.py | 55 +++++++++++++++++++++++++++++++-------- discord/webhook/async_.py | 7 +++++ 5 files changed, 86 insertions(+), 40 deletions(-) diff --git a/discord/http.py b/discord/http.py index 58b50172234e..d8eedeb2e296 100644 --- a/discord/http.py +++ b/discord/http.py @@ -57,6 +57,7 @@ from .mentions import AllowedMentions from . import __version__, utils from .utils import MISSING +from .flags import MessageFlags _log = logging.getLogger(__name__) @@ -66,7 +67,6 @@ from .ui.view import View from .embeds import Embed from .message import Attachment - from .flags import MessageFlags from .poll import Poll from .types import ( diff --git a/discord/ui/container.py b/discord/ui/container.py index 4bd68b724496..a2ca83a25390 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -23,16 +23,16 @@ """ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, TypeVar +import sys +from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, TypeVar, Union from .item import Item +from .view import View, _component_to_item from ..enums import ComponentType if TYPE_CHECKING: from typing_extensions import Self - from .view import View - from ..colour import Colour, Color from ..components import Container as ContainerComponent @@ -41,15 +41,16 @@ __all__ = ('Container',) -class Container(Item[V]): +class Container(View, Item[V]): """Represents a Components V2 Container. .. versionadded:: 2.6 Parameters ---------- - children: List[:class:`Item`] - The initial children of this container. + children: List[Union[:class:`Item`, :class:`View`]] + The initial children or :class:`View`s of this container. Can have up to 10 + items. accent_colour: Optional[:class:`~discord.Colour`] The colour of the container. Defaults to ``None``. accent_color: Optional[:class:`~discord.Color`] @@ -57,31 +58,34 @@ class Container(Item[V]): spoiler: :class:`bool` Whether to flag this container as a spoiler. Defaults to ``False``. - timeout: Optional[:class:`float`] - The timeout to set to this container items. Defaults to ``180``. """ __discord_ui_container__ = True def __init__( self, - children: List[Item[Any]], + children: List[Union[Item[Any], View]], *, accent_colour: Optional[Colour] = None, accent_color: Optional[Color] = None, spoiler: bool = False, + timeout: Optional[float] = 180, ) -> None: - self._children: List[Item[Any]] = children + if len(children) > 10: + raise ValueError('maximum number of components exceeded') + self._children: List[Union[Item[Any], View]] = children self.spoiler: bool = spoiler self._colour = accent_colour or accent_color + super().__init__(timeout=timeout) + @property - def children(self) -> List[Item[Any]]: + def children(self) -> List[Union[Item[Any], View]]: """List[:class:`Item`]: The children of this container.""" return self._children.copy() @children.setter - def children(self, value: List[Item[Any]]) -> None: + def children(self, value: List[Union[Item[Any], View]]) -> None: self._children = value @property @@ -100,22 +104,24 @@ def accent_colour(self, value: Optional[Colour]) -> None: def type(self) -> Literal[ComponentType.container]: return ComponentType.container + @property + def _views(self) -> List[View]: + return [c for c in self._children if isinstance(c, View)] + def _is_v2(self) -> bool: return True - def to_component_dict(self) -> Dict[str, Any]: - base = { + def to_components(self) -> List[Dict[str, Any]]: + components = super().to_components() + return [{ 'type': self.type.value, + 'accent_color': self._colour.value if self._colour else None, 'spoiler': self.spoiler, - 'components': [c.to_component_dict() for c in self._children] - } - if self._colour is not None: - base['accent_color'] = self._colour.value - return base + 'components': components, + }] @classmethod def from_component(cls, component: ContainerComponent) -> Self: - from .view import _component_to_item return cls( children=[_component_to_item(c) for c in component.children], accent_colour=component.accent_colour, diff --git a/discord/ui/section.py b/discord/ui/section.py index 81a0e4ba4fc0..5176d761bdb2 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -58,14 +58,14 @@ class Section(Item[V]): def __init__( self, - children: List[Union[TextDisplay[Any], str]], + children: List[Union[Item[Any], str]], *, accessory: Optional[Item[Any]] = None, ) -> None: if len(children) > 3: raise ValueError('maximum number of children exceeded') - self._children: List[TextDisplay[Any]] = [ - c if isinstance(c, TextDisplay) else TextDisplay(c) for c in children + self._children: List[Item[Any]] = [ + c if isinstance(c, Item) else TextDisplay(c) for c in children ] self.accessory: Optional[Item[Any]] = accessory @@ -76,7 +76,7 @@ def type(self) -> Literal[ComponentType.section]: def _is_v2(self) -> bool: return True - def add_item(self, item: Union[str, TextDisplay[Any]]) -> Self: + def add_item(self, item: Union[str, Item[Any]]) -> Self: """Adds an item to this section. This function returns the class instance to allow for fluent-style @@ -98,15 +98,15 @@ def add_item(self, item: Union[str, TextDisplay[Any]]) -> Self: if len(self._children) >= 3: raise ValueError('maximum number of children exceeded') - if not isinstance(item, (TextDisplay, str)): - raise TypeError(f'expected TextDisplay or str not {item.__class__.__name__}') + if not isinstance(item, (Item, str)): + raise TypeError(f'expected Item or str not {item.__class__.__name__}') self._children.append( - item if isinstance(item, TextDisplay) else TextDisplay(item), + item if isinstance(item, Item) else TextDisplay(item), ) return self - def remove_item(self, item: TextDisplay[Any]) -> Self: + def remove_item(self, item: Item[Any]) -> Self: """Removes an item from this section. This function returns the class instance to allow for fluent-style diff --git a/discord/ui/view.py b/discord/ui/view.py index 4abac51161f6..19bc3f33be20 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -23,7 +23,7 @@ """ from __future__ import annotations -from typing import Any, Callable, ClassVar, Coroutine, Dict, Iterator, List, Optional, Sequence, TYPE_CHECKING, Tuple, Type +from typing import Any, Callable, ClassVar, Coroutine, Dict, Iterator, List, Optional, Sequence, TYPE_CHECKING, Tuple, Type, Union from functools import partial from itertools import groupby @@ -95,13 +95,11 @@ class _ViewWeights: # fmt: off __slots__ = ( 'weights', - 'max_weight', ) # fmt: on - def __init__(self, children: List[Item], container: bool): + def __init__(self, children: List[Item]): self.weights: List[int] = [0, 0, 0, 0, 0] - self.max_weight: int = 5 if container is False else 10 key = lambda i: sys.maxsize if i.row is None else i.row children = sorted(children, key=key) @@ -109,18 +107,26 @@ def __init__(self, children: List[Item], container: bool): for item in group: self.add_item(item) - def find_open_space(self, item: Item) -> int: + def find_open_space(self, item: Union[Item, View]) -> int: for index, weight in enumerate(self.weights): - if weight + item.width <= self.max_weight: + if weight + item.width <= 5: return index raise ValueError('could not find open space for item') - def add_item(self, item: Item) -> None: + def add_item(self, item: Union[Item, View]) -> None: + if hasattr(item, '__discord_ui_container__') and item.__discord_ui_container__ is True: + raise TypeError( + 'containers cannot be added to views' + ) + + if item._is_v2() and not self.v2_weights(): + # v2 components allow up to 10 rows + self.weights.extend([0, 0, 0, 0, 0]) if item.row is not None: total = self.weights[item.row] + item.width if total > 10: - raise ValueError(f'item would not fit at row {item.row} ({total} > {self.max_weight} width)') + raise ValueError(f'item would not fit at row {item.row} ({total} > 5 width)') self.weights[item.row] = total item._rendered_row = item.row else: @@ -128,7 +134,7 @@ def add_item(self, item: Item) -> None: self.weights[index] += item.width item._rendered_row = index - def remove_item(self, item: Item) -> None: + def remove_item(self, item: Union[Item, View]) -> None: if item._rendered_row is not None: self.weights[item._rendered_row] -= item.width item._rendered_row = None @@ -136,6 +142,9 @@ def remove_item(self, item: Item) -> None: def clear(self) -> None: self.weights = [0, 0, 0, 0, 0] + def v2_weights(self) -> bool: + return sum(1 if w > 0 else 0 for w in self.weights) > 5 + class _ViewCallback: __slots__ = ('view', 'callback', 'item') @@ -176,6 +185,8 @@ def __init_subclass__(cls) -> None: for name, member in base.__dict__.items(): if hasattr(member, '__discord_ui_model_type__'): children[name] = member + if cls.__discord_ui_container__ and isinstance(member, View): + children[name] = member if len(children) > 25: raise TypeError('View cannot have more than 25 children') @@ -192,16 +203,25 @@ def _init_children(self) -> List[Item[Self]]: children.append(item) return children - def __init__(self, *, timeout: Optional[float] = 180.0): + def __init__(self, *, timeout: Optional[float] = 180.0, row: Optional[int] = None): self.__timeout = timeout self._children: List[Item[Self]] = self._init_children() - self.__weights = _ViewWeights(self._children, self.__discord_ui_container__) + self.__weights = _ViewWeights(self._children) self.id: str = os.urandom(16).hex() self._cache_key: Optional[int] = None self.__cancel_callback: Optional[Callable[[View], None]] = None self.__timeout_expiry: Optional[float] = None self.__timeout_task: Optional[asyncio.Task[None]] = None self.__stopped: asyncio.Future[bool] = asyncio.get_running_loop().create_future() + self.row: Optional[int] = row + self._rendered_row: Optional[int] = None + + def _is_v2(self) -> bool: + return False + + @property + def width(self): + return 5 def __repr__(self) -> str: return f'<{self.__class__.__name__} timeout={self.timeout} children={len(self._children)}>' @@ -602,6 +622,19 @@ def add_view(self, view: View, message_id: Optional[int] = None) -> None: dispatch_info[(item.type.value, item.custom_id)] = item # type: ignore is_fully_dynamic = False + # components V2 containers allow for views to exist inside them + # with dispatchable items, so we iterate over it and add it + # to the store + if hasattr(view, '_views'): + for v in view._views: + for item in v._children: + if isinstance(item, DynamicItem): + pattern = item.__discord_ui_compiled_template__ + self._dynamic_items[pattern] = item.__class__ + elif item.is_dispatchable(): + dispatch_info[(item.type.value, item.custom_id)] = item + is_fully_dynamic = False + view._cache_key = message_id if message_id is not None and not is_fully_dynamic: self._synced_message_views[message_id] = view diff --git a/discord/webhook/async_.py b/discord/webhook/async_.py index 3b62b10faa2c..f1cfb573bb71 100644 --- a/discord/webhook/async_.py +++ b/discord/webhook/async_.py @@ -592,6 +592,13 @@ def interaction_message_response_params( if view is not MISSING: if view is not None: data['components'] = view.to_components() + + if view.has_components_v2(): + if flags is not MISSING: + flags.components_v2 = True + else: + flags = MessageFlags(components_v2=True) + else: data['components'] = [] From 76e202811831641aa0c8735686a435d1c842794a Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 1 Mar 2025 18:44:49 +0100 Subject: [PATCH 007/272] chore: More v2 components on UI and idk some changes --- discord/abc.py | 13 ++++++- discord/http.py | 2 ++ discord/ui/button.py | 18 +++++----- discord/ui/container.py | 59 +++++++++++++++++++++++-------- discord/ui/section.py | 5 +++ discord/ui/text_display.py | 71 ++++++++++++++++++++++++++++++++++++++ discord/ui/view.py | 53 ++++++++++++---------------- 7 files changed, 167 insertions(+), 54 deletions(-) create mode 100644 discord/ui/text_display.py diff --git a/discord/abc.py b/discord/abc.py index 70531fb2005e..1380b30488fb 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -1389,6 +1389,7 @@ async def send( reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., view: View = ..., + views: Sequence[View] = ..., suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., @@ -1410,6 +1411,7 @@ async def send( reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., view: View = ..., + views: Sequence[View] = ..., suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., @@ -1431,6 +1433,7 @@ async def send( reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., view: View = ..., + views: Sequence[View] = ..., suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., @@ -1452,6 +1455,7 @@ async def send( reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., view: View = ..., + views: Sequence[View] = ..., suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., @@ -1474,6 +1478,7 @@ async def send( reference: Optional[Union[Message, MessageReference, PartialMessage]] = None, mention_author: Optional[bool] = None, view: Optional[View] = None, + views: Optional[Sequence[View]] = None, suppress_embeds: bool = False, silent: bool = False, poll: Optional[Poll] = None, @@ -1550,6 +1555,10 @@ async def send( A Discord UI View to add to the message. .. versionadded:: 2.0 + views: Sequence[:class:`discord.ui.View`] + A sequence of Discord UI Views to add to the message. + + .. versionadded:: 2.6 stickers: Sequence[Union[:class:`~discord.GuildSticker`, :class:`~discord.StickerItem`]] A list of stickers to upload. Must be a maximum of 3. @@ -1580,7 +1589,8 @@ async def send( You specified both ``file`` and ``files``, or you specified both ``embed`` and ``embeds``, or the ``reference`` object is not a :class:`~discord.Message`, - :class:`~discord.MessageReference` or :class:`~discord.PartialMessage`. + :class:`~discord.MessageReference` or :class:`~discord.PartialMessage`, + or you specified both ``view`` and ``views``. Returns --------- @@ -1635,6 +1645,7 @@ async def send( mention_author=mention_author, stickers=sticker_ids, view=view, + views=views if views is not None else MISSING, flags=flags, poll=poll, ) as params: diff --git a/discord/http.py b/discord/http.py index d8eedeb2e296..c6e4d1377277 100644 --- a/discord/http.py +++ b/discord/http.py @@ -192,6 +192,8 @@ def handle_message_parameters( if view is not MISSING: if view is not None: + if getattr(view, '__discord_ui_container__', False): + raise TypeError('Containers must be wrapped around Views') payload['components'] = view.to_components() if view.has_components_v2(): diff --git a/discord/ui/button.py b/discord/ui/button.py index 43bd3a8b0f9d..b4df36aed8a9 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -73,10 +73,11 @@ class Button(Item[V]): The emoji of the button, if available. row: Optional[:class:`int`] The relative row this button 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). + rows in a :class:`View`, but up to 10 on a :class:`Container`. By default, + items are arranged automatically into those 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 or 9 (i.e. zero indexed). sku_id: Optional[:class:`int`] The SKU ID this button sends you to. Can't be combined with ``url``, ``label``, ``emoji`` nor ``custom_id``. @@ -304,10 +305,11 @@ def button( or a full :class:`.Emoji`. row: Optional[:class:`int`] The relative row this button 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). + rows in a :class:`View`, but up to 10 on a :class:`Container`. By default, + items are arranged automatically into those 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 or 9 (i.e. zero indexed). """ def decorator(func: ItemCallbackType[V, Button[V]]) -> ItemCallbackType[V, Button[V]]: diff --git a/discord/ui/container.py b/discord/ui/container.py index a2ca83a25390..a98b0d965ab5 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -23,11 +23,11 @@ """ from __future__ import annotations -import sys -from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, TypeVar, Union +from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Type, TypeVar from .item import Item from .view import View, _component_to_item +from .dynamic import DynamicItem from ..enums import ComponentType if TYPE_CHECKING: @@ -48,7 +48,7 @@ class Container(View, Item[V]): Parameters ---------- - children: List[Union[:class:`Item`, :class:`View`]] + children: List[:class:`Item`] The initial children or :class:`View`s of this container. Can have up to 10 items. accent_colour: Optional[:class:`~discord.Colour`] @@ -58,34 +58,47 @@ class Container(View, Item[V]): spoiler: :class:`bool` Whether to flag this container as a spoiler. Defaults to ``False``. + timeout: Optional[:class:`float`] + Timeout in seconds from last interaction with the UI before no longer accepting input. + If ``None`` then there is no timeout. """ __discord_ui_container__ = True def __init__( self, - children: List[Union[Item[Any], View]], + children: List[Item[Any]], *, accent_colour: Optional[Colour] = None, accent_color: Optional[Color] = None, spoiler: bool = False, timeout: Optional[float] = 180, + row: Optional[int] = None, ) -> None: - if len(children) > 10: + super().__init__(timeout=timeout) + if len(children) + len(self._children) > 10: raise ValueError('maximum number of components exceeded') - self._children: List[Union[Item[Any], View]] = children + self._children.extend(children) self.spoiler: bool = spoiler self._colour = accent_colour or accent_color - super().__init__(timeout=timeout) + self._view: Optional[V] = None + self._row: Optional[int] = None + self._rendered_row: Optional[int] = None + self.row: Optional[int] = row + + def _init_children(self) -> List[Item[Self]]: + if self.__weights.max_weight != 10: + self.__weights.max_weight = 10 + return super()._init_children() @property - def children(self) -> List[Union[Item[Any], View]]: + def children(self) -> List[Item[Self]]: """List[:class:`Item`]: The children of this container.""" return self._children.copy() @children.setter - def children(self, value: List[Union[Item[Any], View]]) -> None: + def children(self, value: List[Item[Any]]) -> None: self._children = value @property @@ -105,20 +118,38 @@ def type(self) -> Literal[ComponentType.container]: return ComponentType.container @property - def _views(self) -> List[View]: - return [c for c in self._children if isinstance(c, View)] + def width(self): + return 5 def _is_v2(self) -> bool: return True - def to_components(self) -> List[Dict[str, Any]]: + def is_dispatchable(self) -> bool: + return any(c.is_dispatchable() for c in self.children) + + def to_component_dict(self) -> Dict[str, Any]: components = super().to_components() - return [{ + return { 'type': self.type.value, 'accent_color': self._colour.value if self._colour else None, 'spoiler': self.spoiler, 'components': components, - }] + } + + def _update_store_data( + self, + dispatch_info: Dict[Tuple[int, str], Item[Any]], + dynamic_items: Dict[Any, Type[DynamicItem]], + ) -> bool: + is_fully_dynamic = True + for item in self._children: + if isinstance(item, DynamicItem): + pattern = item.__discord_ui_compiled_template__ + dynamic_items[pattern] = item.__class__ + elif item.is_dispatchable(): + dispatch_info[(item.type.value, item.custom_id)] = item # type: ignore + is_fully_dynamic = False + return is_fully_dynamic @classmethod def from_component(cls, component: ContainerComponent) -> Self: diff --git a/discord/ui/section.py b/discord/ui/section.py index 5176d761bdb2..0012d0118186 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -62,6 +62,7 @@ def __init__( *, accessory: Optional[Item[Any]] = None, ) -> None: + super().__init__() if len(children) > 3: raise ValueError('maximum number of children exceeded') self._children: List[Item[Any]] = [ @@ -73,6 +74,10 @@ def __init__( def type(self) -> Literal[ComponentType.section]: return ComponentType.section + @property + def width(self): + return 5 + def _is_v2(self) -> bool: return True diff --git a/discord/ui/text_display.py b/discord/ui/text_display.py new file mode 100644 index 000000000000..0daff9c89e5c --- /dev/null +++ b/discord/ui/text_display.py @@ -0,0 +1,71 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal, TypeVar + +from .item import Item +from ..components import TextDisplay as TextDisplayComponent +from ..enums import ComponentType + +if TYPE_CHECKING: + from .view import View + +V = TypeVar('V', bound='View', covariant=True) + +__all__ = ('TextDisplay',) + + +class TextDisplay(Item[V]): + """Represents a UI text display. + + .. versionadded:: 2.6 + + Parameters + ---------- + content: :class:`str` + The content of this text display. + """ + + def __init__(self, content: str) -> None: + super().__init__() + self.content: str = content + + self._underlying = TextDisplayComponent._raw_construct( + content=content, + ) + + def to_component_dict(self): + return self._underlying.to_dict() + + @property + def width(self): + return 5 + + @property + def type(self) -> Literal[ComponentType.text_display]: + return self._underlying.type + + def _is_v2(self) -> bool: + return True diff --git a/discord/ui/view.py b/discord/ui/view.py index 19bc3f33be20..4afcd9fad0ce 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -95,11 +95,13 @@ class _ViewWeights: # fmt: off __slots__ = ( 'weights', + 'max_weight', ) # fmt: on def __init__(self, children: List[Item]): self.weights: List[int] = [0, 0, 0, 0, 0] + self.max_weight: int = 5 key = lambda i: sys.maxsize if i.row is None else i.row children = sorted(children, key=key) @@ -107,26 +109,21 @@ def __init__(self, children: List[Item]): for item in group: self.add_item(item) - def find_open_space(self, item: Union[Item, View]) -> int: + def find_open_space(self, item: Item) -> int: for index, weight in enumerate(self.weights): if weight + item.width <= 5: return index raise ValueError('could not find open space for item') - def add_item(self, item: Union[Item, View]) -> None: - if hasattr(item, '__discord_ui_container__') and item.__discord_ui_container__ is True: - raise TypeError( - 'containers cannot be added to views' - ) - + def add_item(self, item: Item) -> None: if item._is_v2() and not self.v2_weights(): # v2 components allow up to 10 rows self.weights.extend([0, 0, 0, 0, 0]) if item.row is not None: total = self.weights[item.row] + item.width - if total > 10: - raise ValueError(f'item would not fit at row {item.row} ({total} > 5 width)') + if total > self.max_weight: + raise ValueError(f'item would not fit at row {item.row} ({total} > {self.max_weight} width)') self.weights[item.row] = total item._rendered_row = item.row else: @@ -134,7 +131,7 @@ def add_item(self, item: Union[Item, View]) -> None: self.weights[index] += item.width item._rendered_row = index - def remove_item(self, item: Union[Item, View]) -> None: + def remove_item(self, item: Item) -> None: if item._rendered_row is not None: self.weights[item._rendered_row] -= item.width item._rendered_row = None @@ -185,8 +182,6 @@ def __init_subclass__(cls) -> None: for name, member in base.__dict__.items(): if hasattr(member, '__discord_ui_model_type__'): children[name] = member - if cls.__discord_ui_container__ and isinstance(member, View): - children[name] = member if len(children) > 25: raise TypeError('View cannot have more than 25 children') @@ -203,7 +198,7 @@ def _init_children(self) -> List[Item[Self]]: children.append(item) return children - def __init__(self, *, timeout: Optional[float] = 180.0, row: Optional[int] = None): + def __init__(self, *, timeout: Optional[float] = 180.0): self.__timeout = timeout self._children: List[Item[Self]] = self._init_children() self.__weights = _ViewWeights(self._children) @@ -213,8 +208,6 @@ def __init__(self, *, timeout: Optional[float] = 180.0, row: Optional[int] = Non self.__timeout_expiry: Optional[float] = None self.__timeout_task: Optional[asyncio.Task[None]] = None self.__stopped: asyncio.Future[bool] = asyncio.get_running_loop().create_future() - self.row: Optional[int] = row - self._rendered_row: Optional[int] = None def _is_v2(self) -> bool: return False @@ -257,7 +250,12 @@ def to_components(self) -> List[Dict[str, Any]]: # helper mapping to find action rows for items that are not # v2 components - for child in self._children: + def key(item: Item) -> int: + return item._rendered_row or 0 + + # instead of grouping by row we will sort it so it is added + # in order and should work as the original implementation + for child in sorted(self._children, key=key): if child._is_v2(): components.append(child.to_component_dict()) else: @@ -619,21 +617,14 @@ def add_view(self, view: View, message_id: Optional[int] = None) -> None: pattern = item.__discord_ui_compiled_template__ self._dynamic_items[pattern] = item.__class__ elif item.is_dispatchable(): - dispatch_info[(item.type.value, item.custom_id)] = item # type: ignore - is_fully_dynamic = False - - # components V2 containers allow for views to exist inside them - # with dispatchable items, so we iterate over it and add it - # to the store - if hasattr(view, '_views'): - for v in view._views: - for item in v._children: - if isinstance(item, DynamicItem): - pattern = item.__discord_ui_compiled_template__ - self._dynamic_items[pattern] = item.__class__ - elif item.is_dispatchable(): - dispatch_info[(item.type.value, item.custom_id)] = item - is_fully_dynamic = False + if getattr(item, '__discord_ui_container__', False): + is_fully_dynamic = item._update_store_data( # type: ignore + dispatch_info, + self._dynamic_items, + ) + else: + dispatch_info[(item.type.value, item.custom_id)] = item # type: ignore + is_fully_dynamic = False view._cache_key = message_id if message_id is not None and not is_fully_dynamic: From b872925d9ffe2b375acc557dd55534ae0d143bba Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 2 Mar 2025 12:05:26 +0100 Subject: [PATCH 008/272] chore: Remove views things --- discord/abc.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/discord/abc.py b/discord/abc.py index 1380b30488fb..ae6a1fb15811 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -1389,7 +1389,6 @@ async def send( reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., view: View = ..., - views: Sequence[View] = ..., suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., @@ -1411,7 +1410,6 @@ async def send( reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., view: View = ..., - views: Sequence[View] = ..., suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., @@ -1433,7 +1431,6 @@ async def send( reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., view: View = ..., - views: Sequence[View] = ..., suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., @@ -1455,7 +1452,6 @@ async def send( reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., view: View = ..., - views: Sequence[View] = ..., suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., @@ -1478,7 +1474,6 @@ async def send( reference: Optional[Union[Message, MessageReference, PartialMessage]] = None, mention_author: Optional[bool] = None, view: Optional[View] = None, - views: Optional[Sequence[View]] = None, suppress_embeds: bool = False, silent: bool = False, poll: Optional[Poll] = None, @@ -1555,10 +1550,6 @@ async def send( A Discord UI View to add to the message. .. versionadded:: 2.0 - views: Sequence[:class:`discord.ui.View`] - A sequence of Discord UI Views to add to the message. - - .. versionadded:: 2.6 stickers: Sequence[Union[:class:`~discord.GuildSticker`, :class:`~discord.StickerItem`]] A list of stickers to upload. Must be a maximum of 3. @@ -1645,7 +1636,6 @@ async def send( mention_author=mention_author, stickers=sticker_ids, view=view, - views=views if views is not None else MISSING, flags=flags, poll=poll, ) as params: From 4467ebaca690c4d29d4cdc3d5177c1b52c1e8d8e Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 2 Mar 2025 12:10:00 +0100 Subject: [PATCH 009/272] chore: Donnot subclass AttachmentBase and fake payload for Attachment initialization on UnfurledAttachment --- discord/attachment.py | 221 ++++++++++++++++++++---------------------- 1 file changed, 104 insertions(+), 117 deletions(-) diff --git a/discord/attachment.py b/discord/attachment.py index 195ce30b5d3c..4b9765b99fd8 100644 --- a/discord/attachment.py +++ b/discord/attachment.py @@ -52,9 +52,87 @@ ) -class AttachmentBase: +class Attachment(Hashable): + """Represents an attachment from Discord. + + .. container:: operations + + .. describe:: str(x) + + Returns the URL of the attachment. + + .. describe:: x == y + + Checks if the attachment is equal to another attachment. + + .. describe:: x != y + + Checks if the attachment is not equal to another attachment. + + .. describe:: hash(x) + + Returns the hash of the attachment. + + .. versionchanged:: 1.7 + Attachment can now be casted to :class:`str` and is hashable. + + Attributes + ------------ + id: :class:`int` + The attachment ID. + size: :class:`int` + The attachment size in bytes. + height: Optional[:class:`int`] + The attachment's height, in pixels. Only applicable to images and videos. + width: Optional[:class:`int`] + The attachment's width, in pixels. Only applicable to images and videos. + filename: :class:`str` + The attachment's filename. + url: :class:`str` + The attachment URL. If the message this attachment was attached + to is deleted, then this will 404. + proxy_url: :class:`str` + The proxy URL. This is a cached version of the :attr:`~Attachment.url` in the + case of images. When the message is deleted, this URL might be valid for a few + minutes or not valid at all. + content_type: Optional[:class:`str`] + The attachment's `media type `_ + + .. versionadded:: 1.7 + description: Optional[:class:`str`] + The attachment's description. Only applicable to images. + + .. versionadded:: 2.0 + ephemeral: :class:`bool` + Whether the attachment is ephemeral. + + .. versionadded:: 2.0 + duration: Optional[:class:`float`] + The duration of the audio file in seconds. Returns ``None`` if it's not a voice message. + + .. versionadded:: 2.3 + waveform: Optional[:class:`bytes`] + The waveform (amplitudes) of the audio in bytes. Returns ``None`` if it's not a voice message. + + .. versionadded:: 2.3 + title: Optional[:class:`str`] + The normalised version of the attachment's filename. + + .. versionadded:: 2.5 + spoiler: :class:`bool` + Whether the attachment is a spoiler or not. Unlike :meth:`.is_spoiler`, this uses the API returned + data. + + .. versionadded:: 2.6 + """ __slots__ = ( + 'id', + 'size', + 'ephemeral', + 'duration', + 'waveform', + 'title', 'url', 'proxy_url', 'description', @@ -68,7 +146,13 @@ class AttachmentBase: '_state', ) - def __init__(self, data: AttachmentBasePayload, state: Optional[ConnectionState]) -> None: + def __init__(self, *, data: AttachmentPayload, state: Optional[ConnectionState]): + self.id: int = int(data['id']) + self.filename: str = data['filename'] + self.size: int = data['size'] + self.ephemeral: bool = data.get('ephemeral', False) + self.duration: Optional[float] = data.get('duration_secs') + self.title: Optional[str] = data.get('title') self._state: Optional[ConnectionState] = state self._http: Optional[HTTPClient] = state.http if state else None self.url: str = data['url'] @@ -248,11 +332,25 @@ async def to_file( spoiler=spoiler, ) - def to_dict(self) -> AttachmentBasePayload: - base: AttachmentBasePayload = { + def is_spoiler(self) -> bool: + """:class:`bool`: Whether this attachment contains a spoiler.""" + return self.spoiler or self.filename.startswith('SPOILER_') + + def is_voice_message(self) -> bool: + """:class:`bool`: Whether this attachment is a voice message.""" + return self.duration is not None and 'voice-message' in self.url + + def __repr__(self) -> str: + return f'' + + def to_dict(self) -> AttachmentPayload: + base: AttachmentPayload = { 'url': self.url, 'proxy_url': self.proxy_url, 'spoiler': self.spoiler, + 'id': self.id, + 'filename': self.filename, + 'size': self.size, } if self.width: @@ -265,118 +363,7 @@ def to_dict(self) -> AttachmentBasePayload: return base -class Attachment(Hashable, AttachmentBase): - """Represents an attachment from Discord. - - .. container:: operations - - .. describe:: str(x) - - Returns the URL of the attachment. - - .. describe:: x == y - - Checks if the attachment is equal to another attachment. - - .. describe:: x != y - - Checks if the attachment is not equal to another attachment. - - .. describe:: hash(x) - - Returns the hash of the attachment. - - .. versionchanged:: 1.7 - Attachment can now be casted to :class:`str` and is hashable. - - Attributes - ------------ - id: :class:`int` - The attachment ID. - size: :class:`int` - The attachment size in bytes. - height: Optional[:class:`int`] - The attachment's height, in pixels. Only applicable to images and videos. - width: Optional[:class:`int`] - The attachment's width, in pixels. Only applicable to images and videos. - filename: :class:`str` - The attachment's filename. - url: :class:`str` - The attachment URL. If the message this attachment was attached - to is deleted, then this will 404. - proxy_url: :class:`str` - The proxy URL. This is a cached version of the :attr:`~Attachment.url` in the - case of images. When the message is deleted, this URL might be valid for a few - minutes or not valid at all. - content_type: Optional[:class:`str`] - The attachment's `media type `_ - - .. versionadded:: 1.7 - description: Optional[:class:`str`] - The attachment's description. Only applicable to images. - - .. versionadded:: 2.0 - ephemeral: :class:`bool` - Whether the attachment is ephemeral. - - .. versionadded:: 2.0 - duration: Optional[:class:`float`] - The duration of the audio file in seconds. Returns ``None`` if it's not a voice message. - - .. versionadded:: 2.3 - waveform: Optional[:class:`bytes`] - The waveform (amplitudes) of the audio in bytes. Returns ``None`` if it's not a voice message. - - .. versionadded:: 2.3 - title: Optional[:class:`str`] - The normalised version of the attachment's filename. - - .. versionadded:: 2.5 - spoiler: :class:`bool` - Whether the attachment is a spoiler or not. Unlike :meth:`.is_spoiler`, this uses the API returned - data. - - .. versionadded:: 2.6 - """ - - __slots__ = ( - 'id', - 'size', - 'ephemeral', - 'duration', - 'waveform', - 'title', - ) - - def __init__(self, *, data: AttachmentPayload, state: ConnectionState): - self.id: int = int(data['id']) - self.filename: str = data['filename'] - self.size: int = data['size'] - self.ephemeral: bool = data.get('ephemeral', False) - self.duration: Optional[float] = data.get('duration_secs') - self.title: Optional[str] = data.get('title') - super().__init__(data, state) - - def is_spoiler(self) -> bool: - """:class:`bool`: Whether this attachment contains a spoiler.""" - return self.spoiler or self.filename.startswith('SPOILER_') - - def is_voice_message(self) -> bool: - """:class:`bool`: Whether this attachment is a voice message.""" - return self.duration is not None and 'voice-message' in self.url - - def __repr__(self) -> str: - return f'' - - def to_dict(self) -> AttachmentPayload: - result: AttachmentPayload = super().to_dict() # pyright: ignore[reportAssignmentType] - result['id'] = self.id - result['filename'] = self.filename - result['size'] = self.size - return result - - -class UnfurledAttachment(AttachmentBase): +class UnfurledAttachment(Attachment): """Represents an unfurled attachment item from a :class:`Component`. .. versionadded:: 2.6 @@ -425,7 +412,7 @@ class UnfurledAttachment(AttachmentBase): def __init__(self, data: UnfurledAttachmentPayload, state: Optional[ConnectionState]) -> None: self.loading_state: MediaLoadingState = try_enum(MediaLoadingState, data.get('loading_state', 0)) - super().__init__(data, state) + super().__init__(data={'id': 0, 'filename': '', 'size': 0, **data}, state=state) # type: ignore def __repr__(self) -> str: return f'' From 4aef97e24905d79f5184c1952660b3c3d43e3a31 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 2 Mar 2025 12:47:22 +0100 Subject: [PATCH 010/272] chore: undo attachment file move --- discord/attachment.py | 421 ---------------------------------------- discord/components.py | 116 +++++++++-- discord/enums.py | 4 +- discord/ui/thumbnail.py | 19 +- discord/ui/view.py | 3 + 5 files changed, 115 insertions(+), 448 deletions(-) delete mode 100644 discord/attachment.py diff --git a/discord/attachment.py b/discord/attachment.py deleted file mode 100644 index 4b9765b99fd8..000000000000 --- a/discord/attachment.py +++ /dev/null @@ -1,421 +0,0 @@ -""" -The MIT License (MIT) - -Copyright (c) 2015-present Rapptz - -Permission is hereby granted, free of charge, to any person obtaining a -copy of this software and associated documentation files (the 'Software'), -to deal in the Software without restriction, including without limitation -the rights to use, copy, modify, merge, publish, distribute, sublicense, -and/or sell copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. -""" -from __future__ import annotations - -import io -from os import PathLike -from typing import TYPE_CHECKING, Any, Optional, Union - -from .errors import ClientException -from .mixins import Hashable -from .file import File -from .flags import AttachmentFlags -from .enums import MediaLoadingState, try_enum -from . import utils - -if TYPE_CHECKING: - from .types.attachment import ( - AttachmentBase as AttachmentBasePayload, - Attachment as AttachmentPayload, - UnfurledAttachment as UnfurledAttachmentPayload, - ) - - from .http import HTTPClient - from .state import ConnectionState - -MISSING = utils.MISSING - -__all__ = ( - 'Attachment', - 'UnfurledAttachment', -) - - -class Attachment(Hashable): - """Represents an attachment from Discord. - - .. container:: operations - - .. describe:: str(x) - - Returns the URL of the attachment. - - .. describe:: x == y - - Checks if the attachment is equal to another attachment. - - .. describe:: x != y - - Checks if the attachment is not equal to another attachment. - - .. describe:: hash(x) - - Returns the hash of the attachment. - - .. versionchanged:: 1.7 - Attachment can now be casted to :class:`str` and is hashable. - - Attributes - ------------ - id: :class:`int` - The attachment ID. - size: :class:`int` - The attachment size in bytes. - height: Optional[:class:`int`] - The attachment's height, in pixels. Only applicable to images and videos. - width: Optional[:class:`int`] - The attachment's width, in pixels. Only applicable to images and videos. - filename: :class:`str` - The attachment's filename. - url: :class:`str` - The attachment URL. If the message this attachment was attached - to is deleted, then this will 404. - proxy_url: :class:`str` - The proxy URL. This is a cached version of the :attr:`~Attachment.url` in the - case of images. When the message is deleted, this URL might be valid for a few - minutes or not valid at all. - content_type: Optional[:class:`str`] - The attachment's `media type `_ - - .. versionadded:: 1.7 - description: Optional[:class:`str`] - The attachment's description. Only applicable to images. - - .. versionadded:: 2.0 - ephemeral: :class:`bool` - Whether the attachment is ephemeral. - - .. versionadded:: 2.0 - duration: Optional[:class:`float`] - The duration of the audio file in seconds. Returns ``None`` if it's not a voice message. - - .. versionadded:: 2.3 - waveform: Optional[:class:`bytes`] - The waveform (amplitudes) of the audio in bytes. Returns ``None`` if it's not a voice message. - - .. versionadded:: 2.3 - title: Optional[:class:`str`] - The normalised version of the attachment's filename. - - .. versionadded:: 2.5 - spoiler: :class:`bool` - Whether the attachment is a spoiler or not. Unlike :meth:`.is_spoiler`, this uses the API returned - data. - - .. versionadded:: 2.6 - """ - - __slots__ = ( - 'id', - 'size', - 'ephemeral', - 'duration', - 'waveform', - 'title', - 'url', - 'proxy_url', - 'description', - 'filename', - 'spoiler', - 'height', - 'width', - 'content_type', - '_flags', - '_http', - '_state', - ) - - def __init__(self, *, data: AttachmentPayload, state: Optional[ConnectionState]): - self.id: int = int(data['id']) - self.filename: str = data['filename'] - self.size: int = data['size'] - self.ephemeral: bool = data.get('ephemeral', False) - self.duration: Optional[float] = data.get('duration_secs') - self.title: Optional[str] = data.get('title') - self._state: Optional[ConnectionState] = state - self._http: Optional[HTTPClient] = state.http if state else None - self.url: str = data['url'] - self.proxy_url: str = data['proxy_url'] - self.description: Optional[str] = data.get('description') - self.spoiler: bool = data.get('spoiler', False) - self.height: Optional[int] = data.get('height') - self.width: Optional[int] = data.get('width') - self.content_type: Optional[str] = data.get('content_type') - self._flags: int = data.get('flags', 0) - - @property - def flags(self) -> AttachmentFlags: - """:class:`AttachmentFlags`: The attachment's flag value.""" - return AttachmentFlags._from_value(self._flags) - - def __str__(self) -> str: - return self.url or '' - - async def save( - self, - fp: Union[io.BufferedIOBase, PathLike[Any]], - *, - seek_begin: bool = True, - use_cached: bool = False, - ) -> int: - """|coro| - - Saves this attachment into a file-like object. - - Parameters - ---------- - fp: Union[:class:`io.BufferedIOBase`, :class:`os.PathLike`] - The file-like object to save this attachment to or the filename - to use. If a filename is passed then a file is created with that - filename and used instead. - seek_begin: :class:`bool` - Whether to seek to the beginning of the file after saving is - successfully done. - use_cached: :class:`bool` - Whether to use :attr:`proxy_url` rather than :attr:`url` when downloading - the attachment. This will allow attachments to be saved after deletion - more often, compared to the regular URL which is generally deleted right - after the message is deleted. Note that this can still fail to download - deleted attachments if too much time has passed and it does not work - on some types of attachments. - - Raises - -------- - HTTPException - Saving the attachment failed. - NotFound - The attachment was deleted. - - Returns - -------- - :class:`int` - The number of bytes written. - """ - data = await self.read(use_cached=use_cached) - if isinstance(fp, io.BufferedIOBase): - written = fp.write(data) - if seek_begin: - fp.seek(0) - return written - else: - with open(fp, 'wb') as f: - return f.write(data) - - async def read(self, *, use_cached: bool = False) -> bytes: - """|coro| - - Retrieves the content of this attachment as a :class:`bytes` object. - - .. versionadded:: 1.1 - - Parameters - ----------- - use_cached: :class:`bool` - Whether to use :attr:`proxy_url` rather than :attr:`url` when downloading - the attachment. This will allow attachments to be saved after deletion - more often, compared to the regular URL which is generally deleted right - after the message is deleted. Note that this can still fail to download - deleted attachments if too much time has passed and it does not work - on some types of attachments. - - Raises - ------ - HTTPException - Downloading the attachment failed. - Forbidden - You do not have permissions to access this attachment - NotFound - The attachment was deleted. - ClientException - Cannot read a stateless attachment. - - Returns - ------- - :class:`bytes` - The contents of the attachment. - """ - if not self._http: - raise ClientException( - 'Cannot read a stateless attachment' - ) - - url = self.proxy_url if use_cached else self.url - data = await self._http.get_from_cdn(url) - return data - - async def to_file( - self, - *, - filename: Optional[str] = MISSING, - description: Optional[str] = MISSING, - use_cached: bool = False, - spoiler: bool = False, - ) -> File: - """|coro| - - Converts the attachment into a :class:`File` suitable for sending via - :meth:`abc.Messageable.send`. - - .. versionadded:: 1.3 - - Parameters - ----------- - filename: Optional[:class:`str`] - The filename to use for the file. If not specified then the filename - of the attachment is used instead. - - .. versionadded:: 2.0 - description: Optional[:class:`str`] - The description to use for the file. If not specified then the - description of the attachment is used instead. - - .. versionadded:: 2.0 - use_cached: :class:`bool` - Whether to use :attr:`proxy_url` rather than :attr:`url` when downloading - the attachment. This will allow attachments to be saved after deletion - more often, compared to the regular URL which is generally deleted right - after the message is deleted. Note that this can still fail to download - deleted attachments if too much time has passed and it does not work - on some types of attachments. - - .. versionadded:: 1.4 - spoiler: :class:`bool` - Whether the file is a spoiler. - - .. versionadded:: 1.4 - - Raises - ------ - HTTPException - Downloading the attachment failed. - Forbidden - You do not have permissions to access this attachment - NotFound - The attachment was deleted. - - Returns - ------- - :class:`File` - The attachment as a file suitable for sending. - """ - - data = await self.read(use_cached=use_cached) - file_filename = filename if filename is not MISSING else self.filename - file_description = ( - description if description is not MISSING else self.description - ) - return File( - io.BytesIO(data), - filename=file_filename, - description=file_description, - spoiler=spoiler, - ) - - def is_spoiler(self) -> bool: - """:class:`bool`: Whether this attachment contains a spoiler.""" - return self.spoiler or self.filename.startswith('SPOILER_') - - def is_voice_message(self) -> bool: - """:class:`bool`: Whether this attachment is a voice message.""" - return self.duration is not None and 'voice-message' in self.url - - def __repr__(self) -> str: - return f'' - - def to_dict(self) -> AttachmentPayload: - base: AttachmentPayload = { - 'url': self.url, - 'proxy_url': self.proxy_url, - 'spoiler': self.spoiler, - 'id': self.id, - 'filename': self.filename, - 'size': self.size, - } - - if self.width: - base['width'] = self.width - if self.height: - base['height'] = self.height - if self.description: - base['description'] = self.description - - return base - - -class UnfurledAttachment(Attachment): - """Represents an unfurled attachment item from a :class:`Component`. - - .. versionadded:: 2.6 - - .. container:: operations - - .. describe:: str(x) - - Returns the URL of the attachment. - - .. describe:: x == y - - Checks if the unfurled attachment is equal to another unfurled attachment. - - .. describe:: x != y - - Checks if the unfurled attachment is not equal to another unfurled attachment. - - Attributes - ---------- - height: Optional[:class:`int`] - The attachment's height, in pixels. Only applicable to images and videos. - width: Optional[:class:`int`] - The attachment's width, in pixels. Only applicable to images and videos. - url: :class:`str` - The attachment URL. If the message this attachment was attached - to is deleted, then this will 404. - proxy_url: :class:`str` - The proxy URL. This is a cached version of the :attr:`~Attachment.url` in the - case of images. When the message is deleted, this URL might be valid for a few - minutes or not valid at all. - content_type: Optional[:class:`str`] - The attachment's `media type `_ - description: Optional[:class:`str`] - The attachment's description. Only applicable to images. - spoiler: :class:`bool` - Whether the attachment is a spoiler or not. Unlike :meth:`.is_spoiler`, this uses the API returned - data. - loading_state: :class:`MediaLoadingState` - The cache state of this unfurled attachment. - """ - - __slots__ = ( - 'loading_state', - ) - - def __init__(self, data: UnfurledAttachmentPayload, state: Optional[ConnectionState]) -> None: - self.loading_state: MediaLoadingState = try_enum(MediaLoadingState, data.get('loading_state', 0)) - super().__init__(data={'id': 0, 'filename': '', 'size': 0, **data}, state=state) # type: ignore - - def __repr__(self) -> str: - return f'' - - def to_object_dict(self): - return {'url': self.url} diff --git a/discord/components.py b/discord/components.py index 4e0196f7dd92..7330b82e9448 100644 --- a/discord/components.py +++ b/discord/components.py @@ -34,7 +34,7 @@ Union, ) -from .attachment import UnfurledAttachment +from .asset import AssetMixin from .enums import ( try_enum, ComponentType, @@ -43,7 +43,9 @@ ChannelType, SelectDefaultValueType, SeparatorSize, + MediaItemLoadingState, ) +from .flags import AttachmentFlags from .colour import Colour from .utils import get_slots, MISSING from .partial_emoji import PartialEmoji, _EmojiTag @@ -68,6 +70,7 @@ MediaGalleryItem as MediaGalleryItemPayload, ThumbnailComponent as ThumbnailComponentPayload, ContainerComponent as ContainerComponentPayload, + UnfurledMediaItem as UnfurledMediaItemPayload, ) from .emoji import Emoji @@ -773,7 +776,7 @@ def __init__( data: ThumbnailComponentPayload, state: Optional[ConnectionState], ) -> None: - self.media: UnfurledAttachment = UnfurledAttachment(data['media'], state) + self.media: UnfurledMediaItem = UnfurledMediaItem._from_data(data['media'], state) self.description: Optional[str] = data.get('description') self.spoiler: bool = data.get('spoiler', False) @@ -817,15 +820,96 @@ def to_dict(self) -> TextComponentPayload: } +class UnfurledMediaItem(AssetMixin): + """Represents an unfurled media item that can be used on + :class:`MediaGalleryItem`s. + + Unlike :class:`UnfurledAttachment` this represents a media item + not yet stored on Discord and thus it does not have any data. + + Parameters + ---------- + url: :class:`str` + The URL of this media item. + + Attributes + ---------- + proxy_url: Optional[:class:`str`] + The proxy URL. This is a cached version of the :attr:`~UnfurledMediaItem.url` in the + case of images. When the message is deleted, this URL might be valid for a few minutes + or not valid at all. + height: Optional[:class:`int`] + The media item's height, in pixels. Only applicable to images and videos. + width: Optional[:class:`int`] + The media item's width, in pixels. Only applicable to images and videos. + content_type: Optional[:class:`str`] + The media item's `media type `_ + placeholder: Optional[:class:`str`] + The media item's placeholder. + loading_state: Optional[:class:`MediaItemLoadingState`] + The loading state of this media item. + """ + + __slots__ = ( + 'url', + 'proxy_url', + 'height', + 'width', + 'content_type', + '_flags', + 'placeholder', + 'loading_state', + '_state', + ) + + def __init__(self, url: str) -> None: + self.url: str = url + + self.proxy_url: Optional[str] = None + self.height: Optional[int] = None + self.width: Optional[int] = None + self.content_type: Optional[str] = None + self._flags: int = 0 + self.placeholder: Optional[str] = None + self.loading_state: Optional[MediaItemLoadingState] = None + self._state: Optional[ConnectionState] = None + + @property + def flags(self) -> AttachmentFlags: + """:class:`AttachmentFlags`: This media item's flags.""" + return AttachmentFlags._from_value(self._flags) + + @classmethod + def _from_data(cls, data: UnfurledMediaItemPayload, state: Optional[ConnectionState]): + self = cls(data['url']) + self._update(data, state) + return self + + def _update(self, data: UnfurledMediaItemPayload, state: Optional[ConnectionState]) -> None: + self.proxy_url = data['proxy_url'] + self.height = data.get('height') + self.width = data.get('width') + self.content_type = data.get('content_type') + self._flags = data.get('flags', 0) + self.placeholder = data.get('placeholder') + self.loading_state = try_enum(MediaItemLoadingState, data['loading_state']) + self._state = state + + def to_dict(self): + return { + 'url': self.url, + } + + class MediaGalleryItem: """Represents a :class:`MediaGalleryComponent` media item. Parameters ---------- - url: :class:`str` - The url of the media item. This can be a local file uploaded - as an attachment in the message, that can be accessed using - the ``attachment://file-name.extension`` format. + media: Union[:class:`str`, :class:`UnfurledMediaItem`] + The media item data. This can be a string representing a local + file uploaded as an attachment in the message, that can be accessed + using the ``attachment://file-name.extension`` format. description: Optional[:class:`str`] The description to show within this item. spoiler: :class:`bool` @@ -833,7 +917,7 @@ class MediaGalleryItem: """ __slots__ = ( - 'url', + 'media', 'description', 'spoiler', '_state', @@ -841,12 +925,12 @@ class MediaGalleryItem: def __init__( self, - url: str, + media: Union[str, UnfurledMediaItem], *, description: Optional[str] = None, spoiler: bool = False, ) -> None: - self.url: str = url + self.media: UnfurledMediaItem = UnfurledMediaItem(media) if isinstance(media, str) else media self.description: Optional[str] = description self.spoiler: bool = spoiler self._state: Optional[ConnectionState] = None @@ -857,7 +941,7 @@ def _from_data( ) -> MediaGalleryItem: media = data['media'] self = cls( - url=media['url'], + media=media['url'], description=data.get('description'), spoiler=data.get('spoiler', False), ) @@ -873,8 +957,8 @@ def _from_gallery( return [cls._from_data(item, state) for item in items] def to_dict(self) -> MediaGalleryItemPayload: - return { # type: ignore - 'media': {'url': self.url}, + return { + 'media': self.media.to_dict(), # type: ignore 'description': self.description, 'spoiler': self.spoiler, } @@ -927,9 +1011,7 @@ class FileComponent(Component): ) def __init__(self, data: FileComponentPayload, state: Optional[ConnectionState]) -> None: - self.media: UnfurledAttachment = UnfurledAttachment( - data['file'], state, - ) + self.media: UnfurledMediaItem = UnfurledMediaItem._from_data(data['file'], state) self.spoiler: bool = data.get('spoiler', False) @property @@ -937,8 +1019,8 @@ def type(self) -> Literal[ComponentType.file]: return ComponentType.file def to_dict(self) -> FileComponentPayload: - return { # type: ignore - 'file': {'url': self.url}, + return { + 'file': self.media.to_dict(), # type: ignore 'spoiler': self.spoiler, 'type': self.type.value, } diff --git a/discord/enums.py b/discord/enums.py index 025f0bf147c1..49684935f7c7 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -78,7 +78,7 @@ 'SubscriptionStatus', 'MessageReferenceType', 'SeparatorSize', - 'MediaLoadingState', + 'MediaItemLoadingState', ) @@ -877,7 +877,7 @@ class SeparatorSize(Enum): large = 2 -class MediaLoadingState(Enum): +class MediaItemLoadingState(Enum): unknown = 0 loading = 1 loaded = 2 diff --git a/discord/ui/thumbnail.py b/discord/ui/thumbnail.py index a984a1892f9c..67e380f65c5c 100644 --- a/discord/ui/thumbnail.py +++ b/discord/ui/thumbnail.py @@ -23,10 +23,11 @@ """ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, TypeVar +from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, TypeVar, Union from .item import Item from ..enums import ComponentType +from ..components import UnfurledMediaItem if TYPE_CHECKING: from typing_extensions import Self @@ -47,9 +48,9 @@ class Thumbnail(Item[V]): Parameters ---------- - url: :class:`str` - The URL of the thumbnail. This can only point to a local attachment uploaded - within this item. URLs must match the ``attachment://file-name.extension`` + media: Union[:class:`str`, :class:`UnfurledMediaItem`] + The media of the thumbnail. This can be a string that points to a local + attachment uploaded within this item. URLs must match the ``attachment://file-name.extension`` structure. description: Optional[:class:`str`] The description of this thumbnail. Defaults to ``None``. @@ -57,11 +58,13 @@ class Thumbnail(Item[V]): Whether to flag this thumbnail as a spoiler. Defaults to ``False``. """ - def __init__(self, url: str, *, description: Optional[str] = None, spoiler: bool = False) -> None: - self.url: str = url + def __init__(self, media: Union[str, UnfurledMediaItem], *, description: Optional[str] = None, spoiler: bool = False) -> None: + self.media: UnfurledMediaItem = UnfurledMediaItem(media) if isinstance(media, str) else media self.description: Optional[str] = description self.spoiler: bool = spoiler + self._underlying = ThumbnailComponent._raw_construct() + @property def type(self) -> Literal[ComponentType.thumbnail]: return ComponentType.thumbnail @@ -73,14 +76,14 @@ def to_component_dict(self) -> Dict[str, Any]: return { 'type': self.type.value, 'spoiler': self.spoiler, - 'media': {'url': self.url}, + 'media': self.media.to_dict(), 'description': self.description, } @classmethod def from_component(cls, component: ThumbnailComponent) -> Self: return cls( - url=component.media.url, + media=component.media.url, description=component.description, spoiler=component.spoiler, ) diff --git a/discord/ui/view.py b/discord/ui/view.py index 4afcd9fad0ce..c2cef2248f56 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -255,6 +255,9 @@ def key(item: Item) -> int: # instead of grouping by row we will sort it so it is added # in order and should work as the original implementation + # this will append directly the v2 Components into the list + # and will add to an action row the loose items, such as + # buttons and selects for child in sorted(self._children, key=key): if child._is_v2(): components.append(child.to_component_dict()) From 0f04d48893da56eeb0cdc487c9cbdc522e39d63e Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 2 Mar 2025 12:48:31 +0100 Subject: [PATCH 011/272] chore: Remove views leftover --- discord/abc.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/discord/abc.py b/discord/abc.py index ae6a1fb15811..70531fb2005e 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -1580,8 +1580,7 @@ async def send( You specified both ``file`` and ``files``, or you specified both ``embed`` and ``embeds``, or the ``reference`` object is not a :class:`~discord.Message`, - :class:`~discord.MessageReference` or :class:`~discord.PartialMessage`, - or you specified both ``view`` and ``views``. + :class:`~discord.MessageReference` or :class:`~discord.PartialMessage`. Returns --------- From 2b14f0b014f05806e67b840164ea259013d1bac5 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 2 Mar 2025 12:51:36 +0100 Subject: [PATCH 012/272] chore: message attachments things --- discord/message.py | 302 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 298 insertions(+), 4 deletions(-) diff --git a/discord/message.py b/discord/message.py index 000747e787cf..c551138f7d3c 100644 --- a/discord/message.py +++ b/discord/message.py @@ -27,6 +27,8 @@ import asyncio import datetime import re +import io +from os import PathLike from typing import ( Dict, TYPE_CHECKING, @@ -53,7 +55,7 @@ from .components import _component_factory from .embeds import Embed from .member import Member -from .flags import MessageFlags +from .flags import MessageFlags, AttachmentFlags from .file import File from .utils import escape_mentions, MISSING, deprecated from .http import handle_message_parameters @@ -63,7 +65,6 @@ from .threads import Thread from .channel import PartialMessageable from .poll import Poll -from .attachment import Attachment if TYPE_CHECKING: from typing_extensions import Self @@ -107,6 +108,7 @@ __all__ = ( + 'Attachment', 'Message', 'PartialMessage', 'MessageInteraction', @@ -138,6 +140,298 @@ def convert_emoji_reaction(emoji: Union[EmojiInputType, Reaction]) -> str: raise TypeError(f'emoji argument must be str, Emoji, or Reaction not {emoji.__class__.__name__}.') +class Attachment(Hashable): + """Represents an attachment from Discord. + + .. container:: operations + + .. describe:: str(x) + + Returns the URL of the attachment. + + .. describe:: x == y + + Checks if the attachment is equal to another attachment. + + .. describe:: x != y + + Checks if the attachment is not equal to another attachment. + + .. describe:: hash(x) + + Returns the hash of the attachment. + + .. versionchanged:: 1.7 + Attachment can now be casted to :class:`str` and is hashable. + + Attributes + ------------ + id: :class:`int` + The attachment ID. + size: :class:`int` + The attachment size in bytes. + height: Optional[:class:`int`] + The attachment's height, in pixels. Only applicable to images and videos. + width: Optional[:class:`int`] + The attachment's width, in pixels. Only applicable to images and videos. + filename: :class:`str` + The attachment's filename. + url: :class:`str` + The attachment URL. If the message this attachment was attached + to is deleted, then this will 404. + proxy_url: :class:`str` + The proxy URL. This is a cached version of the :attr:`~Attachment.url` in the + case of images. When the message is deleted, this URL might be valid for a few + minutes or not valid at all. + content_type: Optional[:class:`str`] + The attachment's `media type `_ + + .. versionadded:: 1.7 + description: Optional[:class:`str`] + The attachment's description. Only applicable to images. + + .. versionadded:: 2.0 + ephemeral: :class:`bool` + Whether the attachment is ephemeral. + + .. versionadded:: 2.0 + duration: Optional[:class:`float`] + The duration of the audio file in seconds. Returns ``None`` if it's not a voice message. + + .. versionadded:: 2.3 + waveform: Optional[:class:`bytes`] + The waveform (amplitudes) of the audio in bytes. Returns ``None`` if it's not a voice message. + + .. versionadded:: 2.3 + title: Optional[:class:`str`] + The normalised version of the attachment's filename. + + .. versionadded:: 2.5 + """ + + __slots__ = ( + 'id', + 'size', + 'height', + 'width', + 'filename', + 'url', + 'proxy_url', + '_http', + 'content_type', + 'description', + 'ephemeral', + 'duration', + 'waveform', + '_flags', + 'title', + ) + + def __init__(self, *, data: AttachmentPayload, state: ConnectionState): + self.id: int = int(data['id']) + self.size: int = data['size'] + self.height: Optional[int] = data.get('height') + self.width: Optional[int] = data.get('width') + self.filename: str = data['filename'] + self.url: str = data['url'] + self.proxy_url: str = data['proxy_url'] + self._http = state.http + self.content_type: Optional[str] = data.get('content_type') + self.description: Optional[str] = data.get('description') + self.ephemeral: bool = data.get('ephemeral', False) + self.duration: Optional[float] = data.get('duration_secs') + self.title: Optional[str] = data.get('title') + + waveform = data.get('waveform') + self.waveform: Optional[bytes] = utils._base64_to_bytes(waveform) if waveform is not None else None + + self._flags: int = data.get('flags', 0) + + @property + def flags(self) -> AttachmentFlags: + """:class:`AttachmentFlags`: The attachment's flags.""" + return AttachmentFlags._from_value(self._flags) + + def is_spoiler(self) -> bool: + """:class:`bool`: Whether this attachment contains a spoiler.""" + return self.filename.startswith('SPOILER_') + + def is_voice_message(self) -> bool: + """:class:`bool`: Whether this attachment is a voice message.""" + return self.duration is not None and 'voice-message' in self.url + + def __repr__(self) -> str: + return f'' + + def __str__(self) -> str: + return self.url or '' + + async def save( + self, + fp: Union[io.BufferedIOBase, PathLike[Any]], + *, + seek_begin: bool = True, + use_cached: bool = False, + ) -> int: + """|coro| + + Saves this attachment into a file-like object. + + Parameters + ----------- + fp: Union[:class:`io.BufferedIOBase`, :class:`os.PathLike`] + The file-like object to save this attachment to or the filename + to use. If a filename is passed then a file is created with that + filename and used instead. + seek_begin: :class:`bool` + Whether to seek to the beginning of the file after saving is + successfully done. + use_cached: :class:`bool` + Whether to use :attr:`proxy_url` rather than :attr:`url` when downloading + the attachment. This will allow attachments to be saved after deletion + more often, compared to the regular URL which is generally deleted right + after the message is deleted. Note that this can still fail to download + deleted attachments if too much time has passed and it does not work + on some types of attachments. + + Raises + -------- + HTTPException + Saving the attachment failed. + NotFound + The attachment was deleted. + + Returns + -------- + :class:`int` + The number of bytes written. + """ + data = await self.read(use_cached=use_cached) + if isinstance(fp, io.BufferedIOBase): + written = fp.write(data) + if seek_begin: + fp.seek(0) + return written + else: + with open(fp, 'wb') as f: + return f.write(data) + + async def read(self, *, use_cached: bool = False) -> bytes: + """|coro| + + Retrieves the content of this attachment as a :class:`bytes` object. + + .. versionadded:: 1.1 + + Parameters + ----------- + use_cached: :class:`bool` + Whether to use :attr:`proxy_url` rather than :attr:`url` when downloading + the attachment. This will allow attachments to be saved after deletion + more often, compared to the regular URL which is generally deleted right + after the message is deleted. Note that this can still fail to download + deleted attachments if too much time has passed and it does not work + on some types of attachments. + + Raises + ------ + HTTPException + Downloading the attachment failed. + Forbidden + You do not have permissions to access this attachment + NotFound + The attachment was deleted. + + Returns + ------- + :class:`bytes` + The contents of the attachment. + """ + url = self.proxy_url if use_cached else self.url + data = await self._http.get_from_cdn(url) + return data + + async def to_file( + self, + *, + filename: Optional[str] = MISSING, + description: Optional[str] = MISSING, + use_cached: bool = False, + spoiler: bool = False, + ) -> File: + """|coro| + + Converts the attachment into a :class:`File` suitable for sending via + :meth:`abc.Messageable.send`. + + .. versionadded:: 1.3 + + Parameters + ----------- + filename: Optional[:class:`str`] + The filename to use for the file. If not specified then the filename + of the attachment is used instead. + + .. versionadded:: 2.0 + description: Optional[:class:`str`] + The description to use for the file. If not specified then the + description of the attachment is used instead. + + .. versionadded:: 2.0 + use_cached: :class:`bool` + Whether to use :attr:`proxy_url` rather than :attr:`url` when downloading + the attachment. This will allow attachments to be saved after deletion + more often, compared to the regular URL which is generally deleted right + after the message is deleted. Note that this can still fail to download + deleted attachments if too much time has passed and it does not work + on some types of attachments. + + .. versionadded:: 1.4 + spoiler: :class:`bool` + Whether the file is a spoiler. + + .. versionadded:: 1.4 + + Raises + ------ + HTTPException + Downloading the attachment failed. + Forbidden + You do not have permissions to access this attachment + NotFound + The attachment was deleted. + + Returns + ------- + :class:`File` + The attachment as a file suitable for sending. + """ + + data = await self.read(use_cached=use_cached) + file_filename = filename if filename is not MISSING else self.filename + file_description = description if description is not MISSING else self.description + return File(io.BytesIO(data), filename=file_filename, description=file_description, spoiler=spoiler) + + def to_dict(self) -> AttachmentPayload: + result: AttachmentPayload = { + 'filename': self.filename, + 'id': self.id, + 'proxy_url': self.proxy_url, + 'size': self.size, + 'url': self.url, + 'spoiler': self.is_spoiler(), + } + if self.height: + result['height'] = self.height + if self.width: + result['width'] = self.width + if self.content_type: + result['content_type'] = self.content_type + if self.description is not None: + result['description'] = self.description + return result + + class DeletedReferencedMessage: """A special sentinel type given when the resolved message reference points to a deleted message. @@ -238,9 +532,9 @@ def __init__(self, state: ConnectionState, data: MessageSnapshotPayload): self.components: List[MessageComponentType] = [] for component_data in data.get('components', []): - component = _component_factory(component_data, state) + component = _component_factory(component_data, state) # type: ignore if component is not None: - self.components.append(component) + self.components.append(component) # type: ignore self._state: ConnectionState = state From f42b15fe135294a0e08375b6934ddc4a4dcdb3b1 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 2 Mar 2025 12:52:16 +0100 Subject: [PATCH 013/272] chore: Remove attachment.py --- discord/types/attachment.py | 56 ------------------------------------- discord/types/components.py | 18 +++++++++--- discord/types/message.py | 18 +++++++++++- 3 files changed, 31 insertions(+), 61 deletions(-) delete mode 100644 discord/types/attachment.py diff --git a/discord/types/attachment.py b/discord/types/attachment.py deleted file mode 100644 index 0084c334c67e..000000000000 --- a/discord/types/attachment.py +++ /dev/null @@ -1,56 +0,0 @@ -""" -The MIT License (MIT) - -Copyright (c) 2015-present Rapptz - -Permission is hereby granted, free of charge, to any person obtaining a -copy of this software and associated documentation files (the "Software"), -to deal in the Software without restriction, including without limitation -the rights to use, copy, modify, merge, publish, distribute, sublicense, -and/or sell copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. -""" - -from __future__ import annotations - -from typing import Literal, Optional, TypedDict -from typing_extensions import NotRequired, Required - -from .snowflake import Snowflake - -LoadingState = Literal[0, 1, 2, 3] - -class AttachmentBase(TypedDict): - url: str - proxy_url: str - description: NotRequired[str] - spoiler: NotRequired[bool] - height: NotRequired[Optional[int]] - width: NotRequired[Optional[int]] - content_type: NotRequired[str] - flags: NotRequired[int] - - -class Attachment(AttachmentBase): - id: Snowflake - filename: str - size: int - ephemeral: NotRequired[bool] - duration_secs: NotRequired[float] - waveform: NotRequired[str] - title: NotRequired[str] - - -class UnfurledAttachment(AttachmentBase, total=False): - loading_state: Required[LoadingState] diff --git a/discord/types/components.py b/discord/types/components.py index a50cbdd1ec44..68aa6156df8d 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -29,7 +29,6 @@ from .emoji import PartialEmoji from .channel import ChannelType -from .attachment import UnfurledAttachment ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 17] ButtonStyle = Literal[1, 2, 3, 4, 5, 6] @@ -137,15 +136,26 @@ class TextComponent(ComponentBase): content: str +class UnfurledMediaItem(TypedDict): + url: str + proxy_url: str + height: NotRequired[Optional[int]] + width: NotRequired[Optional[int]] + content_type: NotRequired[str] + placeholder: str + loading_state: MediaItemLoadingState + flags: NotRequired[int] + + class ThumbnailComponent(ComponentBase): type: Literal[11] - media: UnfurledAttachment + media: UnfurledMediaItem description: NotRequired[Optional[str]] spoiler: NotRequired[bool] class MediaGalleryItem(TypedDict): - media: UnfurledAttachment + media: UnfurledMediaItem description: NotRequired[Optional[str]] spoiler: NotRequired[bool] @@ -157,7 +167,7 @@ class MediaGalleryComponent(ComponentBase): class FileComponent(ComponentBase): type: Literal[13] - file: UnfurledAttachment + file: UnfurledMediaItem spoiler: NotRequired[bool] diff --git a/discord/types/message.py b/discord/types/message.py index 81bfdd23baed..1d837d2d88e8 100644 --- a/discord/types/message.py +++ b/discord/types/message.py @@ -38,7 +38,6 @@ from .sticker import StickerItem from .threads import Thread from .poll import Poll -from .attachment import Attachment class PartialMessage(TypedDict): @@ -70,6 +69,23 @@ class Reaction(TypedDict): burst_colors: List[str] +class Attachment(TypedDict): + id: Snowflake + filename: str + size: int + url: str + proxy_url: str + height: NotRequired[Optional[int]] + width: NotRequired[Optional[int]] + description: NotRequired[str] + content_type: NotRequired[str] + spoiler: NotRequired[bool] + ephemeral: NotRequired[bool] + duration_secs: NotRequired[float] + waveform: NotRequired[str] + flags: NotRequired[int] + + MessageActivityType = Literal[1, 2, 3, 5] From 39998b4fb3435f71d5e44d7319eebeeac4cbdb01 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 2 Mar 2025 12:53:37 +0100 Subject: [PATCH 014/272] chore: Revert double quotes --- discord/types/message.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/types/message.py b/discord/types/message.py index 1d837d2d88e8..6c260d44dbdf 100644 --- a/discord/types/message.py +++ b/discord/types/message.py @@ -229,7 +229,7 @@ class Message(PartialMessage): purchase_notification: NotRequired[PurchaseNotificationResponse] -AllowedMentionType = Literal["roles", "users", "everyone"] +AllowedMentionType = Literal['roles', 'users', 'everyone'] class AllowedMentions(TypedDict): From 28efb157eec0993a3e47e54bd33f6709237dd7d9 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 2 Mar 2025 13:42:37 +0100 Subject: [PATCH 015/272] chore: Finished first components v2 impl --- discord/components.py | 1 - discord/ui/file.py | 125 +++++++++++++++++++++++++ discord/ui/item.py | 5 +- discord/ui/media_gallery.py | 177 ++++++++++++++++++++++++++++++++++++ discord/ui/section.py | 8 ++ discord/ui/separator.py | 110 ++++++++++++++++++++++ discord/ui/text_display.py | 14 ++- discord/ui/thumbnail.py | 24 ++++- discord/ui/view.py | 7 +- 9 files changed, 462 insertions(+), 9 deletions(-) create mode 100644 discord/ui/file.py create mode 100644 discord/ui/media_gallery.py create mode 100644 discord/ui/separator.py diff --git a/discord/components.py b/discord/components.py index 7330b82e9448..f1364691016c 100644 --- a/discord/components.py +++ b/discord/components.py @@ -986,7 +986,6 @@ def type(self) -> Literal[ComponentType.media_gallery]: def to_dict(self) -> MediaGalleryComponentPayload: return { - 'id': self.id, 'type': self.type.value, 'items': [item.to_dict() for item in self.items], } diff --git a/discord/ui/file.py b/discord/ui/file.py new file mode 100644 index 000000000000..b4285a654b5b --- /dev/null +++ b/discord/ui/file.py @@ -0,0 +1,125 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal, Optional, TypeVar, Union + +from .item import Item +from ..components import FileComponent, UnfurledMediaItem +from ..enums import ComponentType + +if TYPE_CHECKING: + from typing_extensions import Self + + from .view import View + +V = TypeVar('V', bound='View', covariant=True) + +__all__ = ('File',) + + +class File(Item[V]): + """Represents a UI file component. + + .. versionadded:: 2.6 + + Parameters + ---------- + media: Union[:class:`str`, :class:`UnfurledMediaItem`] + This file's media. If this is a string itmust point to a local + file uploaded within the parent view of this item, and must + meet the ``attachment://file-name.extension`` structure. + spoiler: :class:`bool` + Whether to flag this file as a spoiler. Defaults to ``False``. + row: Optional[:class:`int`] + The relative row this file component belongs to. By default + items are arranged automatically into those 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 9 (i.e. zero indexed) + """ + + def __init__( + self, + media: Union[str, UnfurledMediaItem], + *, + spoiler: bool = False, + row: Optional[int] = None, + ) -> None: + super().__init__() + self._underlying = FileComponent._raw_construct( + media=UnfurledMediaItem(media) if isinstance(media, str) else media, + spoiler=spoiler, + ) + + self.row = row + + def _is_v2(self): + return True + + @property + def width(self): + return 5 + + @property + def type(self) -> Literal[ComponentType.file]: + return self._underlying.type + + @property + def media(self) -> UnfurledMediaItem: + """:class:`UnfurledMediaItem`: Returns this file media.""" + return self._underlying.media + + @media.setter + def media(self, value: UnfurledMediaItem) -> None: + self._underlying.media = value + + @property + def url(self) -> str: + """:class:`str`: Returns this file's url.""" + return self._underlying.media.url + + @url.setter + def url(self, value: str) -> None: + self._underlying.media = UnfurledMediaItem(value) + + @property + def spoiler(self) -> bool: + """:class:`bool`: Returns whether this file should be flagged as a spoiler.""" + return self._underlying.spoiler + + @spoiler.setter + def spoiler(self, value: bool) -> None: + self._underlying.spoiler = value + + def to_component_dict(self): + return self._underlying.to_dict() + + @classmethod + def from_component(cls, component: FileComponent) -> Self: + return cls( + media=component.media, + spoiler=component.spoiler, + ) diff --git a/discord/ui/item.py b/discord/ui/item.py index 2d2a3aaa6f88..aaf15cee648b 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -70,6 +70,7 @@ def __init__(self): # actually affect the intended purpose of this check because from_component is # only called upon edit and we're mainly interested during initial creation time. self._provided_custom_id: bool = False + self._max_row: int = 5 if not self._is_v2() else 10 def to_component_dict(self) -> Dict[str, Any]: raise NotImplementedError @@ -109,10 +110,10 @@ def row(self) -> Optional[int]: def row(self, value: Optional[int]) -> None: if value is None: self._row = None - elif 5 > value >= 0: + elif self._max_row > value >= 0: self._row = value else: - raise ValueError('row cannot be negative or greater than or equal to 5') + raise ValueError('row cannot be negative or greater than or equal to 10') @property def width(self) -> int: diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py new file mode 100644 index 000000000000..fa20af740651 --- /dev/null +++ b/discord/ui/media_gallery.py @@ -0,0 +1,177 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING, List, Literal, Optional, TypeVar + +from .item import Item +from ..enums import ComponentType +from ..components import ( + MediaGalleryItem, + MediaGalleryComponent, +) + +if TYPE_CHECKING: + from typing_extensions import Self + + from .view import View + +V = TypeVar('V', bound='View', covariant=True) + +__all__ = ('MediaGallery',) + + +class MediaGallery(Item[V]): + """Represents a UI media gallery. + + This can contain up to 10 :class:`MediaGalleryItem`s. + + .. versionadded:: 2.6 + + Parameters + ---------- + items: List[:class:`MediaGalleryItem`] + The initial items of this gallery. + row: Optional[:class:`int`] + The relative row this media gallery belongs to. By default + items are arranged automatically into those 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 9 (i.e. zero indexed) + """ + + def __init__(self, items: List[MediaGalleryItem], *, row: Optional[int] = None) -> None: + super().__init__() + + self._underlying = MediaGalleryComponent._raw_construct( + items=items, + ) + + self.row = row + + @property + def items(self) -> List[MediaGalleryItem]: + """List[:class:`MediaGalleryItem`]: Returns a read-only list of this gallery's items.""" + return self._underlying.items.copy() + + @items.setter + def items(self, value: List[MediaGalleryItem]) -> None: + if len(value) > 10: + raise ValueError('media gallery only accepts up to 10 items') + + self._underlying.items = value + + def to_component_dict(self): + return self._underlying.to_dict() + + def _is_v2(self) -> bool: + return True + + def add_item(self, item: MediaGalleryItem) -> Self: + """Adds an item to this gallery. + + This function returns the class instance to allow for fluent-style + chaining. + + Parameters + ---------- + item: :class:`MediaGalleryItem` + The item to add to the gallery. + + Raises + ------ + TypeError + A :class:`MediaGalleryItem` was not passed. + ValueError + Maximum number of items has been exceeded (10). + """ + + if len(self._underlying.items) >= 10: + raise ValueError('maximum number of items has been exceeded') + + if not isinstance(item, MediaGalleryItem): + raise TypeError(f'expected MediaGalleryItem not {item.__class__.__name__}') + + self._underlying.items.append(item) + return self + + def remove_item(self, item: MediaGalleryItem) -> Self: + """Removes an item from the gallery. + + This function returns the class instance to allow for fluent-style + chaining. + + Parameters + ---------- + item: :class:`MediaGalleryItem` + The item to remove from the gallery. + """ + + try: + self._underlying.items.remove(item) + except ValueError: + pass + return self + + def insert_item_at(self, index: int, item: MediaGalleryItem) -> Self: + """Inserts an item before a specified index to the gallery. + + This function returns the class instance to allow for fluent-style + chaining. + + Parameters + ---------- + index: :class:`int` + The index of where to insert the item. + item: :class:`MediaGalleryItem` + The item to insert. + """ + + self._underlying.items.insert(index, item) + return self + + def clear_items(self) -> Self: + """Removes all items from the gallery. + + This function returns the class instance to allow for fluent-style + chaining. + """ + + self._underlying.items.clear() + return self + + @property + def type(self) -> Literal[ComponentType.media_gallery]: + return self._underlying.type + + @property + def width(self): + return 5 + + @classmethod + def from_component(cls, component: MediaGalleryComponent) -> Self: + return cls( + items=component.items, + ) diff --git a/discord/ui/section.py b/discord/ui/section.py index 0012d0118186..4da36f86f033 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -49,6 +49,13 @@ class Section(Item[V]): The text displays of this section. Up to 3. accessory: Optional[:class:`Item`] The section accessory. Defaults to ``None``. + row: Optional[:class:`int`] + The relative row this section belongs to. By default + items are arranged automatically into those 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 9 (i.e. zero indexed) """ __slots__ = ( @@ -61,6 +68,7 @@ def __init__( children: List[Union[Item[Any], str]], *, accessory: Optional[Item[Any]] = None, + row: Optional[int] = None, ) -> None: super().__init__() if len(children) > 3: diff --git a/discord/ui/separator.py b/discord/ui/separator.py new file mode 100644 index 000000000000..c275fad82279 --- /dev/null +++ b/discord/ui/separator.py @@ -0,0 +1,110 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal, Optional, TypeVar + +from .item import Item +from ..components import SeparatorComponent +from ..enums import SeparatorSize, ComponentType + +if TYPE_CHECKING: + from .view import View + +V = TypeVar('V', bound='View', covariant=True) + +__all__ = ('Separator',) + + +class Separator(Item[V]): + """Represents a UI separator. + + .. versionadded:: 2.6 + + Parameters + ---------- + visible: :class:`bool` + Whether this separator is visible. On the client side this + is whether a divider line should be shown or not. + spacing: :class:`SeparatorSize` + The spacing of this separator. + row: Optional[:class:`int`] + The relative row this separator belongs to. By default + items are arranged automatically into those 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 9 (i.e. zero indexed) + """ + + def __init__( + self, + *, + visible: bool = True, + spacing: SeparatorSize = SeparatorSize.small, + row: Optional[int] = None, + ) -> None: + super().__init__() + self._underlying = SeparatorComponent._raw_construct( + spacing=spacing, + visible=visible, + ) + + self.row = row + + def _is_v2(self): + return True + + @property + def visible(self) -> bool: + """:class:`bool`: Whether this separator is visible. + + On the client side this is whether a divider line should + be shown or not. + """ + return self._underlying.visible + + @visible.setter + def visible(self, value: bool) -> None: + self._underlying.visible = value + + @property + def spacing(self) -> SeparatorSize: + """:class:`SeparatorSize`: The spacing of this separator.""" + return self._underlying.spacing + + @spacing.setter + def spacing(self, value: SeparatorSize) -> None: + self._underlying.spacing = value + + @property + def width(self): + return 5 + + @property + def type(self) -> Literal[ComponentType.separator]: + return self._underlying.type + + def to_component_dict(self): + return self._underlying.to_dict() diff --git a/discord/ui/text_display.py b/discord/ui/text_display.py index 0daff9c89e5c..a51d604938b6 100644 --- a/discord/ui/text_display.py +++ b/discord/ui/text_display.py @@ -23,7 +23,7 @@ """ from __future__ import annotations -from typing import TYPE_CHECKING, Literal, TypeVar +from typing import TYPE_CHECKING, Literal, Optional, TypeVar from .item import Item from ..components import TextDisplay as TextDisplayComponent @@ -46,16 +46,24 @@ class TextDisplay(Item[V]): ---------- content: :class:`str` The content of this text display. + row: Optional[:class:`int`] + The relative row this text display belongs to. By default + items are arranged automatically into those 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 9 (i.e. zero indexed) """ - def __init__(self, content: str) -> None: + def __init__(self, content: str, *, row: Optional[int] = None) -> None: super().__init__() self.content: str = content - self._underlying = TextDisplayComponent._raw_construct( content=content, ) + self.row = row + def to_component_dict(self): return self._underlying.to_dict() diff --git a/discord/ui/thumbnail.py b/discord/ui/thumbnail.py index 67e380f65c5c..fe1d962218c8 100644 --- a/discord/ui/thumbnail.py +++ b/discord/ui/thumbnail.py @@ -56,14 +56,34 @@ class Thumbnail(Item[V]): The description of this thumbnail. Defaults to ``None``. spoiler: :class:`bool` Whether to flag this thumbnail as a spoiler. Defaults to ``False``. + row: Optional[:class:`int`] + The relative row this thumbnail belongs to. By default + items are arranged automatically into those 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 9 (i.e. zero indexed) """ - def __init__(self, media: Union[str, UnfurledMediaItem], *, description: Optional[str] = None, spoiler: bool = False) -> None: + def __init__( + self, + media: Union[str, UnfurledMediaItem], + *, + description: Optional[str] = None, + spoiler: bool = False, + row: Optional[int] = None, + ) -> None: + super().__init__() + self.media: UnfurledMediaItem = UnfurledMediaItem(media) if isinstance(media, str) else media self.description: Optional[str] = description self.spoiler: bool = spoiler - self._underlying = ThumbnailComponent._raw_construct() + self.row = row + + @property + def width(self): + return 5 @property def type(self) -> Literal[ComponentType.thumbnail]: diff --git a/discord/ui/view.py b/discord/ui/view.py index c2cef2248f56..08714bf3a2af 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -685,7 +685,12 @@ async def schedule_dynamic_item_call( item._view = view item._rendered_row = base_item._rendered_row item._refresh_state(interaction, interaction.data) # type: ignore - +The relative row this text display belongs to. By default + items are arranged automatically into those 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 9 (i.e. zero indexed) try: allow = await item.interaction_check(interaction) except Exception: From de8a7238f8cd282ba291b88875fd9177f95280ed Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 2 Mar 2025 14:04:45 +0100 Subject: [PATCH 016/272] chore: docs and some changes --- discord/components.py | 2 + discord/ui/__init__.py | 6 ++ discord/ui/container.py | 4 +- discord/ui/file.py | 2 +- discord/ui/media_gallery.py | 2 +- discord/ui/section.py | 2 + discord/ui/separator.py | 11 ++- discord/ui/text_display.py | 8 ++ discord/ui/thumbnail.py | 2 +- discord/ui/view.py | 26 ++++- docs/api.rst | 29 ++++-- docs/interactions/api.rst | 188 +++++++++++++++++++++++++++++++++++- 12 files changed, 268 insertions(+), 14 deletions(-) diff --git a/discord/components.py b/discord/components.py index f1364691016c..cb1a50976379 100644 --- a/discord/components.py +++ b/discord/components.py @@ -91,6 +91,8 @@ 'SelectDefaultValue', 'SectionComponent', 'ThumbnailComponent', + 'UnfurledMediaItem', + 'MediaGalleryItem', 'MediaGalleryComponent', 'FileComponent', 'SectionComponent', diff --git a/discord/ui/__init__.py b/discord/ui/__init__.py index 029717cb5294..62a78634c72d 100644 --- a/discord/ui/__init__.py +++ b/discord/ui/__init__.py @@ -17,3 +17,9 @@ from .text_input import * from .dynamic import * from .container import * +from .file import * +from .media_gallery import * +from .section import * +from .separator import * +from .text_display import * +from .thumbnail import * diff --git a/discord/ui/container.py b/discord/ui/container.py index a98b0d965ab5..978781dd8470 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -51,9 +51,9 @@ class Container(View, Item[V]): children: List[:class:`Item`] The initial children or :class:`View`s of this container. Can have up to 10 items. - accent_colour: Optional[:class:`~discord.Colour`] + accent_colour: Optional[:class:`.Colour`] The colour of the container. Defaults to ``None``. - accent_color: Optional[:class:`~discord.Color`] + accent_color: Optional[:class:`.Color`] The color of the container. Defaults to ``None``. spoiler: :class:`bool` Whether to flag this container as a spoiler. Defaults diff --git a/discord/ui/file.py b/discord/ui/file.py index b4285a654b5b..fabf5b0f31d0 100644 --- a/discord/ui/file.py +++ b/discord/ui/file.py @@ -46,7 +46,7 @@ class File(Item[V]): Parameters ---------- - media: Union[:class:`str`, :class:`UnfurledMediaItem`] + media: Union[:class:`str`, :class:`.UnfurledMediaItem`] This file's media. If this is a string itmust point to a local file uploaded within the parent view of this item, and must meet the ``attachment://file-name.extension`` structure. diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index fa20af740651..93638d7f6690 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -51,7 +51,7 @@ class MediaGallery(Item[V]): Parameters ---------- - items: List[:class:`MediaGalleryItem`] + items: List[:class:`.MediaGalleryItem`] The initial items of this gallery. row: Optional[:class:`int`] The relative row this media gallery belongs to. By default diff --git a/discord/ui/section.py b/discord/ui/section.py index 4da36f86f033..fece9b053f13 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -78,6 +78,8 @@ def __init__( ] self.accessory: Optional[Item[Any]] = accessory + self.row = row + @property def type(self) -> Literal[ComponentType.section]: return ComponentType.section diff --git a/discord/ui/separator.py b/discord/ui/separator.py index c275fad82279..cc49adecb235 100644 --- a/discord/ui/separator.py +++ b/discord/ui/separator.py @@ -30,6 +30,8 @@ from ..enums import SeparatorSize, ComponentType if TYPE_CHECKING: + from typing_extensions import Self + from .view import View V = TypeVar('V', bound='View', covariant=True) @@ -47,7 +49,7 @@ class Separator(Item[V]): visible: :class:`bool` Whether this separator is visible. On the client side this is whether a divider line should be shown or not. - spacing: :class:`SeparatorSize` + spacing: :class:`discord.SeparatorSize` The spacing of this separator. row: Optional[:class:`int`] The relative row this separator belongs to. By default @@ -108,3 +110,10 @@ def type(self) -> Literal[ComponentType.separator]: def to_component_dict(self): return self._underlying.to_dict() + + @classmethod + def from_component(cls, component: SeparatorComponent) -> Self: + return cls( + visible=component.visible, + spacing=component.spacing, + ) diff --git a/discord/ui/text_display.py b/discord/ui/text_display.py index a51d604938b6..9a70bd24728b 100644 --- a/discord/ui/text_display.py +++ b/discord/ui/text_display.py @@ -30,6 +30,8 @@ from ..enums import ComponentType if TYPE_CHECKING: + from typing_extensions import Self + from .view import View V = TypeVar('V', bound='View', covariant=True) @@ -77,3 +79,9 @@ def type(self) -> Literal[ComponentType.text_display]: def _is_v2(self) -> bool: return True + + @classmethod + def from_component(cls, component: TextDisplayComponent) -> Self: + return cls( + content=component.content, + ) diff --git a/discord/ui/thumbnail.py b/discord/ui/thumbnail.py index fe1d962218c8..ce178fb4cb5c 100644 --- a/discord/ui/thumbnail.py +++ b/discord/ui/thumbnail.py @@ -48,7 +48,7 @@ class Thumbnail(Item[V]): Parameters ---------- - media: Union[:class:`str`, :class:`UnfurledMediaItem`] + media: Union[:class:`str`, :class:`.UnfurledMediaItem`] The media of the thumbnail. This can be a string that points to a local attachment uploaded within this item. URLs must match the ``attachment://file-name.extension`` structure. diff --git a/discord/ui/view.py b/discord/ui/view.py index 08714bf3a2af..92ec768fa3f4 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -45,6 +45,7 @@ MediaGalleryComponent, FileComponent, SeparatorComponent, + ThumbnailComponent, ) # fmt: off @@ -86,7 +87,30 @@ def _component_to_item(component: Component) -> Item: from .select import BaseSelect return BaseSelect.from_component(component) - # TODO: convert V2 Components into Item's + if isinstance(component, SectionComponent): + from .section import Section + + return Section.from_component(component) + if isinstance(component, TextDisplayComponent): + from .text_display import TextDisplay + + return TextDisplay.from_component(component) + if isinstance(component, MediaGalleryComponent): + from .media_gallery import MediaGallery + + return MediaGallery.from_component(component) + if isinstance(component, FileComponent): + from .file import File + + return File.from_component(component) + if isinstance(component, SeparatorComponent): + from .separator import Separator + + return Separator.from_component(component) + if isinstance(component, ThumbnailComponent): + from .thumbnail import Thumbnail + + return Thumbnail.from_component(component) return Item.from_component(component) diff --git a/docs/api.rst b/docs/api.rst index 934335c5ac70..07e04ca77ae8 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -5427,8 +5427,6 @@ PollAnswer .. autoclass:: PollAnswer() :members: -.. _discord_api_data: - MessageSnapshot ~~~~~~~~~~~~~~~~~ @@ -5445,6 +5443,16 @@ ClientStatus .. autoclass:: ClientStatus() :members: +CallMessage +~~~~~~~~~~~~~~~~~~~ + +.. attributetable:: CallMessage + +.. autoclass:: CallMessage() + :members: + +.. _discord_api_data: + Data Classes -------------- @@ -5748,12 +5756,21 @@ PollMedia .. autoclass:: PollMedia :members: -CallMessage -~~~~~~~~~~~~~~~~~~~ +UnfurledMediaItem +~~~~~~~~~~~~~~~~~ -.. attributetable:: CallMessage +.. attributetable:: UnfurledMediaItem -.. autoclass:: CallMessage() +.. autoclass:: UnfurledMediaItem + :members: + + +MediaGalleryItem +~~~~~~~~~~~~~~~~ + +.. attributetable:: MediaGalleryItem + +.. autoclass:: MediaGalleryItem :members: diff --git a/docs/interactions/api.rst b/docs/interactions/api.rst index feab669073ea..df2d7418dfff 100644 --- a/docs/interactions/api.rst +++ b/docs/interactions/api.rst @@ -113,6 +113,77 @@ TextInput :members: :inherited-members: + +SectionComponent +~~~~~~~~~~~~~~~~ + +.. attributetable:: SectionComponent + +.. autoclass:: SectionComponent() + :members: + :inherited-members: + + +ThumbnailComponent +~~~~~~~~~~~~~~~~~~ + +.. attributetable:: ThumbnailComponent + +.. autoclass:: ThumbnailComponent() + :members: + :inherited-members: + + +TextDisplay +~~~~~~~~~~~ + +.. attributetable:: TextDisplay + +.. autoclass:: TextDisplay() + :members: + :inherited-members: + + +MediaGalleryComponent +~~~~~~~~~~~~~~~~~~~~~ + +.. attributetable:: MediaGalleryComponent + +.. autoclass:: MediaGalleryComponent() + :members: + :inherited-members: + + +FileComponent +~~~~~~~~~~~~~ + +.. attributetable:: FileComponent + +.. autoclass:: FileComponent() + :members: + :inherited-members: + + +SeparatorComponent +~~~~~~~~~~~~~~~~~~ + +.. attributetable:: SeparatorComponent + +.. autoclass:: SeparatorComponent() + :members: + :inherited-members: + + +Container +~~~~~~~~~ + +.. attributetable:: Container + +.. autoclass:: Container() + :members: + :inherited-members: + + AppCommand ~~~~~~~~~~~ @@ -299,7 +370,7 @@ Enumerations .. attribute:: action_row - Represents the group component which holds different components in a row. + Represents a component which holds different components in a row. .. attribute:: button @@ -329,6 +400,38 @@ Enumerations Represents a select in which both users and roles can be selected. + .. attribute:: channel_select + + Represents a channel select component. + + .. attribute:: section + + Represents a component which holds different components in a section. + + .. attribute:: text_display + + Represents a text display component. + + .. attribute:: thumbnail + + Represents a thumbnail component. + + .. attribute:: media_gallery + + Represents a media gallery component. + + .. attribute:: file + + Represents a file component. + + .. attribute:: separator + + Represents a separator component. + + .. attribute:: container + + Represents a component which holds different components in a container. + .. class:: ButtonStyle Represents the style of the button component. @@ -463,6 +566,19 @@ Enumerations The permission is for a user. +.. class:: SeparatorSize + + The separator's size type. + + .. versionadded:: 2.6 + + .. attribute:: small + + A small separator. + .. attribute:: large + + A large separator. + .. _discord_ui_kit: Bot UI Kit @@ -582,6 +698,76 @@ TextInput :members: :inherited-members: + +Container +~~~~~~~~~ + +.. attributetable:: discord.ui.Container + +.. autoclass:: discord.ui.Container + :members: + :inherited-members: + + +File +~~~~ + +.. attributetable:: discord.ui.File + +.. autoclass:: discord.ui.File + :members: + :inherited-members: + + +MediaGallery +~~~~~~~~~~~~ + +.. attributetable:: discord.ui.MediaGallery + +.. autoclass:: discord.ui.MediaGallery + :members: + :inherited-members: + + +Section +~~~~~~~ + +.. attributetable:: discord.ui.Section + +.. autoclass:: discord.ui.Section + :members: + :inherited-members: + + +Separator +~~~~~~~~~ + +.. attributetable:: discord.ui.Separator + +.. autoclass:: discord.ui.Separator + :members: + :inherited-members: + + +TextDisplay +~~~~~~~~~~~ + +.. attributetable:: discord.ui.TextDisplay + +.. autoclass:: discord.ui.TextDisplay + :members: + :inherited-members: + + +Thumbnail +~~~~~~~~~ + +.. attributetable:: discord.ui.Thumbnail + +.. autoclass:: discord.ui.Thumbnail + :members: + :inherited-members: + .. _discord_app_commands: Application Commands From 7824c3f544708cfc044470045539abb19cb56ddd Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 2 Mar 2025 14:18:29 +0100 Subject: [PATCH 017/272] chore: fix everything lol --- discord/components.py | 43 ++++++++++++++++++------------------- discord/message.py | 3 +-- discord/types/components.py | 2 +- discord/ui/section.py | 9 ++++---- discord/ui/thumbnail.py | 5 ++--- discord/ui/view.py | 9 ++------ 6 files changed, 31 insertions(+), 40 deletions(-) diff --git a/discord/components.py b/discord/components.py index cb1a50976379..9769066386f0 100644 --- a/discord/components.py +++ b/discord/components.py @@ -79,6 +79,17 @@ ActionRowChildComponentType = Union['Button', 'SelectMenu', 'TextInput'] SectionComponentType = Union['TextDisplay', 'Button'] + MessageComponentType = Union[ + ActionRowChildComponentType, + SectionComponentType, + 'ActionRow', + 'SectionComponent', + 'ThumbnailComponent', + 'MediaGalleryComponent', + 'FileComponent', + 'SectionComponent', + 'Component', + ] __all__ = ( @@ -337,13 +348,9 @@ def __init__(self, data: SelectMenuPayload, /) -> None: self.placeholder: Optional[str] = data.get('placeholder') self.min_values: int = data.get('min_values', 1) self.max_values: int = data.get('max_values', 1) - self.options: List[SelectOption] = [ - SelectOption.from_dict(option) for option in data.get('options', []) - ] + self.options: List[SelectOption] = [SelectOption.from_dict(option) for option in data.get('options', [])] self.disabled: bool = data.get('disabled', False) - self.channel_types: List[ChannelType] = [ - try_enum(ChannelType, t) for t in data.get('channel_types', []) - ] + self.channel_types: List[ChannelType] = [try_enum(ChannelType, t) for t in data.get('channel_types', [])] self.default_values: List[SelectDefaultValue] = [ SelectDefaultValue.from_dict(d) for d in data.get('default_values', []) ] @@ -459,9 +466,7 @@ def emoji(self, value: Optional[Union[str, Emoji, PartialEmoji]]) -> None: elif isinstance(value, _EmojiTag): self._emoji = value._to_partial() else: - raise TypeError( - f'expected str, Emoji, or PartialEmoji, received {value.__class__.__name__} instead' - ) + raise TypeError(f'expected str, Emoji, or PartialEmoji, received {value.__class__.__name__} instead') else: self._emoji = None @@ -617,9 +622,7 @@ def type(self) -> SelectDefaultValueType: @type.setter def type(self, value: SelectDefaultValueType) -> None: if not isinstance(value, SelectDefaultValueType): - raise TypeError( - f'expected SelectDefaultValueType, received {value.__class__.__name__} instead' - ) + raise TypeError(f'expected SelectDefaultValueType, received {value.__class__.__name__} instead') self._type = value @@ -733,7 +736,7 @@ def __init__(self, data: SectionComponentPayload, state: Optional[ConnectionStat self.components.append(component) # type: ignore # should be the correct type here try: - self.accessory: Optional[Component] = _component_factory(data['accessory']) + self.accessory: Optional[Component] = _component_factory(data['accessory']) # type: ignore except KeyError: self.accessory = None @@ -788,7 +791,7 @@ def type(self) -> Literal[ComponentType.thumbnail]: def to_dict(self) -> ThumbnailComponentPayload: return { - 'media': self.media.to_dict(), # type: ignroe + 'media': self.media.to_dict(), # pyright: ignore[reportReturnType] 'description': self.description, 'spoiler': self.spoiler, 'type': self.type.value, @@ -938,9 +941,7 @@ def __init__( self._state: Optional[ConnectionState] = None @classmethod - def _from_data( - cls, data: MediaGalleryItemPayload, state: Optional[ConnectionState] - ) -> MediaGalleryItem: + def _from_data(cls, data: MediaGalleryItemPayload, state: Optional[ConnectionState]) -> MediaGalleryItem: media = data['media'] self = cls( media=media['url'], @@ -1089,7 +1090,7 @@ def __init__(self, data: ContainerComponentPayload, state: Optional[ConnectionSt self.spoiler: bool = data.get('spoiler', False) self._colour: Optional[Colour] try: - self._colour = Colour(data['accent_color']) + self._colour = Colour(data['accent_color']) # type: ignore except KeyError: self._colour = None @@ -1102,9 +1103,7 @@ def accent_colour(self) -> Optional[Colour]: """Optional[:class:`Color`]: The container's accent color.""" -def _component_factory( - data: ComponentPayload, state: Optional[ConnectionState] = None -) -> Optional[Component]: +def _component_factory(data: ComponentPayload, state: Optional[ConnectionState] = None) -> Optional[Component]: if data['type'] == 1: return ActionRow(data) elif data['type'] == 2: @@ -1112,7 +1111,7 @@ def _component_factory( elif data['type'] == 4: return TextInput(data) elif data['type'] in (3, 5, 6, 7, 8): - return SelectMenu(data) + return SelectMenu(data) # type: ignore elif data['type'] == 9: return SectionComponent(data, state) elif data['type'] == 10: diff --git a/discord/message.py b/discord/message.py index c551138f7d3c..c0a853ce3cba 100644 --- a/discord/message.py +++ b/discord/message.py @@ -96,7 +96,7 @@ from .types.gateway import MessageReactionRemoveEvent, MessageUpdateEvent from .abc import Snowflake from .abc import GuildChannel, MessageableChannel - from .components import ActionRow, ActionRowChildComponentType + from .components import MessageComponentType from .state import ConnectionState from .mentions import AllowedMentions from .user import User @@ -104,7 +104,6 @@ from .ui.view import View EmojiInputType = Union[Emoji, PartialEmoji, str] - MessageComponentType = Union[ActionRow, ActionRowChildComponentType] __all__ = ( diff --git a/discord/types/components.py b/discord/types/components.py index 68aa6156df8d..98201817ad3d 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -59,7 +59,7 @@ class ButtonComponent(ComponentBase): sku_id: NotRequired[str] -class SelectOption(ComponentBase): +class SelectOption(TypedDict): label: str value: str default: bool diff --git a/discord/ui/section.py b/discord/ui/section.py index fece9b053f13..f2b6554cad46 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -72,10 +72,8 @@ def __init__( ) -> None: super().__init__() if len(children) > 3: - raise ValueError('maximum number of children exceeded') - self._children: List[Item[Any]] = [ - c if isinstance(c, Item) else TextDisplay(c) for c in children - ] + raise ValueError('maximum number of children exceeded') + self._children: List[Item[Any]] = [c if isinstance(c, Item) else TextDisplay(c) for c in children] self.accessory: Optional[Item[Any]] = accessory self.row = row @@ -150,7 +148,8 @@ def clear_items(self) -> Self: @classmethod def from_component(cls, component: SectionComponent) -> Self: - from .view import _component_to_item # >circular import< + from .view import _component_to_item # >circular import< + return cls( children=[_component_to_item(c) for c in component.components], accessory=_component_to_item(component.accessory) if component.accessory else None, diff --git a/discord/ui/thumbnail.py b/discord/ui/thumbnail.py index ce178fb4cb5c..05e68b88166b 100644 --- a/discord/ui/thumbnail.py +++ b/discord/ui/thumbnail.py @@ -37,9 +37,8 @@ V = TypeVar('V', bound='View', covariant=True) -__all__ = ( - 'Thumbnail', -) +__all__ = ('Thumbnail',) + class Thumbnail(Item[V]): """Represents a UI Thumbnail. diff --git a/discord/ui/view.py b/discord/ui/view.py index 92ec768fa3f4..8dd7ca2d4628 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -23,7 +23,7 @@ """ from __future__ import annotations -from typing import Any, Callable, ClassVar, Coroutine, Dict, Iterator, List, Optional, Sequence, TYPE_CHECKING, Tuple, Type, Union +from typing import Any, Callable, ClassVar, Coroutine, Dict, Iterator, List, Optional, Sequence, TYPE_CHECKING, Tuple, Type from functools import partial from itertools import groupby @@ -709,12 +709,7 @@ async def schedule_dynamic_item_call( item._view = view item._rendered_row = base_item._rendered_row item._refresh_state(interaction, interaction.data) # type: ignore -The relative row this text display belongs to. By default - items are arranged automatically into those 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 9 (i.e. zero indexed) + try: allow = await item.interaction_check(interaction) except Exception: From 5d1300d9fc84bcbc7654b5c4435d7436f093aaa9 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 2 Mar 2025 14:26:01 +0100 Subject: [PATCH 018/272] fix: documentation errors --- discord/components.py | 2 ++ discord/ui/file.py | 2 +- discord/ui/media_gallery.py | 2 +- discord/ui/separator.py | 2 +- discord/ui/thumbnail.py | 2 +- discord/ui/view.py | 6 +++--- 6 files changed, 9 insertions(+), 7 deletions(-) diff --git a/discord/components.py b/discord/components.py index 9769066386f0..c09adb9136c1 100644 --- a/discord/components.py +++ b/discord/components.py @@ -107,6 +107,8 @@ 'MediaGalleryComponent', 'FileComponent', 'SectionComponent', + 'Container', + 'TextDisplay', ) diff --git a/discord/ui/file.py b/discord/ui/file.py index fabf5b0f31d0..84ef4ef527ec 100644 --- a/discord/ui/file.py +++ b/discord/ui/file.py @@ -46,7 +46,7 @@ class File(Item[V]): Parameters ---------- - media: Union[:class:`str`, :class:`.UnfurledMediaItem`] + media: Union[:class:`str`, :class:`discord.UnfurledMediaItem`] This file's media. If this is a string itmust point to a local file uploaded within the parent view of this item, and must meet the ``attachment://file-name.extension`` structure. diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index 93638d7f6690..b2da65df0ded 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -45,7 +45,7 @@ class MediaGallery(Item[V]): """Represents a UI media gallery. - This can contain up to 10 :class:`MediaGalleryItem`s. + This can contain up to 10 :class:`.MediaGalleryItem`s. .. versionadded:: 2.6 diff --git a/discord/ui/separator.py b/discord/ui/separator.py index cc49adecb235..2eadd2a4bc40 100644 --- a/discord/ui/separator.py +++ b/discord/ui/separator.py @@ -49,7 +49,7 @@ class Separator(Item[V]): visible: :class:`bool` Whether this separator is visible. On the client side this is whether a divider line should be shown or not. - spacing: :class:`discord.SeparatorSize` + spacing: :class:`.SeparatorSize` The spacing of this separator. row: Optional[:class:`int`] The relative row this separator belongs to. By default diff --git a/discord/ui/thumbnail.py b/discord/ui/thumbnail.py index 05e68b88166b..cf9bfd3cc3d0 100644 --- a/discord/ui/thumbnail.py +++ b/discord/ui/thumbnail.py @@ -47,7 +47,7 @@ class Thumbnail(Item[V]): Parameters ---------- - media: Union[:class:`str`, :class:`.UnfurledMediaItem`] + media: Union[:class:`str`, :class:`discord.UnfurledMediaItem`] The media of the thumbnail. This can be a string that points to a local attachment uploaded within this item. URLs must match the ``attachment://file-name.extension`` structure. diff --git a/discord/ui/view.py b/discord/ui/view.py index 8dd7ca2d4628..e701d09e976d 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -61,7 +61,7 @@ from ..interactions import Interaction from ..message import Message - from ..types.components import Component as ComponentPayload + from ..types.components import ComponentBase as ComponentBasePayload from ..types.interactions import ModalSubmitComponentInteractionData as ModalSubmitComponentInteractionDataPayload from ..state import ConnectionState from .modal import Modal @@ -802,11 +802,11 @@ def is_message_tracked(self, message_id: int) -> bool: def remove_message_tracking(self, message_id: int) -> Optional[View]: return self._synced_message_views.pop(message_id, None) - def update_from_message(self, message_id: int, data: List[ComponentPayload]) -> None: + def update_from_message(self, message_id: int, data: List[ComponentBasePayload]) -> None: components: List[Component] = [] for component_data in data: - component = _component_factory(component_data, self._state) + component = _component_factory(component_data, self._state) # type: ignore if component is not None: components.append(component) From a1bc73b51b1b9570481fbde9df5ce4e70d4dfb2f Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 2 Mar 2025 14:28:31 +0100 Subject: [PATCH 019/272] fix: add SeparatorComponent to __all__ --- discord/components.py | 1 + 1 file changed, 1 insertion(+) diff --git a/discord/components.py b/discord/components.py index c09adb9136c1..b12de4f5efa2 100644 --- a/discord/components.py +++ b/discord/components.py @@ -109,6 +109,7 @@ 'SectionComponent', 'Container', 'TextDisplay', + 'SeparatorComponent', ) From 14d8f315362087a90d7af0ce0dceefa04dfa71fd Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 2 Mar 2025 14:37:49 +0100 Subject: [PATCH 020/272] chore: Add missing enums to docs and fix docstrings --- discord/components.py | 7 ++----- discord/ui/container.py | 7 +++---- discord/ui/media_gallery.py | 10 +++++----- docs/api.rst | 21 +++++++++++++++++++++ 4 files changed, 31 insertions(+), 14 deletions(-) diff --git a/discord/components.py b/discord/components.py index b12de4f5efa2..d528f02ce409 100644 --- a/discord/components.py +++ b/discord/components.py @@ -769,7 +769,7 @@ class ThumbnailComponent(Component): Attributes ---------- - media: :class:`UnfurledAttachment` + media: :class:`UnfurledMediaItem` The media for this thumbnail. description: Optional[:class:`str`] The description shown within this thumbnail. @@ -832,9 +832,6 @@ class UnfurledMediaItem(AssetMixin): """Represents an unfurled media item that can be used on :class:`MediaGalleryItem`s. - Unlike :class:`UnfurledAttachment` this represents a media item - not yet stored on Discord and thus it does not have any data. - Parameters ---------- url: :class:`str` @@ -1004,7 +1001,7 @@ class FileComponent(Component): Attributes ---------- - media: :class:`UnfurledAttachment` + media: :class:`UnfurledMediaItem` The unfurled attachment contents of the file. spoiler: :class:`bool` Whether this file is flagged as a spoiler. diff --git a/discord/ui/container.py b/discord/ui/container.py index 978781dd8470..170d6eeca92d 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -42,7 +42,7 @@ class Container(View, Item[V]): - """Represents a Components V2 Container. + """Represents a UI container. .. versionadded:: 2.6 @@ -53,7 +53,7 @@ class Container(View, Item[V]): items. accent_colour: Optional[:class:`.Colour`] The colour of the container. Defaults to ``None``. - accent_color: Optional[:class:`.Color`] + accent_color: Optional[:class:`.Colour`] The color of the container. Defaults to ``None``. spoiler: :class:`bool` Whether to flag this container as a spoiler. Defaults @@ -103,7 +103,7 @@ def children(self, value: List[Item[Any]]) -> None: @property def accent_colour(self) -> Optional[Colour]: - """Optional[:class:`~discord.Colour`]: The colour of the container, or ``None``.""" + """Optional[:class:`discord.Colour`]: The colour of the container, or ``None``.""" return self._colour @accent_colour.setter @@ -111,7 +111,6 @@ def accent_colour(self, value: Optional[Colour]) -> None: self._colour = value accent_color = accent_colour - """Optional[:class:`~discord.Color`]: The color of the container, or ``None``.""" @property def type(self) -> Literal[ComponentType.container]: diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index b2da65df0ded..88991d40bd46 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -73,7 +73,7 @@ def __init__(self, items: List[MediaGalleryItem], *, row: Optional[int] = None) @property def items(self) -> List[MediaGalleryItem]: - """List[:class:`MediaGalleryItem`]: Returns a read-only list of this gallery's items.""" + """List[:class:`.MediaGalleryItem`]: Returns a read-only list of this gallery's items.""" return self._underlying.items.copy() @items.setter @@ -97,13 +97,13 @@ def add_item(self, item: MediaGalleryItem) -> Self: Parameters ---------- - item: :class:`MediaGalleryItem` + item: :class:`.MediaGalleryItem` The item to add to the gallery. Raises ------ TypeError - A :class:`MediaGalleryItem` was not passed. + A :class:`.MediaGalleryItem` was not passed. ValueError Maximum number of items has been exceeded (10). """ @@ -125,7 +125,7 @@ def remove_item(self, item: MediaGalleryItem) -> Self: Parameters ---------- - item: :class:`MediaGalleryItem` + item: :class:`.MediaGalleryItem` The item to remove from the gallery. """ @@ -145,7 +145,7 @@ def insert_item_at(self, index: int, item: MediaGalleryItem) -> Self: ---------- index: :class:`int` The index of where to insert the item. - item: :class:`MediaGalleryItem` + item: :class:`.MediaGalleryItem` The item to insert. """ diff --git a/docs/api.rst b/docs/api.rst index 07e04ca77ae8..d331715c30c0 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -3863,6 +3863,27 @@ of :class:`enum.Enum`. An alias for :attr:`.reply`. + +.. class:: MediaItemLoadingState + + Represents a :class:`UnfurledMediaItem` load state. + + .. attribute:: unknown + + Unknown load state. + + .. attribute:: loading + + The media item is still loading. + + .. attribute:: loaded + + The media item is loaded. + + .. attribute:: not_found + + The media item was not found. + .. _discord-api-audit-logs: Audit Log Data From 4202ef4c7ea08803b66b7cda25f9ef2031a4f24c Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 2 Mar 2025 14:39:49 +0100 Subject: [PATCH 021/272] chore: Format ValueError no row.setter to show the maxrow and not 10 --- discord/ui/item.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/item.py b/discord/ui/item.py index aaf15cee648b..bbd90464a603 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -113,7 +113,7 @@ def row(self, value: Optional[int]) -> None: elif self._max_row > value >= 0: self._row = value else: - raise ValueError('row cannot be negative or greater than or equal to 10') + raise ValueError(f'row cannot be negative or greater than or equal to {self._max_row}') @property def width(self) -> int: From 15ec28b8701bb27355745106b1a86c30b8f4d9dd Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 2 Mar 2025 14:42:43 +0100 Subject: [PATCH 022/272] chore: yet more docs fix --- discord/components.py | 5 +++-- discord/ui/file.py | 4 ++-- discord/ui/separator.py | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/discord/components.py b/discord/components.py index d528f02ce409..ed15e1c48167 100644 --- a/discord/components.py +++ b/discord/components.py @@ -839,8 +839,10 @@ class UnfurledMediaItem(AssetMixin): Attributes ---------- + url: :class:`str` + The URL of this media item. proxy_url: Optional[:class:`str`] - The proxy URL. This is a cached version of the :attr:`~UnfurledMediaItem.url` in the + The proxy URL. This is a cached version of the :attr:`.url` in the case of images. When the message is deleted, this URL might be valid for a few minutes or not valid at all. height: Optional[:class:`int`] @@ -1100,7 +1102,6 @@ def accent_colour(self) -> Optional[Colour]: return self._colour accent_color = accent_colour - """Optional[:class:`Color`]: The container's accent color.""" def _component_factory(data: ComponentPayload, state: Optional[ConnectionState] = None) -> Optional[Component]: diff --git a/discord/ui/file.py b/discord/ui/file.py index 84ef4ef527ec..3ff6c7d0f04f 100644 --- a/discord/ui/file.py +++ b/discord/ui/file.py @@ -46,7 +46,7 @@ class File(Item[V]): Parameters ---------- - media: Union[:class:`str`, :class:`discord.UnfurledMediaItem`] + media: Union[:class:`str`, :class:`.UnfurledMediaItem`] This file's media. If this is a string itmust point to a local file uploaded within the parent view of this item, and must meet the ``attachment://file-name.extension`` structure. @@ -89,7 +89,7 @@ def type(self) -> Literal[ComponentType.file]: @property def media(self) -> UnfurledMediaItem: - """:class:`UnfurledMediaItem`: Returns this file media.""" + """:class:`.UnfurledMediaItem`: Returns this file media.""" return self._underlying.media @media.setter diff --git a/discord/ui/separator.py b/discord/ui/separator.py index 2eadd2a4bc40..33401f880b95 100644 --- a/discord/ui/separator.py +++ b/discord/ui/separator.py @@ -93,7 +93,7 @@ def visible(self, value: bool) -> None: @property def spacing(self) -> SeparatorSize: - """:class:`SeparatorSize`: The spacing of this separator.""" + """:class:`.SeparatorSize`: The spacing of this separator.""" return self._underlying.spacing @spacing.setter From 86d967cbcd364279291e9a12e22d8adeaed9f7b5 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 2 Mar 2025 14:47:15 +0100 Subject: [PATCH 023/272] fix: Docs failing due to :class: ames --- discord/components.py | 3 +-- discord/ui/container.py | 2 +- discord/ui/media_gallery.py | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/discord/components.py b/discord/components.py index ed15e1c48167..962be86f7038 100644 --- a/discord/components.py +++ b/discord/components.py @@ -829,8 +829,7 @@ def to_dict(self) -> TextComponentPayload: class UnfurledMediaItem(AssetMixin): - """Represents an unfurled media item that can be used on - :class:`MediaGalleryItem`s. + """Represents an unfurled media item. Parameters ---------- diff --git a/discord/ui/container.py b/discord/ui/container.py index 170d6eeca92d..b49e1a7007fc 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -49,7 +49,7 @@ class Container(View, Item[V]): Parameters ---------- children: List[:class:`Item`] - The initial children or :class:`View`s of this container. Can have up to 10 + The initial children or :class:`View` s of this container. Can have up to 10 items. accent_colour: Optional[:class:`.Colour`] The colour of the container. Defaults to ``None``. diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index 88991d40bd46..4bc6c826f630 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -45,7 +45,7 @@ class MediaGallery(Item[V]): """Represents a UI media gallery. - This can contain up to 10 :class:`.MediaGalleryItem`s. + This can contain up to 10 :class:`.MediaGalleryItem` s. .. versionadded:: 2.6 From e7693d91346ecae3effbe9bf6cfdb8f93e884aa7 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 2 Mar 2025 14:59:40 +0100 Subject: [PATCH 024/272] chore: buttons cannot be outside action rows --- discord/ui/button.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/discord/ui/button.py b/discord/ui/button.py index b4df36aed8a9..43bd3a8b0f9d 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -73,11 +73,10 @@ class Button(Item[V]): The emoji of the button, if available. row: Optional[:class:`int`] The relative row this button belongs to. A Discord component can only have 5 - rows in a :class:`View`, but up to 10 on a :class:`Container`. By default, - items are arranged automatically into those 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 or 9 (i.e. zero indexed). + 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). sku_id: Optional[:class:`int`] The SKU ID this button sends you to. Can't be combined with ``url``, ``label``, ``emoji`` nor ``custom_id``. @@ -305,11 +304,10 @@ def button( or a full :class:`.Emoji`. row: Optional[:class:`int`] The relative row this button belongs to. A Discord component can only have 5 - rows in a :class:`View`, but up to 10 on a :class:`Container`. By default, - items are arranged automatically into those 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 or 9 (i.e. zero indexed). + 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). """ def decorator(func: ItemCallbackType[V, Button[V]]) -> ItemCallbackType[V, Button[V]]: From 5fda19eb917f4ff346b5ffad8eb209e6f8e7b46b Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 2 Mar 2025 15:05:41 +0100 Subject: [PATCH 025/272] chore: add ui.Section.is_dispatchable --- discord/ui/section.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/discord/ui/section.py b/discord/ui/section.py index f2b6554cad46..f8b8ea4e294f 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -89,6 +89,15 @@ def width(self): def _is_v2(self) -> bool: return True + # Accessory can be a button, and thus it can have a callback so, maybe + # allow for section to be dispatchable and make the callback func + # be accessory component callback, only called if accessory is + # dispatchable? + def is_dispatchable(self) -> bool: + if self.accessory: + return self.accessory.is_dispatchable() + return False + def add_item(self, item: Union[str, Item[Any]]) -> Self: """Adds an item to this section. From 0a0396889c9fc9014c9a5dc1d05d4e23383dfa3d Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 2 Mar 2025 16:41:10 +0100 Subject: [PATCH 026/272] chore: fix errors TextDisplay attribute error when doing TextDisplay.to_component_dict() View.to_components() appending Item objects instead of Item.to_component_dict() Changed View.to_components() sorting key --- discord/components.py | 9 ++------- discord/types/components.py | 2 +- discord/ui/container.py | 22 ++++++++++++--------- discord/ui/section.py | 38 +++++++++++++++++++++++-------------- discord/ui/text_display.py | 10 +++++----- discord/ui/view.py | 38 ++++++++++++++++++++++--------------- 6 files changed, 68 insertions(+), 51 deletions(-) diff --git a/discord/components.py b/discord/components.py index 962be86f7038..f06eda2f6948 100644 --- a/discord/components.py +++ b/discord/components.py @@ -732,17 +732,13 @@ class SectionComponent(Component): def __init__(self, data: SectionComponentPayload, state: Optional[ConnectionState]) -> None: self.components: List[SectionComponentType] = [] + self.accessory: Component = _component_factory(data['accessory'], state) for component_data in data['components']: component = _component_factory(component_data, state) if component is not None: self.components.append(component) # type: ignore # should be the correct type here - try: - self.accessory: Optional[Component] = _component_factory(data['accessory']) # type: ignore - except KeyError: - self.accessory = None - @property def type(self) -> Literal[ComponentType.section]: return ComponentType.section @@ -751,9 +747,8 @@ def to_dict(self) -> SectionComponentPayload: payload: SectionComponentPayload = { 'type': self.type.value, 'components': [c.to_dict() for c in self.components], + 'accessory': self.accessory.to_dict() } - if self.accessory: - payload['accessory'] = self.accessory.to_dict() return payload diff --git a/discord/types/components.py b/discord/types/components.py index 98201817ad3d..bb241c9ac6ff 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -128,7 +128,7 @@ class SelectMenu(SelectComponent): class SectionComponent(ComponentBase): type: Literal[9] components: List[Union[TextComponent, ButtonComponent]] - accessory: NotRequired[ComponentBase] + accessory: ComponentBase class TextComponent(ComponentBase): diff --git a/discord/ui/container.py b/discord/ui/container.py index b49e1a7007fc..2acf95d20bad 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -29,6 +29,7 @@ from .view import View, _component_to_item from .dynamic import DynamicItem from ..enums import ComponentType +from ..utils import MISSING if TYPE_CHECKING: from typing_extensions import Self @@ -61,13 +62,20 @@ class Container(View, Item[V]): timeout: Optional[:class:`float`] Timeout in seconds from last interaction with the UI before no longer accepting input. If ``None`` then there is no timeout. + row: Optional[:class:`int`] + The relative row this container belongs to. By default + items are arranged automatically into those 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 9 (i.e. zero indexed) """ __discord_ui_container__ = True def __init__( self, - children: List[Item[Any]], + children: List[Item[Any]] = MISSING, *, accent_colour: Optional[Colour] = None, accent_color: Optional[Color] = None, @@ -76,9 +84,10 @@ def __init__( row: Optional[int] = None, ) -> None: super().__init__(timeout=timeout) - if len(children) + len(self._children) > 10: - raise ValueError('maximum number of components exceeded') - self._children.extend(children) + if children is not MISSING: + if len(children) + len(self._children) > 10: + raise ValueError('maximum number of components exceeded') + self._children.extend(children) self.spoiler: bool = spoiler self._colour = accent_colour or accent_color @@ -87,11 +96,6 @@ def __init__( self._rendered_row: Optional[int] = None self.row: Optional[int] = row - def _init_children(self) -> List[Item[Self]]: - if self.__weights.max_weight != 10: - self.__weights.max_weight = 10 - return super()._init_children() - @property def children(self) -> List[Item[Self]]: """List[:class:`Item`]: The children of this container.""" diff --git a/discord/ui/section.py b/discord/ui/section.py index f8b8ea4e294f..ce87b99f4bf4 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -28,6 +28,7 @@ from .item import Item from .text_display import TextDisplay from ..enums import ComponentType +from ..utils import MISSING if TYPE_CHECKING: from typing_extensions import Self @@ -37,6 +38,8 @@ V = TypeVar('V', bound='View', covariant=True) +__all__ = ('Section',) + class Section(Item[V]): """Represents a UI section. @@ -47,8 +50,8 @@ class Section(Item[V]): ---------- children: List[Union[:class:`str`, :class:`TextDisplay`]] The text displays of this section. Up to 3. - accessory: Optional[:class:`Item`] - The section accessory. Defaults to ``None``. + accessory: :class:`Item` + The section accessory. row: Optional[:class:`int`] The relative row this section belongs to. By default items are arranged automatically into those rows. If you'd @@ -65,16 +68,23 @@ class Section(Item[V]): def __init__( self, - children: List[Union[Item[Any], str]], + children: List[Union[Item[Any], str]] = MISSING, *, - accessory: Optional[Item[Any]] = None, + accessory: Item[Any], row: Optional[int] = None, ) -> None: super().__init__() - if len(children) > 3: - raise ValueError('maximum number of children exceeded') - self._children: List[Item[Any]] = [c if isinstance(c, Item) else TextDisplay(c) for c in children] - self.accessory: Optional[Item[Any]] = accessory + self._children: List[Item[Any]] = [] + if children is not MISSING: + if len(children) > 3: + raise ValueError('maximum number of children exceeded') + self._children.extend( + [ + c if isinstance(c, Item) + else TextDisplay(c) for c in children + ], + ) + self.accessory: Item[Any] = accessory self.row = row @@ -106,13 +116,14 @@ def add_item(self, item: Union[str, Item[Any]]) -> Self: Parameters ---------- - item: Union[:class:`str`, :class:`TextDisplay`] - The text display to add. + item: Union[:class:`str`, :class:`Item`] + The items to append, if it is a string it automatically wrapped around + :class:`TextDisplay`. Raises ------ TypeError - A :class:`TextDisplay` was not passed. + An :class:`Item` or :class:`str` was not passed. ValueError Maximum number of children has been exceeded (3). """ @@ -161,14 +172,13 @@ def from_component(cls, component: SectionComponent) -> Self: return cls( children=[_component_to_item(c) for c in component.components], - accessory=_component_to_item(component.accessory) if component.accessory else None, + accessory=_component_to_item(component.accessory), ) def to_component_dict(self) -> Dict[str, Any]: data = { 'components': [c.to_component_dict() for c in self._children], 'type': self.type.value, + 'accessory': self.accessory.to_component_dict() } - if self.accessory: - data['accessory'] = self.accessory.to_component_dict() return data diff --git a/discord/ui/text_display.py b/discord/ui/text_display.py index 9a70bd24728b..1bf88678d798 100644 --- a/discord/ui/text_display.py +++ b/discord/ui/text_display.py @@ -60,14 +60,14 @@ class TextDisplay(Item[V]): def __init__(self, content: str, *, row: Optional[int] = None) -> None: super().__init__() self.content: str = content - self._underlying = TextDisplayComponent._raw_construct( - content=content, - ) self.row = row def to_component_dict(self): - return self._underlying.to_dict() + return { + 'type': self.type.value, + 'content': self.content, + } @property def width(self): @@ -75,7 +75,7 @@ def width(self): @property def type(self) -> Literal[ComponentType.text_display]: - return self._underlying.type + return ComponentType.text_display def _is_v2(self) -> bool: return True diff --git a/discord/ui/view.py b/discord/ui/view.py index e701d09e976d..6ac69d66e1b8 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -23,7 +23,7 @@ """ from __future__ import annotations -from typing import Any, Callable, ClassVar, Coroutine, Dict, Iterator, List, Optional, Sequence, TYPE_CHECKING, Tuple, Type +from typing import Any, Callable, ClassVar, Coroutine, Dict, Iterator, List, Optional, Sequence, TYPE_CHECKING, Tuple, Type, Union from functools import partial from itertools import groupby @@ -119,13 +119,11 @@ class _ViewWeights: # fmt: off __slots__ = ( 'weights', - 'max_weight', ) # fmt: on def __init__(self, children: List[Item]): self.weights: List[int] = [0, 0, 0, 0, 0] - self.max_weight: int = 5 key = lambda i: sys.maxsize if i.row is None else i.row children = sorted(children, key=key) @@ -146,8 +144,8 @@ def add_item(self, item: Item) -> None: self.weights.extend([0, 0, 0, 0, 0]) if item.row is not None: total = self.weights[item.row] + item.width - if total > self.max_weight: - raise ValueError(f'item would not fit at row {item.row} ({total} > {self.max_weight} width)') + if total > 5: + raise ValueError(f'item would not fit at row {item.row} ({total} > 5 width)') self.weights[item.row] = total item._rendered_row = item.row else: @@ -196,15 +194,15 @@ class View: __discord_ui_view__: ClassVar[bool] = True __discord_ui_modal__: ClassVar[bool] = False __discord_ui_container__: ClassVar[bool] = False - __view_children_items__: ClassVar[List[ItemCallbackType[Any, Any]]] = [] + __view_children_items__: ClassVar[List[Union[ItemCallbackType[Any, Any], Item[Any]]]] = [] def __init_subclass__(cls) -> None: super().__init_subclass__() - children: Dict[str, ItemCallbackType[Any, Any]] = {} + children: Dict[str, Union[ItemCallbackType[Any, Any], Item]] = {} for base in reversed(cls.__mro__): for name, member in base.__dict__.items(): - if hasattr(member, '__discord_ui_model_type__'): + if hasattr(member, '__discord_ui_model_type__') or isinstance(member, Item): children[name] = member if len(children) > 25: @@ -214,12 +212,16 @@ def __init_subclass__(cls) -> None: def _init_children(self) -> List[Item[Self]]: children = [] + for func in self.__view_children_items__: - item: Item = func.__discord_ui_model_type__(**func.__discord_ui_model_kwargs__) - item.callback = _ViewCallback(func, self, item) # type: ignore - item._view = self - setattr(self, func.__name__, item) - children.append(item) + if isinstance(func, Item): + children.append(func) + else: + item: Item = func.__discord_ui_model_type__(**func.__discord_ui_model_kwargs__) + item.callback = _ViewCallback(func, self, item) # type: ignore + item._view = self + setattr(self, func.__name__, item) + children.append(item) return children def __init__(self, *, timeout: Optional[float] = 180.0): @@ -275,7 +277,13 @@ def to_components(self) -> List[Dict[str, Any]]: # v2 components def key(item: Item) -> int: - return item._rendered_row or 0 + if item._rendered_row is not None: + return item._rendered_row + + try: + return self._children.index(item) + except ValueError: + return 0 # instead of grouping by row we will sort it so it is added # in order and should work as the original implementation @@ -290,7 +298,7 @@ def key(item: Item) -> int: index = rows_index.get(row) if index is not None: - components[index]['components'].append(child) + components[index]['components'].append(child.to_component_dict()) else: components.append( { From a4389cbe7e60cee8473763abd0d1da4d27d32c0d Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 2 Mar 2025 16:42:54 +0100 Subject: [PATCH 027/272] chore: Revert change View.to_components() sorting key --- discord/ui/view.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index 6ac69d66e1b8..208299c3a2b4 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -277,13 +277,7 @@ def to_components(self) -> List[Dict[str, Any]]: # v2 components def key(item: Item) -> int: - if item._rendered_row is not None: - return item._rendered_row - - try: - return self._children.index(item) - except ValueError: - return 0 + return item._rendered_row or 0 # instead of grouping by row we will sort it so it is added # in order and should work as the original implementation From 70bdcfa0b741b55e7566f6c21ebeadd980f9b202 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 2 Mar 2025 17:03:03 +0100 Subject: [PATCH 028/272] chore: Update item _view attr and # type: ignore self.accessory in section --- discord/components.py | 2 +- discord/ui/view.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/discord/components.py b/discord/components.py index f06eda2f6948..af3e23e7b873 100644 --- a/discord/components.py +++ b/discord/components.py @@ -732,7 +732,7 @@ class SectionComponent(Component): def __init__(self, data: SectionComponentPayload, state: Optional[ConnectionState]) -> None: self.components: List[SectionComponentType] = [] - self.accessory: Component = _component_factory(data['accessory'], state) + self.accessory: Component = _component_factory(data['accessory'], state) # type: ignore for component_data in data['components']: component = _component_factory(component_data, state) diff --git a/discord/ui/view.py b/discord/ui/view.py index 208299c3a2b4..3769d4c4c92f 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -215,6 +215,7 @@ def _init_children(self) -> List[Item[Self]]: for func in self.__view_children_items__: if isinstance(func, Item): + func._view = self children.append(func) else: item: Item = func.__discord_ui_model_type__(**func.__discord_ui_model_kwargs__) From 568a3c396fec5359f51dc9894155d757d012df7b Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 2 Mar 2025 17:04:12 +0100 Subject: [PATCH 029/272] chore: Run black --- discord/components.py | 2 +- discord/ui/section.py | 7 ++----- discord/ui/view.py | 16 +++++++++++++++- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/discord/components.py b/discord/components.py index af3e23e7b873..fc9a77de80ae 100644 --- a/discord/components.py +++ b/discord/components.py @@ -747,7 +747,7 @@ def to_dict(self) -> SectionComponentPayload: payload: SectionComponentPayload = { 'type': self.type.value, 'components': [c.to_dict() for c in self.components], - 'accessory': self.accessory.to_dict() + 'accessory': self.accessory.to_dict(), } return payload diff --git a/discord/ui/section.py b/discord/ui/section.py index ce87b99f4bf4..c0dfbfae74de 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -79,10 +79,7 @@ def __init__( if len(children) > 3: raise ValueError('maximum number of children exceeded') self._children.extend( - [ - c if isinstance(c, Item) - else TextDisplay(c) for c in children - ], + [c if isinstance(c, Item) else TextDisplay(c) for c in children], ) self.accessory: Item[Any] = accessory @@ -179,6 +176,6 @@ def to_component_dict(self) -> Dict[str, Any]: data = { 'components': [c.to_component_dict() for c in self._children], 'type': self.type.value, - 'accessory': self.accessory.to_component_dict() + 'accessory': self.accessory.to_component_dict(), } return data diff --git a/discord/ui/view.py b/discord/ui/view.py index 3769d4c4c92f..92910e7a6b3a 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -23,7 +23,21 @@ """ from __future__ import annotations -from typing import Any, Callable, ClassVar, Coroutine, Dict, Iterator, List, Optional, Sequence, TYPE_CHECKING, Tuple, Type, Union +from typing import ( + Any, + Callable, + ClassVar, + Coroutine, + Dict, + Iterator, + List, + Optional, + Sequence, + TYPE_CHECKING, + Tuple, + Type, + Union, +) from functools import partial from itertools import groupby From bfae3a5183b390ea05962d6144015a385bfe0079 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 2 Mar 2025 17:13:08 +0100 Subject: [PATCH 030/272] chore: Make type the first key on to_components_dict --- discord/components.py | 2 +- discord/ui/section.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/components.py b/discord/components.py index fc9a77de80ae..4321d79dc7e0 100644 --- a/discord/components.py +++ b/discord/components.py @@ -1018,9 +1018,9 @@ def type(self) -> Literal[ComponentType.file]: def to_dict(self) -> FileComponentPayload: return { + 'type': self.type.value, 'file': self.media.to_dict(), # type: ignore 'spoiler': self.spoiler, - 'type': self.type.value, } diff --git a/discord/ui/section.py b/discord/ui/section.py index c0dfbfae74de..5a0ec7f27398 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -174,8 +174,8 @@ def from_component(cls, component: SectionComponent) -> Self: def to_component_dict(self) -> Dict[str, Any]: data = { - 'components': [c.to_component_dict() for c in self._children], 'type': self.type.value, + 'components': [c.to_component_dict() for c in self._children], 'accessory': self.accessory.to_component_dict(), } return data From 869b68f68b899d9f2a1a7b08f01fce2df13cbfa9 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 2 Mar 2025 17:44:45 +0100 Subject: [PATCH 031/272] fix: _ViewWeights.v2_weights always returning False --- discord/ui/view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index 92910e7a6b3a..e490f1444cee 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -176,7 +176,7 @@ def clear(self) -> None: self.weights = [0, 0, 0, 0, 0] def v2_weights(self) -> bool: - return sum(1 if w > 0 else 0 for w in self.weights) > 5 + return len(self.weights) > 5 class _ViewCallback: From faa31ffc5270bca4c4d394c8bcc7c7e8b8fc8e9b Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 2 Mar 2025 19:57:03 +0100 Subject: [PATCH 032/272] chore: Add notes and versionaddeds --- discord/components.py | 67 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 3 deletions(-) diff --git a/discord/components.py b/discord/components.py index 4321d79dc7e0..ac4ff987e51e 100644 --- a/discord/components.py +++ b/discord/components.py @@ -719,7 +719,7 @@ class SectionComponent(Component): ---------- components: List[Union[:class:`TextDisplay`, :class:`Button`]] The components on this section. - accessory: Optional[:class:`Component`] + accessory: :class:`Component` The section accessory. """ @@ -762,6 +762,8 @@ class ThumbnailComponent(Component): The user constructible and usable type to create a thumbnail is :class:`discord.ui.Thumbnail` not this one. + .. versionadded:: 2.6 + Attributes ---------- media: :class:`UnfurledMediaItem` @@ -772,7 +774,13 @@ class ThumbnailComponent(Component): Whether this thumbnail is flagged as a spoiler. """ - __slots__ = () + __slots__ = ( + 'media', + 'spoiler', + 'description', + ) + + __repr_info__ = __slots__ def __init__( self, @@ -801,6 +809,11 @@ class TextDisplay(Component): This inherits from :class:`Component`. + .. note:: + + The user constructible and usable type to create a text display is + :class:`discord.ui.TextDisplay` not this one. + .. versionadded:: 2.6 Attributes @@ -809,6 +822,10 @@ class TextDisplay(Component): The content that this display shows. """ + __slots__ = ('content',) + + __repr_info__ = __slots__ + def __init__(self, data: TextComponentPayload) -> None: self.content: str = data['content'] @@ -826,6 +843,8 @@ def to_dict(self) -> TextComponentPayload: class UnfurledMediaItem(AssetMixin): """Represents an unfurled media item. + .. versionadded:: 2.6 + Parameters ---------- url: :class:`str` @@ -896,6 +915,9 @@ def _update(self, data: UnfurledMediaItemPayload, state: Optional[ConnectionStat self.loading_state = try_enum(MediaItemLoadingState, data['loading_state']) self._state = state + def __repr__(self) -> str: + return f'' + def to_dict(self): return { 'url': self.url, @@ -905,6 +927,8 @@ def to_dict(self): class MediaGalleryItem: """Represents a :class:`MediaGalleryComponent` media item. + .. versionadded:: 2.6 + Parameters ---------- media: Union[:class:`str`, :class:`UnfurledMediaItem`] @@ -936,6 +960,9 @@ def __init__( self.spoiler: bool = spoiler self._state: Optional[ConnectionState] = None + def __repr__(self) -> str: + return f'' + @classmethod def _from_data(cls, data: MediaGalleryItemPayload, state: Optional[ConnectionState]) -> MediaGalleryItem: media = data['media'] @@ -968,13 +995,22 @@ class MediaGalleryComponent(Component): This inherits from :class:`Component`. + .. note:: + + The user constructible and usable type for creating a media gallery is + :class:`discord.ui.MediaGallery` not this one. + + .. versionadded:: 2.6 + Attributes ---------- items: List[:class:`MediaGalleryItem`] The items this gallery has. """ - __slots__ = ('items', 'id') + __slots__ = ('items',) + + __repr_info__ = __slots__ def __init__(self, data: MediaGalleryComponentPayload, state: Optional[ConnectionState]) -> None: self.items: List[MediaGalleryItem] = MediaGalleryItem._from_gallery(data['items'], state) @@ -995,6 +1031,13 @@ class FileComponent(Component): This inherits from :class:`Component`. + .. note:: + + The user constructible and usable type for create a file component is + :class:`discord.ui.File` not this one. + + .. versionadded:: 2.6 + Attributes ---------- media: :class:`UnfurledMediaItem` @@ -1008,6 +1051,8 @@ class FileComponent(Component): 'spoiler', ) + __repr_info__ = __slots__ + def __init__(self, data: FileComponentPayload, state: Optional[ConnectionState]) -> None: self.media: UnfurledMediaItem = UnfurledMediaItem._from_data(data['file'], state) self.spoiler: bool = data.get('spoiler', False) @@ -1029,6 +1074,13 @@ class SeparatorComponent(Component): This inherits from :class:`Component`. + .. note:: + + The user constructible and usable type for creating a separator is + :class:`discord.ui.Separator` not this one. + + .. versionadded:: 2.6 + Attributes ---------- spacing: :class:`SeparatorSize` @@ -1042,6 +1094,8 @@ class SeparatorComponent(Component): 'visible', ) + __repr_info__ = __slots__ + def __init__( self, data: SeparatorComponentPayload, @@ -1066,6 +1120,13 @@ class Container(Component): This inherits from :class:`Component`. + .. note:: + + The user constructible and usable type for creating a container is + :class:`discord.ui.Container` not this one. + + .. versionadded:: 2.6 + Attributes ---------- children: :class:`Component` From 18f72f58fd5111b60d8d1879d2704cfe34aeaa76 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 4 Mar 2025 12:33:55 +0100 Subject: [PATCH 033/272] idk some things --- discord/ui/action_row.py | 292 +++++++++++++++++++++++++++++++++++++++ discord/ui/button.py | 2 + discord/ui/select.py | 2 + discord/ui/view.py | 133 ++++++++++++------ 4 files changed, 384 insertions(+), 45 deletions(-) create mode 100644 discord/ui/action_row.py diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py new file mode 100644 index 000000000000..160a9eca812c --- /dev/null +++ b/discord/ui/action_row.py @@ -0,0 +1,292 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" +from __future__ import annotations + +import inspect +import os +from typing import ( + TYPE_CHECKING, + Any, + Callable, + ClassVar, + Coroutine, + Dict, + List, + Literal, + Optional, + Sequence, + Type, + TypeVar, + Union, +) + +from .item import Item, ItemCallbackType +from .button import Button +from .select import Select, SelectCallbackDecorator +from ..enums import ButtonStyle, ComponentType, ChannelType +from ..partial_emoji import PartialEmoji +from ..utils import MISSING + +if TYPE_CHECKING: + from .view import LayoutView + from .select import ( + BaseSelectT, + ValidDefaultValues, + MentionableSelectT, + ChannelSelectT, + RoleSelectT, + UserSelectT, + SelectT + ) + from ..emoji import Emoji + from ..components import SelectOption + from ..interactions import Interaction + +V = TypeVar('V', bound='LayoutView', covariant=True) + +__all__ = ('ActionRow',) + + +class _ActionRowCallback: + __slots__ = ('row', 'callback', 'item') + + def __init__(self, callback: ItemCallbackType[Any, Any], row: ActionRow, item: Item[Any]) -> None: + self.callback: ItemCallbackType[Any, Any] = callback + self.row: ActionRow = row + self.item: Item[Any] = item + + def __call__(self, interaction: Interaction) -> Coroutine[Any, Any, Any]: + return self.callback(self.row, interaction, self.item) + + +class ActionRow(Item[V]): + """Represents a UI action row. + + This object can be inherited. + + .. versionadded:: 2.6 + + Parameters + ---------- + id: Optional[:class:`str`] + The ID of this action row. Defaults to ``None``. + """ + + __action_row_children_items__: ClassVar[List[ItemCallbackType[Any, Any]]] = [] + __discord_ui_action_row__: ClassVar[bool] = True + + def __init__(self, *, id: Optional[str] = None) -> None: + super().__init__() + + self.id: str = id or os.urandom(16).hex() + self._children: List[Item[Any]] = self._init_children() + + def __init_subclass__(cls) -> None: + super().__init_subclass__() + + children: Dict[str, ItemCallbackType[Any, Any]] = {} + for base in reversed(cls.__mro__): + for name, member in base.__dict__.items(): + if hasattr(member, '__discord_ui_model_type__'): + children[name] = member + + if len(children) > 5: + raise TypeError('ActionRow cannot have more than 5 children') + + cls.__action_row_children_items__ = list(children.values()) + + def _init_children(self) -> List[Item[Any]]: + children = [] + + for func in self.__action_row_children_items__: + item: Item = func.__discord_ui_model_type__(**func.__discord_ui_model_kwargs__) + item.callback = _ActionRowCallback(func, self, item) + item._parent = self # type: ignore + setattr(self, func.__name__, item) + children.append(item) + return children + + def _update_children_view(self, view: LayoutView) -> None: + for child in self._children: + child._view = view + + def _is_v2(self) -> bool: + # although it is not really a v2 component the only usecase here is for + # LayoutView which basically represents the top-level payload of components + # and ActionRow is only allowed there anyways. + # If the user tries to add any V2 component to a View instead of LayoutView + # it should error anyways. + return True + + @property + def width(self): + return 5 + + @property + def type(self) -> Literal[ComponentType.action_row]: + return ComponentType.action_row + + def button( + self, + *, + label: Optional[str] = None, + custom_id: Optional[str] = None, + disabled: bool = False, + style: ButtonStyle = ButtonStyle.secondary, + emoji: Optional[Union[str, Emoji, PartialEmoji]] = None, + ) -> Callable[[ItemCallbackType[V, Button[V]]], Button[V]]: + """A decorator that attaches a button to a component. + + The function being decorated should have three parameters, ``self`` representing + the :class:`discord.ui.LayoutView`, the :class:`discord.Interaction` you receive and + the :class:`discord.ui.Button` being pressed. + + .. note:: + + Buttons with a URL or a SKU cannot be created with this function. + Consider creating a :class:`Button` manually and adding it via + :meth:`ActionRow.add_item` instead. This is beacuse these buttons + cannot have a callback associated with them since Discord does not + do any processing with them. + + Parameters + ---------- + label: Optional[:class:`str`] + The label of the button, if any. + Can only be up to 80 characters. + custom_id: Optional[:class:`str`] + The ID of the button that gets received during an interaction. + It is recommended to not set this parameters to prevent conflicts. + Can only be up to 100 characters. + style: :class:`.ButtonStyle` + The style of the button. Defaults to :attr:`.ButtonStyle.grey`. + disabled: :class:`bool` + Whether the button is disabled or not. Defaults to ``False``. + emoji: Optional[Union[:class:`str`, :class:`.Emoji`, :class:`.PartialEmoji`]] + The emoji of the button. This can be in string form or a :class:`.PartialEmoji` + or a full :class:`.Emoji`. + """ + + def decorator(func: ItemCallbackType[V, Button[V]]) -> ItemCallbackType[V, Button[V]]: + if not inspect.iscoroutinefunction(func): + raise TypeError('button function must be a coroutine function') + + func.__discord_ui_modal_type__ = Button + func.__discord_ui_model_kwargs__ = { + 'style': style, + 'custom_id': custom_id, + 'url': None, + 'disabled': disabled, + 'label': label, + 'emoji': emoji, + 'row': None, + 'sku_id': None, + } + return func + + return decorator # type: ignore + + def select( + *, + cls: Type[BaseSelectT] = Select[Any], + options: List[SelectOption] = MISSING, + channel_types: List[ChannelType] = MISSING, + placeholder: Optional[str] = None, + custom_id: str = MISSING, + min_values: int = 1, + max_values: int = 1, + disabled: bool = False, + default_values: Sequence[ValidDefaultValues] = MISSING, + ) -> SelectCallbackDecorator[V, BaseSelectT]: + """A decorator that attaches a select menu to a component. + + The function being decorated should have three parameters, ``self`` representing + the :class:`discord.ui.View`, the :class:`discord.Interaction` you receive and + the chosen select class. + + To obtain the selected values inside the callback, you can use the ``values`` attribute of the chosen class in the callback. The list of values + will depend on the type of select menu used. View the table below for more information. + + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------+ + | Select Type | Resolved Values | + +========================================+=================================================================================================================+ + | :class:`discord.ui.Select` | List[:class:`str`] | + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------+ + | :class:`discord.ui.UserSelect` | List[Union[:class:`discord.Member`, :class:`discord.User`]] | + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------+ + | :class:`discord.ui.RoleSelect` | List[:class:`discord.Role`] | + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------+ + | :class:`discord.ui.MentionableSelect` | List[Union[:class:`discord.Role`, :class:`discord.Member`, :class:`discord.User`]] | + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------+ + | :class:`discord.ui.ChannelSelect` | List[Union[:class:`~discord.app_commands.AppCommandChannel`, :class:`~discord.app_commands.AppCommandThread`]] | + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------+ + + .. versionchanged:: 2.1 + Added the following keyword-arguments: ``cls``, ``channel_types`` + + Example + --------- + .. code-block:: python3 + + class ActionRow(discord.ui.ActionRow): + + @discord.ui.select(cls=ChannelSelect, channel_types=[discord.ChannelType.text]) + async def select_channels(self, interaction: discord.Interaction, select: ChannelSelect): + return await interaction.response.send_message(f'You selected {select.values[0].mention}') + + Parameters + ------------ + cls: Union[Type[:class:`discord.ui.Select`], Type[:class:`discord.ui.UserSelect`], Type[:class:`discord.ui.RoleSelect`], \ + Type[:class:`discord.ui.MentionableSelect`], Type[:class:`discord.ui.ChannelSelect`]] + The class to use for the select menu. Defaults to :class:`discord.ui.Select`. You can use other + select types to display different select menus to the user. See the table above for the different + values you can get from each select type. Subclasses work as well, however the callback in the subclass will + get overridden. + placeholder: Optional[:class:`str`] + The placeholder text that is shown if nothing is selected, if any. + Can only be up to 150 characters. + custom_id: :class:`str` + The ID of the select menu that gets received during an interaction. + It is recommended not to set this parameter to prevent conflicts. + Can only be up to 100 characters. + min_values: :class:`int` + The minimum number of items that must be chosen for this select menu. + Defaults to 1 and must be between 0 and 25. + max_values: :class:`int` + The maximum number of items that must be chosen for this select menu. + Defaults to 1 and must be between 1 and 25. + options: List[:class:`discord.SelectOption`] + A list of options that can be selected in this menu. This can only be used with + :class:`Select` instances. + Can only contain up to 25 items. + channel_types: List[:class:`~discord.ChannelType`] + The types of channels to show in the select menu. Defaults to all channels. This can only be used + with :class:`ChannelSelect` instances. + disabled: :class:`bool` + Whether the select is disabled or not. Defaults to ``False``. + default_values: Sequence[:class:`~discord.abc.Snowflake`] + A list of objects representing the default values for the select menu. This cannot be used with regular :class:`Select` instances. + If ``cls`` is :class:`MentionableSelect` and :class:`.Object` is passed, then the type must be specified in the constructor. + Number of items must be in range of ``min_values`` and ``max_values``. + """ diff --git a/discord/ui/button.py b/discord/ui/button.py index 43bd3a8b0f9d..f15910effce1 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -43,6 +43,7 @@ from typing_extensions import Self from .view import View + from .action_row import ActionRow from ..emoji import Emoji from ..types.components import ButtonComponent as ButtonComponentPayload @@ -144,6 +145,7 @@ def __init__( emoji=emoji, sku_id=sku_id, ) + self._parent: Optional[ActionRow] = None self.row = row @property diff --git a/discord/ui/select.py b/discord/ui/select.py index 1ef085cc5df2..b2534e146f85 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -73,6 +73,7 @@ from typing_extensions import TypeAlias, TypeGuard from .view import View + from .action_row import ActionRow from ..types.components import SelectMenu as SelectMenuPayload from ..types.interactions import SelectMessageComponentInteractionData from ..app_commands import AppCommandChannel, AppCommandThread @@ -258,6 +259,7 @@ def __init__( ) self.row = row + self._parent: Optional[ActionRow] = None self._values: List[PossibleValue] = [] @property diff --git a/discord/ui/view.py b/discord/ui/view.py index e490f1444cee..e19f8bc6c684 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -36,7 +36,6 @@ TYPE_CHECKING, Tuple, Type, - Union, ) from functools import partial from itertools import groupby @@ -47,6 +46,7 @@ import time import os from .item import Item, ItemCallbackType +from .action_row import ActionRow from .dynamic import DynamicItem from ..components import ( Component, @@ -153,9 +153,6 @@ def find_open_space(self, item: Item) -> int: raise ValueError('could not find open space for item') def add_item(self, item: Item) -> None: - if item._is_v2() and not self.v2_weights(): - # v2 components allow up to 10 rows - self.weights.extend([0, 0, 0, 0, 0]) if item.row is not None: total = self.weights[item.row] + item.width if total > 5: @@ -191,7 +188,7 @@ def __call__(self, interaction: Interaction) -> Coroutine[Any, Any, Any]: return self.callback(self.view, interaction, self.item) -class View: +class View: # NOTE: maybe add a deprecation warning in favour of LayoutView? """Represents a UI view. This object must be inherited to create a UI within Discord. @@ -208,15 +205,15 @@ class View: __discord_ui_view__: ClassVar[bool] = True __discord_ui_modal__: ClassVar[bool] = False __discord_ui_container__: ClassVar[bool] = False - __view_children_items__: ClassVar[List[Union[ItemCallbackType[Any, Any], Item[Any]]]] = [] + __view_children_items__: ClassVar[List[ItemCallbackType[Any, Any]]] = [] def __init_subclass__(cls) -> None: super().__init_subclass__() - children: Dict[str, Union[ItemCallbackType[Any, Any], Item]] = {} + children: Dict[str, ItemCallbackType[Any, Any]] = {} for base in reversed(cls.__mro__): for name, member in base.__dict__.items(): - if hasattr(member, '__discord_ui_model_type__') or isinstance(member, Item): + if hasattr(member, '__discord_ui_model_type__'): children[name] = member if len(children) > 25: @@ -228,15 +225,11 @@ def _init_children(self) -> List[Item[Self]]: children = [] for func in self.__view_children_items__: - if isinstance(func, Item): - func._view = self - children.append(func) - else: - item: Item = func.__discord_ui_model_type__(**func.__discord_ui_model_kwargs__) - item.callback = _ViewCallback(func, self, item) # type: ignore - item._view = self - setattr(self, func.__name__, item) - children.append(item) + item: Item = func.__discord_ui_model_type__(**func.__discord_ui_model_kwargs__) + item.callback = _ViewCallback(func, self, item) # type: ignore + item._view = self + setattr(self, func.__name__, item) + children.append(item) return children def __init__(self, *, timeout: Optional[float] = 180.0): @@ -286,36 +279,22 @@ def has_components_v2(self) -> bool: return any(c._is_v2() for c in self.children) def to_components(self) -> List[Dict[str, Any]]: - components: List[Dict[str, Any]] = [] - rows_index: Dict[int, int] = {} - # helper mapping to find action rows for items that are not - # v2 components - def key(item: Item) -> int: return item._rendered_row or 0 - # instead of grouping by row we will sort it so it is added - # in order and should work as the original implementation - # this will append directly the v2 Components into the list - # and will add to an action row the loose items, such as - # buttons and selects - for child in sorted(self._children, key=key): - if child._is_v2(): - components.append(child.to_component_dict()) - else: - row = child._rendered_row or 0 - index = rows_index.get(row) + children = sorted(self._children, key=key) + components: List[Dict[str, Any]] = [] + for _, group in groupby(children, key=key): + children = [item.to_component_dict() for item in group] + if not children: + continue - if index is not None: - components[index]['components'].append(child.to_component_dict()) - else: - components.append( - { - 'type': 1, - 'components': [child.to_component_dict()], - }, - ) - rows_index[row] = len(components) - 1 + components.append( + { + 'type': 1, + 'components': children, + } + ) return components @@ -401,8 +380,9 @@ def add_item(self, item: Item[Any]) -> Self: TypeError An :class:`Item` was not passed. ValueError - Maximum number of children has been exceeded (25) - or the row the item is trying to be added to is full. + Maximum number of children has been exceeded (25), the + row the item is trying to be added to is full or the item + you tried to add is not allowed in this View. """ if len(self._children) >= 25: @@ -411,6 +391,11 @@ def add_item(self, item: Item[Any]) -> Self: if not isinstance(item, Item): raise TypeError(f'expected Item not {item.__class__.__name__}') + if item._is_v2() and not self._is_v2(): + raise ValueError( + 'The item can only be added on LayoutView' + ) + self.__weights.add_item(item) item._view = self @@ -614,6 +599,64 @@ async def wait(self) -> bool: return await self.__stopped +class LayoutView(View): + __view_children_items__: ClassVar[List[Item[Any]]] = [] + + def __init__(self, *, timeout: Optional[float] = 180) -> None: + super().__init__(timeout=timeout) + self.__weights.weights.extend([0, 0, 0, 0, 0]) + + def __init_subclass__(cls) -> None: + children: Dict[str, Item[Any]] = {} + + for base in reversed(cls.__mro__): + for name, member in base.__dict__.items(): + if isinstance(member, Item): + children[name] = member + + if len(children) > 10: + raise TypeError('LayoutView cannot have more than 10 top-level children') + + cls.__view_children_items__ = list(children.values()) + + def _init_children(self) -> List[Item[Self]]: + children = [] + + for i in self.__view_children_items__: + if isinstance(i, Item): + if getattr(i, '_parent', None): + # this is for ActionRows which have decorators such as + # @action_row.button and @action_row.select that will convert + # those callbacks into their types but will have a _parent + # attribute which is checked here so the item is not added twice + continue + i._view = self + if getattr(i, '__discord_ui_action_row__', False): + i._update_children_view(self) # type: ignore + children.append(i) + else: + # guard just in case + raise TypeError( + 'LayoutView can only have items' + ) + return children + + def _is_v2(self) -> bool: + return True + + def to_components(self): + components: List[Dict[str, Any]] = [] + + # sorted by row, which in LayoutView indicates the position of the component in the + # payload instead of in which ActionRow it should be placed on. + for child in sorted(self._children, key=lambda i: i._rendered_row or 0): + components.append( + child.to_component_dict(), + ) + + return child + + class ViewStore: def __init__(self, state: ConnectionState): # entity_id: {(component_type, custom_id): Item} From cbdc618e3e5f4c49d317acb35086ec7d0bb80133 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 6 Mar 2025 16:16:24 +0100 Subject: [PATCH 034/272] chore: Finish ActionRow and fix ViewStore.add_view --- discord/ui/action_row.py | 198 ++++++++++++++++++++++++++++++++++++++- discord/ui/view.py | 19 ++++ 2 files changed, 212 insertions(+), 5 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 160a9eca812c..4101eb2ddab0 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -39,16 +39,20 @@ Type, TypeVar, Union, + overload, ) from .item import Item, ItemCallbackType from .button import Button -from .select import Select, SelectCallbackDecorator +from .dynamic import DynamicItem +from .select import select as _select, Select, SelectCallbackDecorator, UserSelect, RoleSelect, ChannelSelect, MentionableSelect from ..enums import ButtonStyle, ComponentType, ChannelType from ..partial_emoji import PartialEmoji from ..utils import MISSING if TYPE_CHECKING: + from typing_extensions import Self + from .view import LayoutView from .select import ( BaseSelectT, @@ -122,11 +126,26 @@ def _init_children(self) -> List[Item[Any]]: for func in self.__action_row_children_items__: item: Item = func.__discord_ui_model_type__(**func.__discord_ui_model_kwargs__) item.callback = _ActionRowCallback(func, self, item) - item._parent = self # type: ignore + item._parent = getattr(func, '__discord_ui_parent__', self) # type: ignore setattr(self, func.__name__, item) children.append(item) return children + def _update_store_data(self, dispatch_info: Dict, dynamic_items: Dict) -> bool: + is_fully_dynamic = True + + for item in self._children: + if isinstance(item, DynamicItem): + pattern = item.__discord_ui_compiled_template__ + dynamic_items[pattern] = item.__class__ + elif item.is_dispatchable(): + dispatch_info[(item.type.value, item.custom_id)] = item # type: ignore + is_fully_dynamic = False + return is_fully_dynamic + + def is_dispatchable(self) -> bool: + return any(c.is_dispatchable() for c in self.children) + def _update_children_view(self, view: LayoutView) -> None: for child in self._children: child._view = view @@ -147,6 +166,77 @@ def width(self): def type(self) -> Literal[ComponentType.action_row]: return ComponentType.action_row + @property + def children(self) -> List[Item[V]]: + """List[:class:`Item`]: The list of children attached to this action row.""" + return self._children.copy() + + def add_item(self, item: Item[Any]) -> Self: + """Adds an item to this row. + + This function returns the class instance to allow for fluent-style + chaining. + + Parameters + ---------- + item: :class:`Item` + The item to add to the row. + + Raises + ------ + TypeError + An :class:`Item` was not passed. + ValueError + Maximum number of children has been exceeded (5). + """ + + if len(self._children) >= 5: + raise ValueError('maximum number of children exceeded') + + if not isinstance(item, Item): + raise TypeError(f'expected Item not {item.__class__.__name__}') + + self._children.append(item) + return self + + def remove_item(self, item: Item[Any]) -> Self: + """Removes an item from the row. + + This function returns the class instance to allow for fluent-style + chaining. + + Parameters + ---------- + item: :class:`Item` + The item to remove from the view. + """ + + try: + self._children.remove(item) + except ValueError: + pass + return self + + def clear_items(self) -> Self: + """Removes all items from the row. + + This function returns the class instance to allow for fluent-style + chaining. + """ + self._children.clear() + return self + + def to_component_dict(self) -> Dict[str, Any]: + components = [] + + for item in self._children: + components.append(item.to_component_dict()) + + return { + 'type': self.type.value, + 'components': components, + } + def button( self, *, @@ -192,6 +282,7 @@ def decorator(func: ItemCallbackType[V, Button[V]]) -> ItemCallbackType[V, Butto if not inspect.iscoroutinefunction(func): raise TypeError('button function must be a coroutine function') + func.__discord_ui_parent__ = self func.__discord_ui_modal_type__ = Button func.__discord_ui_model_kwargs__ = { 'style': style, @@ -207,7 +298,90 @@ def decorator(func: ItemCallbackType[V, Button[V]]) -> ItemCallbackType[V, Butto return decorator # type: ignore + @overload def select( + self, + *, + cls: Type[SelectT] = Select[Any], + options: List[SelectOption] = MISSING, + channel_types: List[ChannelType] = ..., + placeholder: Optional[str] = ..., + custom_id: str = ..., + min_values: int = ..., + max_values: int = ..., + disabled: bool = ..., + ) -> SelectCallbackDecorator[V, SelectT]: + ... + + @overload + def select( + self, + *, + cls: Type[UserSelectT] = UserSelect[Any], + options: List[SelectOption] = MISSING, + channel_types: List[ChannelType] = ..., + placeholder: Optional[str] = ..., + custom_id: str = ..., + min_values: int = ..., + max_values: int = ..., + disabled: bool = ..., + default_values: Sequence[ValidDefaultValues] = ..., + ) -> SelectCallbackDecorator[V, UserSelectT]: + ... + + + @overload + def select( + self, + *, + cls: Type[RoleSelectT] = RoleSelect[Any], + options: List[SelectOption] = MISSING, + channel_types: List[ChannelType] = ..., + placeholder: Optional[str] = ..., + custom_id: str = ..., + min_values: int = ..., + max_values: int = ..., + disabled: bool = ..., + default_values: Sequence[ValidDefaultValues] = ..., + ) -> SelectCallbackDecorator[V, RoleSelectT]: + ... + + + @overload + def select( + self, + *, + cls: Type[ChannelSelectT] = ChannelSelect[Any], + options: List[SelectOption] = MISSING, + channel_types: List[ChannelType] = ..., + placeholder: Optional[str] = ..., + custom_id: str = ..., + min_values: int = ..., + max_values: int = ..., + disabled: bool = ..., + default_values: Sequence[ValidDefaultValues] = ..., + ) -> SelectCallbackDecorator[V, ChannelSelectT]: + ... + + + @overload + def select( + self, + *, + cls: Type[MentionableSelectT] = MentionableSelect[Any], + options: List[SelectOption] = MISSING, + channel_types: List[ChannelType] = MISSING, + placeholder: Optional[str] = ..., + custom_id: str = ..., + min_values: int = ..., + max_values: int = ..., + disabled: bool = ..., + default_values: Sequence[ValidDefaultValues] = ..., + ) -> SelectCallbackDecorator[V, MentionableSelectT]: + ... + + def select( + self, *, cls: Type[BaseSelectT] = Select[Any], options: List[SelectOption] = MISSING, @@ -242,9 +416,6 @@ def select( | :class:`discord.ui.ChannelSelect` | List[Union[:class:`~discord.app_commands.AppCommandChannel`, :class:`~discord.app_commands.AppCommandThread`]] | +----------------------------------------+-----------------------------------------------------------------------------------------------------------------+ - .. versionchanged:: 2.1 - Added the following keyword-arguments: ``cls``, ``channel_types`` - Example --------- .. code-block:: python3 @@ -290,3 +461,20 @@ async def select_channels(self, interaction: discord.Interaction, select: Channe If ``cls`` is :class:`MentionableSelect` and :class:`.Object` is passed, then the type must be specified in the constructor. Number of items must be in range of ``min_values`` and ``max_values``. """ + + def decorator(func: ItemCallbackType[V, BaseSelectT]) -> ItemCallbackType[V, BaseSelectT]: + r = _select( # type: ignore + cls=cls, # type: ignore + placeholder=placeholder, + custom_id=custom_id, + min_values=min_values, + max_values=max_values, + options=options, + channel_types=channel_types, + disabled=disabled, + default_values=default_values, + )(func) + r.__discord_ui_parent__ = self + return r + + return decorator # type: ignore diff --git a/discord/ui/view.py b/discord/ui/view.py index e19f8bc6c684..9ea612aebe64 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -601,6 +601,7 @@ async def wait(self) -> bool: class LayoutView(View): __view_children_items__: ClassVar[List[Item[Any]]] = [] + __view_pending_children__: ClassVar[List[ItemCallbackType[Any, Any]]] = [] def __init__(self, *, timeout: Optional[float] = 180) -> None: super().__init__(timeout=timeout) @@ -608,20 +609,32 @@ def __init__(self, *, timeout: Optional[float] = 180) -> None: def __init_subclass__(cls) -> None: children: Dict[str, Item[Any]] = {} + pending: Dict[str, ItemCallbackType[Any, Any]] = {} for base in reversed(cls.__mro__): for name, member in base.__dict__.items(): if isinstance(member, Item): children[name] = member + elif hasattr(member, '__discord_ui_model_type__') and getattr(member, '__discord_ui_parent__', None): + pending[name] = member if len(children) > 10: raise TypeError('LayoutView cannot have more than 10 top-level children') cls.__view_children_items__ = list(children.values()) + cls.__view_pending_children__ = list(pending.values()) def _init_children(self) -> List[Item[Self]]: children = [] + for func in self.__view_pending_children__: + item: Item = func.__discord_ui_model_type__(**func.__discord_ui_model_kwargs__) + item.callback = _ViewCallback(func, self, item) + item._view = self + setattr(self, func.__name__, item) + parent: ActionRow = func.__discord_ui_parent__ + parent.add_item(item) + for i in self.__view_children_items__: if isinstance(i, Item): if getattr(i, '_parent', None): @@ -639,6 +652,7 @@ def _init_children(self) -> List[Item[Self]]: raise TypeError( 'LayoutView can only have items' ) + return children def _is_v2(self) -> bool: @@ -709,6 +723,11 @@ def add_view(self, view: View, message_id: Optional[int] = None) -> None: dispatch_info, self._dynamic_items, ) + elif getattr(item, '__discord_ui_action_row__', False): + is_fully_dynamic = item._update_store_data( # type: ignore + dispatch_info, + self._dynamic_items, + ) or is_fully_dynamic else: dispatch_info[(item.type.value, item.custom_id)] = item # type: ignore is_fully_dynamic = False From 8f59216e680137895104c6ee5e873c2b35f195fd Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 6 Mar 2025 20:31:26 +0100 Subject: [PATCH 035/272] chore: more components v2 things and finished danny's suggested impl --- discord/components.py | 2 +- discord/ui/action_row.py | 16 +- discord/ui/button.py | 5 +- discord/ui/container.py | 8 +- discord/ui/dynamic.py | 8 +- discord/ui/file.py | 8 +- discord/ui/item.py | 16 +- discord/ui/media_gallery.py | 15 +- discord/ui/section.py | 8 +- discord/ui/select.py | 5 +- discord/ui/separator.py | 8 +- discord/ui/text_display.py | 9 +- discord/ui/thumbnail.py | 8 +- discord/ui/view.py | 397 +++++++++++++++++++++++------------- 14 files changed, 336 insertions(+), 177 deletions(-) diff --git a/discord/components.py b/discord/components.py index ac4ff987e51e..ef7d676700ac 100644 --- a/discord/components.py +++ b/discord/components.py @@ -967,7 +967,7 @@ def __repr__(self) -> str: def _from_data(cls, data: MediaGalleryItemPayload, state: Optional[ConnectionState]) -> MediaGalleryItem: media = data['media'] self = cls( - media=media['url'], + media=UnfurledMediaItem._from_data(media, state), description=data.get('description'), spoiler=data.get('spoiler', False), ) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 4101eb2ddab0..1df526cba41c 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -45,7 +45,8 @@ from .item import Item, ItemCallbackType from .button import Button from .dynamic import DynamicItem -from .select import select as _select, Select, SelectCallbackDecorator, UserSelect, RoleSelect, ChannelSelect, MentionableSelect +from .select import select as _select, Select, UserSelect, RoleSelect, ChannelSelect, MentionableSelect +from ..components import ActionRow as ActionRowComponent from ..enums import ButtonStyle, ComponentType, ChannelType from ..partial_emoji import PartialEmoji from ..utils import MISSING @@ -61,7 +62,8 @@ ChannelSelectT, RoleSelectT, UserSelectT, - SelectT + SelectT, + SelectCallbackDecorator, ) from ..emoji import Emoji from ..components import SelectOption @@ -125,7 +127,7 @@ def _init_children(self) -> List[Item[Any]]: for func in self.__action_row_children_items__: item: Item = func.__discord_ui_model_type__(**func.__discord_ui_model_kwargs__) - item.callback = _ActionRowCallback(func, self, item) + item.callback = _ActionRowCallback(func, self, item) # type: ignore item._parent = getattr(func, '__discord_ui_parent__', self) # type: ignore setattr(self, func.__name__, item) children.append(item) @@ -478,3 +480,11 @@ def decorator(func: ItemCallbackType[V, BaseSelectT]) -> ItemCallbackType[V, Bas return r return decorator # type: ignore + + @classmethod + def from_component(cls, component: ActionRowComponent) -> ActionRow: + from .view import _component_to_item + self = cls() + for cmp in component.children: + self.add_item(_component_to_item(cmp)) + return self diff --git a/discord/ui/button.py b/discord/ui/button.py index f15910effce1..df21c770fc4b 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -42,12 +42,12 @@ if TYPE_CHECKING: from typing_extensions import Self - from .view import View + from .view import BaseView from .action_row import ActionRow from ..emoji import Emoji from ..types.components import ButtonComponent as ButtonComponentPayload -V = TypeVar('V', bound='View', covariant=True) +V = TypeVar('V', bound='BaseView', covariant=True) class Button(Item[V]): @@ -147,6 +147,7 @@ def __init__( ) self._parent: Optional[ActionRow] = None self.row = row + self.id = custom_id @property def style(self) -> ButtonStyle: diff --git a/discord/ui/container.py b/discord/ui/container.py index 2acf95d20bad..1b50eceb9c32 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -26,7 +26,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Type, TypeVar from .item import Item -from .view import View, _component_to_item +from .view import View, _component_to_item, LayoutView from .dynamic import DynamicItem from ..enums import ComponentType from ..utils import MISSING @@ -37,7 +37,7 @@ from ..colour import Colour, Color from ..components import Container as ContainerComponent -V = TypeVar('V', bound='View', covariant=True) +V = TypeVar('V', bound='LayoutView', covariant=True) __all__ = ('Container',) @@ -69,6 +69,8 @@ class Container(View, Item[V]): 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 9 (i.e. zero indexed) + id: Optional[:class:`str`] + The ID of this component. This must be unique across the view. """ __discord_ui_container__ = True @@ -82,6 +84,7 @@ def __init__( spoiler: bool = False, timeout: Optional[float] = 180, row: Optional[int] = None, + id: Optional[str] = None, ) -> None: super().__init__(timeout=timeout) if children is not MISSING: @@ -95,6 +98,7 @@ def __init__( self._row: Optional[int] = None self._rendered_row: Optional[int] = None self.row: Optional[int] = row + self.id: Optional[str] = id @property def children(self) -> List[Item[Self]]: diff --git a/discord/ui/dynamic.py b/discord/ui/dynamic.py index 0b65e90f3a35..ee3ad30d50c1 100644 --- a/discord/ui/dynamic.py +++ b/discord/ui/dynamic.py @@ -38,14 +38,14 @@ from ..interactions import Interaction from ..components import Component from ..enums import ComponentType - from .view import View + from .view import BaseView - V = TypeVar('V', bound='View', covariant=True, default=View) + V = TypeVar('V', bound='BaseView', covariant=True, default=BaseView) else: - V = TypeVar('V', bound='View', covariant=True) + V = TypeVar('V', bound='BaseView', covariant=True) -class DynamicItem(Generic[BaseT], Item['View']): +class DynamicItem(Generic[BaseT], Item['BaseView']): """Represents an item with a dynamic ``custom_id`` that can be used to store state within that ``custom_id``. diff --git a/discord/ui/file.py b/discord/ui/file.py index 3ff6c7d0f04f..2654d351c305 100644 --- a/discord/ui/file.py +++ b/discord/ui/file.py @@ -32,9 +32,9 @@ if TYPE_CHECKING: from typing_extensions import Self - from .view import View + from .view import LayoutView -V = TypeVar('V', bound='View', covariant=True) +V = TypeVar('V', bound='LayoutView', covariant=True) __all__ = ('File',) @@ -59,6 +59,8 @@ class File(Item[V]): 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 9 (i.e. zero indexed) + id: Optional[:class:`str`] + The ID of this component. This must be unique across the view. """ def __init__( @@ -67,6 +69,7 @@ def __init__( *, spoiler: bool = False, row: Optional[int] = None, + id: Optional[str] = None, ) -> None: super().__init__() self._underlying = FileComponent._raw_construct( @@ -75,6 +78,7 @@ def __init__( ) self.row = row + self.id = id def _is_v2(self): return True diff --git a/discord/ui/item.py b/discord/ui/item.py index bbd90464a603..1fa68b68c701 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -37,11 +37,11 @@ if TYPE_CHECKING: from ..enums import ComponentType - from .view import View + from .view import BaseView from ..components import Component I = TypeVar('I', bound='Item[Any]') -V = TypeVar('V', bound='View', covariant=True) +V = TypeVar('V', bound='BaseView', covariant=True) ItemCallbackType = Callable[[V, Interaction[Any], I], Coroutine[Any, Any, Any]] @@ -70,6 +70,7 @@ def __init__(self): # actually affect the intended purpose of this check because from_component is # only called upon edit and we're mainly interested during initial creation time. self._provided_custom_id: bool = False + self._id: Optional[str] = None self._max_row: int = 5 if not self._is_v2() else 10 def to_component_dict(self) -> Dict[str, Any]: @@ -124,6 +125,17 @@ def view(self) -> Optional[V]: """Optional[:class:`View`]: The underlying view for this item.""" return self._view + @property + def id(self) -> Optional[str]: + """Optional[:class:`str`]: The ID of this component. For non v2 components this is the + equivalent to ``custom_id``. + """ + return self._id + + @id.setter + def id(self, value: Optional[str]) -> None: + self._id = value + async def callback(self, interaction: Interaction[ClientT]) -> Any: """|coro| diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index 4bc6c826f630..f9e1fb2644c7 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -35,9 +35,9 @@ if TYPE_CHECKING: from typing_extensions import Self - from .view import View + from .view import LayoutView -V = TypeVar('V', bound='View', covariant=True) +V = TypeVar('V', bound='LayoutView', covariant=True) __all__ = ('MediaGallery',) @@ -60,9 +60,17 @@ class MediaGallery(Item[V]): 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 9 (i.e. zero indexed) + id: Optional[:class:`str`] + The ID of this component. This must be unique across the view. """ - def __init__(self, items: List[MediaGalleryItem], *, row: Optional[int] = None) -> None: + def __init__( + self, + items: List[MediaGalleryItem], + *, + row: Optional[int] = None, + id: Optional[str] = None, + ) -> None: super().__init__() self._underlying = MediaGalleryComponent._raw_construct( @@ -70,6 +78,7 @@ def __init__(self, items: List[MediaGalleryItem], *, row: Optional[int] = None) ) self.row = row + self.id = id @property def items(self) -> List[MediaGalleryItem]: diff --git a/discord/ui/section.py b/discord/ui/section.py index 5a0ec7f27398..ba919beb81b6 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -33,10 +33,10 @@ if TYPE_CHECKING: from typing_extensions import Self - from .view import View + from .view import LayoutView from ..components import SectionComponent -V = TypeVar('V', bound='View', covariant=True) +V = TypeVar('V', bound='LayoutView', covariant=True) __all__ = ('Section',) @@ -59,6 +59,8 @@ class Section(Item[V]): 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 9 (i.e. zero indexed) + id: Optional[:class:`str`] + The ID of this component. This must be unique across the view. """ __slots__ = ( @@ -72,6 +74,7 @@ def __init__( *, accessory: Item[Any], row: Optional[int] = None, + id: Optional[str] = None, ) -> None: super().__init__() self._children: List[Item[Any]] = [] @@ -84,6 +87,7 @@ def __init__( self.accessory: Item[Any] = accessory self.row = row + self.id = id @property def type(self) -> Literal[ComponentType.section]: diff --git a/discord/ui/select.py b/discord/ui/select.py index b2534e146f85..f5a9fcbee2e1 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -72,7 +72,7 @@ if TYPE_CHECKING: from typing_extensions import TypeAlias, TypeGuard - from .view import View + from .view import BaseView from .action_row import ActionRow from ..types.components import SelectMenu as SelectMenuPayload from ..types.interactions import SelectMessageComponentInteractionData @@ -102,7 +102,7 @@ Thread, ] -V = TypeVar('V', bound='View', covariant=True) +V = TypeVar('V', bound='BaseView', covariant=True) BaseSelectT = TypeVar('BaseSelectT', bound='BaseSelect[Any]') SelectT = TypeVar('SelectT', bound='Select[Any]') UserSelectT = TypeVar('UserSelectT', bound='UserSelect[Any]') @@ -259,6 +259,7 @@ def __init__( ) self.row = row + self.id = custom_id if custom_id is not MISSING else None self._parent: Optional[ActionRow] = None self._values: List[PossibleValue] = [] diff --git a/discord/ui/separator.py b/discord/ui/separator.py index 33401f880b95..b9ff955adcc5 100644 --- a/discord/ui/separator.py +++ b/discord/ui/separator.py @@ -32,9 +32,9 @@ if TYPE_CHECKING: from typing_extensions import Self - from .view import View + from .view import LayoutView -V = TypeVar('V', bound='View', covariant=True) +V = TypeVar('V', bound='LayoutView', covariant=True) __all__ = ('Separator',) @@ -58,6 +58,8 @@ class Separator(Item[V]): 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 9 (i.e. zero indexed) + id: Optional[:class:`str`] + The ID of this component. This must be unique across the view. """ def __init__( @@ -66,6 +68,7 @@ def __init__( visible: bool = True, spacing: SeparatorSize = SeparatorSize.small, row: Optional[int] = None, + id: Optional[str] = None, ) -> None: super().__init__() self._underlying = SeparatorComponent._raw_construct( @@ -74,6 +77,7 @@ def __init__( ) self.row = row + self.id = id def _is_v2(self): return True diff --git a/discord/ui/text_display.py b/discord/ui/text_display.py index 1bf88678d798..e55c72ba4970 100644 --- a/discord/ui/text_display.py +++ b/discord/ui/text_display.py @@ -32,9 +32,9 @@ if TYPE_CHECKING: from typing_extensions import Self - from .view import View + from .view import LayoutView -V = TypeVar('V', bound='View', covariant=True) +V = TypeVar('V', bound='LayoutView', covariant=True) __all__ = ('TextDisplay',) @@ -55,13 +55,16 @@ class TextDisplay(Item[V]): 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 9 (i.e. zero indexed) + id: Optional[:class:`str`] + The ID of this component. This must be unique across the view. """ - def __init__(self, content: str, *, row: Optional[int] = None) -> None: + def __init__(self, content: str, *, row: Optional[int] = None, id: Optional[str] = None) -> None: super().__init__() self.content: str = content self.row = row + self.id = id def to_component_dict(self): return { diff --git a/discord/ui/thumbnail.py b/discord/ui/thumbnail.py index cf9bfd3cc3d0..0e7def382559 100644 --- a/discord/ui/thumbnail.py +++ b/discord/ui/thumbnail.py @@ -32,10 +32,10 @@ if TYPE_CHECKING: from typing_extensions import Self - from .view import View + from .view import LayoutView from ..components import ThumbnailComponent -V = TypeVar('V', bound='View', covariant=True) +V = TypeVar('V', bound='LayoutView', covariant=True) __all__ = ('Thumbnail',) @@ -62,6 +62,8 @@ class Thumbnail(Item[V]): 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 9 (i.e. zero indexed) + id: Optional[:class:`str`] + The ID of this component. This must be unique across the view. """ def __init__( @@ -71,6 +73,7 @@ def __init__( description: Optional[str] = None, spoiler: bool = False, row: Optional[int] = None, + id: Optional[str] = None, ) -> None: super().__init__() @@ -79,6 +82,7 @@ def __init__( self.spoiler: bool = spoiler self.row = row + self.id = id @property def width(self): diff --git a/discord/ui/view.py b/discord/ui/view.py index 9ea612aebe64..c63ac00e7277 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -36,6 +36,7 @@ TYPE_CHECKING, Tuple, Type, + Union, ) from functools import partial from itertools import groupby @@ -46,7 +47,6 @@ import time import os from .item import Item, ItemCallbackType -from .action_row import ActionRow from .dynamic import DynamicItem from ..components import ( Component, @@ -61,10 +61,12 @@ SeparatorComponent, ThumbnailComponent, ) +from ..utils import get as _utils_get # fmt: off __all__ = ( 'View', + 'LayoutView', ) # fmt: on @@ -80,6 +82,8 @@ from ..state import ConnectionState from .modal import Modal + ItemLike = Union[ItemCallbackType[Any, Any], Item[Any]] + _log = logging.getLogger(__name__) @@ -188,57 +192,18 @@ def __call__(self, interaction: Interaction) -> Coroutine[Any, Any, Any]: return self.callback(self.view, interaction, self.item) -class View: # NOTE: maybe add a deprecation warning in favour of LayoutView? - """Represents a UI view. - - This object must be inherited to create a UI within Discord. - - .. versionadded:: 2.0 - - Parameters - ----------- - timeout: Optional[:class:`float`] - Timeout in seconds from last interaction with the UI before no longer accepting input. - If ``None`` then there is no timeout. - """ - - __discord_ui_view__: ClassVar[bool] = True +class BaseView: + __discord_ui_view__: ClassVar[bool] = False __discord_ui_modal__: ClassVar[bool] = False __discord_ui_container__: ClassVar[bool] = False - __view_children_items__: ClassVar[List[ItemCallbackType[Any, Any]]] = [] - - def __init_subclass__(cls) -> None: - super().__init_subclass__() - - children: Dict[str, ItemCallbackType[Any, Any]] = {} - for base in reversed(cls.__mro__): - for name, member in base.__dict__.items(): - if hasattr(member, '__discord_ui_model_type__'): - children[name] = member - - if len(children) > 25: - raise TypeError('View cannot have more than 25 children') - - cls.__view_children_items__ = list(children.values()) - - def _init_children(self) -> List[Item[Self]]: - children = [] - - for func in self.__view_children_items__: - item: Item = func.__discord_ui_model_type__(**func.__discord_ui_model_kwargs__) - item.callback = _ViewCallback(func, self, item) # type: ignore - item._view = self - setattr(self, func.__name__, item) - children.append(item) - return children + __view_children_items__: ClassVar[List[ItemLike]] = [] - def __init__(self, *, timeout: Optional[float] = 180.0): + def __init__(self, *, timeout: Optional[float] = 180.0) -> None: self.__timeout = timeout self._children: List[Item[Self]] = self._init_children() - self.__weights = _ViewWeights(self._children) self.id: str = os.urandom(16).hex() self._cache_key: Optional[int] = None - self.__cancel_callback: Optional[Callable[[View], None]] = None + self.__cancel_callback: Optional[Callable[[BaseView], None]] = None self.__timeout_expiry: Optional[float] = None self.__timeout_task: Optional[asyncio.Task[None]] = None self.__stopped: asyncio.Future[bool] = asyncio.get_running_loop().create_future() @@ -246,12 +211,32 @@ def __init__(self, *, timeout: Optional[float] = 180.0): def _is_v2(self) -> bool: return False - @property - def width(self): - return 5 - def __repr__(self) -> str: - return f'<{self.__class__.__name__} timeout={self.timeout} children={len(self._children)}>' + return f'<{self.__class__.__name__} timeout={self.timeout} children={len(self._children)}' + + def _init_children(self) -> List[Item[Self]]: + children = [] + + for raw in self.__view_children_items__: + if isinstance(raw, Item): + raw._view = self + parent = getattr(raw, '__discord_ui_parent__', None) + if parent and parent._view is None: + parent._view = self + item = raw + else: + item: Item = raw.__discord_ui_model_type__(**raw.__discord_ui_model_kwargs__) + item.callback = _ViewCallback(raw, self, item) # type: ignore + item._view = self + setattr(self, raw.__name__, item) + parent = getattr(raw, '__discord_ui_parent__', None) + if parent: + if not self._is_v2(): + raise RuntimeError('This view cannot have v2 items') + parent._children.append(item) + children.append(item) + + return children async def __timeout_task_impl(self) -> None: while True: @@ -279,24 +264,7 @@ def has_components_v2(self) -> bool: return any(c._is_v2() for c in self.children) def to_components(self) -> List[Dict[str, Any]]: - def key(item: Item) -> int: - return item._rendered_row or 0 - - children = sorted(self._children, key=key) - components: List[Dict[str, Any]] = [] - for _, group in groupby(children, key=key): - children = [item.to_component_dict() for item in group] - if not children: - continue - - components.append( - { - 'type': 1, - 'components': children, - } - ) - - return components + return NotImplemented def _refresh_timeout(self) -> None: if self.__timeout: @@ -327,7 +295,7 @@ def children(self) -> List[Item[Self]]: return self._children.copy() @classmethod - def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> View: + def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> Any: """Converts a message's components into a :class:`View`. The :attr:`.Message.components` of a message are read-only @@ -341,28 +309,8 @@ def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) The message with components to convert into a view. timeout: Optional[:class:`float`] The timeout of the converted view. - - Returns - -------- - :class:`View` - The converted view. This always returns a :class:`View` and not - one of its subclasses. """ - view = View(timeout=timeout) - row = 0 - for component in message.components: - if isinstance(component, ActionRowComponent): - for child in component.children: - item = _component_to_item(child) - item.row = row - view.add_item(item) - row += 1 - else: - item = _component_to_item(component) - item.row = row - view.add_item(item) - - return view + pass def add_item(self, item: Item[Any]) -> Self: """Adds an item to the view. @@ -385,18 +333,10 @@ def add_item(self, item: Item[Any]) -> Self: you tried to add is not allowed in this View. """ - if len(self._children) >= 25: - raise ValueError('maximum number of children exceeded') - if not isinstance(item, Item): raise TypeError(f'expected Item not {item.__class__.__name__}') - if item._is_v2() and not self._is_v2(): - raise ValueError( - 'The item can only be added on LayoutView' - ) - - self.__weights.add_item(item) + raise ValueError('v2 items cannot be added to this view') item._view = self self._children.append(item) @@ -418,8 +358,6 @@ def remove_item(self, item: Item[Any]) -> Self: self._children.remove(item) except ValueError: pass - else: - self.__weights.remove_item(item) return self def clear_items(self) -> Self: @@ -429,9 +367,30 @@ def clear_items(self) -> Self: chaining. """ self._children.clear() - self.__weights.clear() return self + def get_item_by_id(self, id: str, /) -> Optional[Item[Self]]: + """Gets an item with :attr:`Item.id` set as ``id``, or ``None`` if + not found. + + .. warning:: + + This is **not the same** as ``custom_id``. + + .. versionadded:: 2.6 + + Parameters + ---------- + id: :class:`str` + The ID of the component. + + Returns + ------- + Optional[:class:`Item`] + The item found, or ``None``. + """ + return _utils_get(self._children, id=id) + async def interaction_check(self, interaction: Interaction, /) -> bool: """|coro| @@ -599,61 +558,167 @@ async def wait(self) -> bool: return await self.__stopped -class LayoutView(View): - __view_children_items__: ClassVar[List[Item[Any]]] = [] - __view_pending_children__: ClassVar[List[ItemCallbackType[Any, Any]]] = [] +class View(BaseView): # NOTE: maybe add a deprecation warning in favour of LayoutView? + """Represents a UI view. + + This object must be inherited to create a UI within Discord. + + .. versionadded:: 2.0 + + Parameters + ----------- + timeout: Optional[:class:`float`] + Timeout in seconds from last interaction with the UI before no longer accepting input. + If ``None`` then there is no timeout. + """ + + __discord_ui_view__: ClassVar[bool] = True + + def __init_subclass__(cls) -> None: + super().__init_subclass__() + + children: Dict[str, ItemCallbackType[Any, Any]] = {} + for base in reversed(cls.__mro__): + for name, member in base.__dict__.items(): + if hasattr(member, '__discord_ui_model_type__'): + children[name] = member + + if len(children) > 25: + raise TypeError('View cannot have more than 25 children') + + cls.__view_children_items__ = list(children.values()) + + def __init__(self, *, timeout: Optional[float] = 180.0): + super().__init__(timeout=timeout) + self.__weights = _ViewWeights(self._children) + + @property + def width(self): + return 5 + + def to_components(self) -> List[Dict[str, Any]]: + def key(item: Item) -> int: + return item._rendered_row or 0 + + children = sorted(self._children, key=key) + components: List[Dict[str, Any]] = [] + for _, group in groupby(children, key=key): + children = [item.to_component_dict() for item in group] + if not children: + continue + + components.append( + { + 'type': 1, + 'components': children, + } + ) + + return components + + @classmethod + def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> View: + """Converts a message's components into a :class:`View`. + + The :attr:`.Message.components` of a message are read-only + and separate types from those in the ``discord.ui`` namespace. + In order to modify and edit message components they must be + converted into a :class:`View` first. + + Parameters + ----------- + message: :class:`discord.Message` + The message with components to convert into a view. + timeout: Optional[:class:`float`] + The timeout of the converted view. + + Returns + -------- + :class:`View` + The converted view. This always returns a :class:`View` and not + one of its subclasses. + """ + view = View(timeout=timeout) + row = 0 + for component in message.components: + if isinstance(component, ActionRowComponent): + for child in component.children: + item = _component_to_item(child) + item.row = row + if item._is_v2(): + raise RuntimeError('v2 components cannot be added to this View') + view.add_item(item) + row += 1 + else: + item = _component_to_item(component) + item.row = row + if item._is_v2(): + raise RuntimeError('v2 components cannot be added to this View') + view.add_item(item) + + return view - def __init__(self, *, timeout: Optional[float] = 180) -> None: + def add_item(self, item: Item[Any]) -> Self: + if len(self._children) >= 25: + raise ValueError('maximum number of children exceeded') + + super().add_item(item) + try: + self.__weights.add_item(item) + except ValueError as e: + # if the item has no space left then remove it from _children + self._children.remove(item) + raise e + + return self + + def remove_item(self, item: Item[Any]) -> Self: + try: + self._children.remove(item) + except ValueError: + pass + else: + self.__weights.remove_item(item) + return self + + def clear_items(self) -> Self: + super().clear_items() + self.__weights.clear() + return self + + +class LayoutView(BaseView): + """Represents a layout view for components v2. + + Unline :class:`View` this allows for components v2 to exist + within it. + + .. versionadded:: 2.6 + + Parameters + ---------- + timeout: Optional[:class:`float`] + Timeout in seconds from last interaction with the UI before no longer accepting input. + If ``None`` then there is no timeout. + """ + + def __init__(self, *, timeout: Optional[float] = 180.0) -> None: super().__init__(timeout=timeout) - self.__weights.weights.extend([0, 0, 0, 0, 0]) def __init_subclass__(cls) -> None: children: Dict[str, Item[Any]] = {} - pending: Dict[str, ItemCallbackType[Any, Any]] = {} for base in reversed(cls.__mro__): for name, member in base.__dict__.items(): if isinstance(member, Item): children[name] = member elif hasattr(member, '__discord_ui_model_type__') and getattr(member, '__discord_ui_parent__', None): - pending[name] = member + children[name] = member if len(children) > 10: raise TypeError('LayoutView cannot have more than 10 top-level children') cls.__view_children_items__ = list(children.values()) - cls.__view_pending_children__ = list(pending.values()) - - def _init_children(self) -> List[Item[Self]]: - children = [] - - for func in self.__view_pending_children__: - item: Item = func.__discord_ui_model_type__(**func.__discord_ui_model_kwargs__) - item.callback = _ViewCallback(func, self, item) - item._view = self - setattr(self, func.__name__, item) - parent: ActionRow = func.__discord_ui_parent__ - parent.add_item(item) - - for i in self.__view_children_items__: - if isinstance(i, Item): - if getattr(i, '_parent', None): - # this is for ActionRows which have decorators such as - # @action_row.button and @action_row.select that will convert - # those callbacks into their types but will have a _parent - # attribute which is checked here so the item is not added twice - continue - i._view = self - if getattr(i, '__discord_ui_action_row__', False): - i._update_children_view(self) # type: ignore - children.append(i) - else: - # guard just in case - raise TypeError( - 'LayoutView can only have items' - ) - - return children def _is_v2(self) -> bool: return True @@ -670,11 +735,49 @@ def to_components(self): return child + def add_item(self, item: Item[Any]) -> Self: + if len(self._children) >= 10: + raise ValueError('maximum number of children exceeded') + super().add_item(item) + return self + + @classmethod + def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> LayoutView: + """Converts a message's components into a :class:`LayoutView`. + + The :attr:`.Message.components` of a message are read-only + and separate types from those in the ``discord.ui`` namespace. + In order to modify and edit message components they must be + converted into a :class:`LayoutView` first. + + Unlike :meth:`View.from_message` this works for + + Parameters + ----------- + message: :class:`discord.Message` + The message with components to convert into a view. + timeout: Optional[:class:`float`] + The timeout of the converted view. + + Returns + -------- + :class:`LayoutView` + The converted view. This always returns a :class:`LayoutView` and not + one of its subclasses. + """ + view = LayoutView(timeout=timeout) + for component in message.components: + item = _component_to_item(component) + item.row = 0 + view.add_item(item) + + return view + class ViewStore: def __init__(self, state: ConnectionState): # entity_id: {(component_type, custom_id): Item} - self._views: Dict[Optional[int], Dict[Tuple[int, str], Item[View]]] = {} + self._views: Dict[Optional[int], Dict[Tuple[int, str], Item[BaseView]]] = {} # message_id: View self._synced_message_views: Dict[int, View] = {} # custom_id: Modal @@ -684,7 +787,7 @@ def __init__(self, state: ConnectionState): self._state: ConnectionState = state @property - def persistent_views(self) -> Sequence[View]: + def persistent_views(self) -> Sequence[BaseView]: # fmt: off views = { item.view.id: item.view @@ -722,7 +825,7 @@ def add_view(self, view: View, message_id: Optional[int] = None) -> None: is_fully_dynamic = item._update_store_data( # type: ignore dispatch_info, self._dynamic_items, - ) + ) or is_fully_dynamic elif getattr(item, '__discord_ui_action_row__', False): is_fully_dynamic = item._update_store_data( # type: ignore dispatch_info, @@ -784,7 +887,7 @@ async def schedule_dynamic_item_call( return # Swap the item in the view with our new dynamic item - view._children[base_item_index] = item + view._children[base_item_index] = item # type: ignore item._view = view item._rendered_row = base_item._rendered_row item._refresh_state(interaction, interaction.data) # type: ignore @@ -826,7 +929,7 @@ def dispatch_view(self, component_type: int, custom_id: str, interaction: Intera key = (component_type, custom_id) # The entity_id can either be message_id, interaction_id, or None in that priority order. - item: Optional[Item[View]] = None + item: Optional[Item[BaseView]] = None if message_id is not None: item = self._views.get(message_id, {}).get(key) @@ -878,7 +981,7 @@ def remove_interaction_mapping(self, interaction_id: int) -> None: def is_message_tracked(self, message_id: int) -> bool: return message_id in self._synced_message_views - def remove_message_tracking(self, message_id: int) -> Optional[View]: + def remove_message_tracking(self, message_id: int) -> Optional[BaseView]: return self._synced_message_views.pop(message_id, None) def update_from_message(self, message_id: int, data: List[ComponentBasePayload]) -> None: From 6c02a7dd9a28d01acfea3e07c9c261af28dfcf9c Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 6 Mar 2025 20:33:05 +0100 Subject: [PATCH 036/272] chore: docs --- docs/interactions/api.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/interactions/api.rst b/docs/interactions/api.rst index df2d7418dfff..c62e50a3a748 100644 --- a/docs/interactions/api.rst +++ b/docs/interactions/api.rst @@ -604,6 +604,15 @@ Modal :members: :inherited-members: +LayoutView +~~~~~~~~~~ + +.. attributetable:: discord.ui.LayoutView + +.. autoclass:: discord.ui.LayoutView + :member: + :inherited-members: + Item ~~~~~~~ From 67bfa57f32572cf90906c418d96a414d2f6db5ce Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 6 Mar 2025 20:35:01 +0100 Subject: [PATCH 037/272] chore: run black --- discord/ui/action_row.py | 4 +--- discord/ui/view.py | 24 +++++++++++++++--------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 1df526cba41c..5727860562ba 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -331,7 +331,6 @@ def select( ) -> SelectCallbackDecorator[V, UserSelectT]: ... - @overload def select( self, @@ -348,7 +347,6 @@ def select( ) -> SelectCallbackDecorator[V, RoleSelectT]: ... - @overload def select( self, @@ -365,7 +363,6 @@ def select( ) -> SelectCallbackDecorator[V, ChannelSelectT]: ... - @overload def select( self, @@ -484,6 +481,7 @@ def decorator(func: ItemCallbackType[V, BaseSelectT]) -> ItemCallbackType[V, Bas @classmethod def from_component(cls, component: ActionRowComponent) -> ActionRow: from .view import _component_to_item + self = cls() for cmp in component.children: self.add_item(_component_to_item(cmp)) diff --git a/discord/ui/view.py b/discord/ui/view.py index c63ac00e7277..cd8e50f375a1 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -750,7 +750,7 @@ def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) In order to modify and edit message components they must be converted into a :class:`LayoutView` first. - Unlike :meth:`View.from_message` this works for + Unlike :meth:`View.from_message` this works for Parameters ----------- @@ -822,15 +822,21 @@ def add_view(self, view: View, message_id: Optional[int] = None) -> None: self._dynamic_items[pattern] = item.__class__ elif item.is_dispatchable(): if getattr(item, '__discord_ui_container__', False): - is_fully_dynamic = item._update_store_data( # type: ignore - dispatch_info, - self._dynamic_items, - ) or is_fully_dynamic + is_fully_dynamic = ( + item._update_store_data( # type: ignore + dispatch_info, + self._dynamic_items, + ) + or is_fully_dynamic + ) elif getattr(item, '__discord_ui_action_row__', False): - is_fully_dynamic = item._update_store_data( # type: ignore - dispatch_info, - self._dynamic_items, - ) or is_fully_dynamic + is_fully_dynamic = ( + item._update_store_data( # type: ignore + dispatch_info, + self._dynamic_items, + ) + or is_fully_dynamic + ) else: dispatch_info[(item.type.value, item.custom_id)] = item # type: ignore is_fully_dynamic = False From 0b23f1022850c6fb538669320667bc201f14d066 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 6 Mar 2025 20:36:05 +0100 Subject: [PATCH 038/272] chore: fix discord.ui.View --- docs/interactions/api.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/interactions/api.rst b/docs/interactions/api.rst index c62e50a3a748..46a2fa1888ee 100644 --- a/docs/interactions/api.rst +++ b/docs/interactions/api.rst @@ -594,6 +594,7 @@ View .. autoclass:: discord.ui.View :members: + :inherited-members: Modal ~~~~~~ From eae08956dec704da485e905a1ce816ec01e4f445 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 6 Mar 2025 20:38:33 +0100 Subject: [PATCH 039/272] chore: fix linting --- discord/client.py | 8 ++++---- discord/state.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/discord/client.py b/discord/client.py index b997bd96f4af..2eaae2455b76 100644 --- a/discord/client.py +++ b/discord/client.py @@ -72,7 +72,7 @@ from .backoff import ExponentialBackoff from .webhook import Webhook from .appinfo import AppInfo -from .ui.view import View +from .ui.view import BaseView from .ui.dynamic import DynamicItem from .stage_instance import StageInstance from .threads import Thread @@ -3149,7 +3149,7 @@ def remove_dynamic_items(self, *items: Type[DynamicItem[Item[Any]]]) -> None: self._connection.remove_dynamic_items(*items) - def add_view(self, view: View, *, message_id: Optional[int] = None) -> None: + def add_view(self, view: BaseView, *, message_id: Optional[int] = None) -> None: """Registers a :class:`~discord.ui.View` for persistent listening. This method should be used for when a view is comprised of components @@ -3175,7 +3175,7 @@ def add_view(self, view: View, *, message_id: Optional[int] = None) -> None: and all their components have an explicitly provided custom_id. """ - if not isinstance(view, View): + if not isinstance(view, BaseView): raise TypeError(f'expected an instance of View not {view.__class__.__name__}') if not view.is_persistent(): @@ -3187,7 +3187,7 @@ def add_view(self, view: View, *, message_id: Optional[int] = None) -> None: self._connection.store_view(view, message_id) @property - def persistent_views(self) -> Sequence[View]: + def persistent_views(self) -> Sequence[BaseView]: """Sequence[:class:`.View`]: A sequence of persistent views added to the client. .. versionadded:: 2.0 diff --git a/discord/state.py b/discord/state.py index c4b71b368ad3..dd8c0d561dc5 100644 --- a/discord/state.py +++ b/discord/state.py @@ -71,7 +71,7 @@ from .invite import Invite from .integrations import _integration_factory from .interactions import Interaction -from .ui.view import ViewStore, View +from .ui.view import ViewStore, BaseView from .scheduled_event import ScheduledEvent from .stage_instance import StageInstance from .threads import Thread, ThreadMember @@ -412,12 +412,12 @@ def store_sticker(self, guild: Guild, data: GuildStickerPayload) -> GuildSticker self._stickers[sticker_id] = sticker = GuildSticker(state=self, data=data) return sticker - def store_view(self, view: View, message_id: Optional[int] = None, interaction_id: Optional[int] = None) -> None: + def store_view(self, view: BaseView, message_id: Optional[int] = None, interaction_id: Optional[int] = None) -> None: if interaction_id is not None: self._view_store.remove_interaction_mapping(interaction_id) self._view_store.add_view(view, message_id) - def prevent_view_updates_for(self, message_id: int) -> Optional[View]: + def prevent_view_updates_for(self, message_id: int) -> Optional[BaseView]: return self._view_store.remove_message_tracking(message_id) def store_dynamic_items(self, *items: Type[DynamicItem[Item[Any]]]) -> None: @@ -427,7 +427,7 @@ def remove_dynamic_items(self, *items: Type[DynamicItem[Item[Any]]]) -> None: self._view_store.remove_dynamic_items(*items) @property - def persistent_views(self) -> Sequence[View]: + def persistent_views(self) -> Sequence[BaseView]: return self._view_store.persistent_views @property From c63ad950ee868f03d502da7a3c61ade2070bf223 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 6 Mar 2025 20:41:30 +0100 Subject: [PATCH 040/272] chore: more linting things and docs --- discord/ui/view.py | 4 ++-- docs/interactions/api.rst | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index cd8e50f375a1..22dad5e98ac4 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -461,7 +461,7 @@ async def _scheduled_task(self, item: Item, interaction: Interaction): return await self.on_error(interaction, e, item) def _start_listening_from_store(self, store: ViewStore) -> None: - self.__cancel_callback = partial(store.remove_view) + self.__cancel_callback = partial(store.remove_view) # type: ignore if self.timeout: if self.__timeout_task is not None: self.__timeout_task.cancel() @@ -808,7 +808,7 @@ def remove_dynamic_items(self, *items: Type[DynamicItem[Item[Any]]]) -> None: pattern = item.__discord_ui_compiled_template__ self._dynamic_items.pop(pattern, None) - def add_view(self, view: View, message_id: Optional[int] = None) -> None: + def add_view(self, view: BaseView, message_id: Optional[int] = None) -> None: view._start_listening_from_store(self) if view.__discord_ui_modal__: self._modals[view.custom_id] = view # type: ignore diff --git a/docs/interactions/api.rst b/docs/interactions/api.rst index 46a2fa1888ee..a4005882341b 100644 --- a/docs/interactions/api.rst +++ b/docs/interactions/api.rst @@ -611,7 +611,7 @@ LayoutView .. attributetable:: discord.ui.LayoutView .. autoclass:: discord.ui.LayoutView - :member: + :members: :inherited-members: Item From 7338da2b11f0cd7b108d7e8164d838c1d9fa7c79 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 6 Mar 2025 20:43:06 +0100 Subject: [PATCH 041/272] fix linting yet again --- discord/ui/view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index 22dad5e98ac4..d0f187187f0b 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -779,7 +779,7 @@ def __init__(self, state: ConnectionState): # entity_id: {(component_type, custom_id): Item} self._views: Dict[Optional[int], Dict[Tuple[int, str], Item[BaseView]]] = {} # message_id: View - self._synced_message_views: Dict[int, View] = {} + self._synced_message_views: Dict[int, BaseView] = {} # custom_id: Modal self._modals: Dict[str, Modal] = {} # component_type is the key From c5ffc6a079826ac6737712040bffdae4ab5d3321 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Fri, 7 Mar 2025 15:51:51 +0100 Subject: [PATCH 042/272] chore: fix LayoutView.to_components --- discord/ext/commands/context.py | 20 ++++++++++---------- discord/ui/view.py | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/discord/ext/commands/context.py b/discord/ext/commands/context.py index 93303973523a..0e81f33b897f 100644 --- a/discord/ext/commands/context.py +++ b/discord/ext/commands/context.py @@ -48,7 +48,7 @@ from discord.mentions import AllowedMentions from discord.sticker import GuildSticker, StickerItem from discord.message import MessageReference, PartialMessage - from discord.ui import View + from discord.ui.view import BaseView from discord.types.interactions import ApplicationCommandInteractionData from discord.poll import Poll @@ -642,7 +642,7 @@ async def reply( allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: View = ..., + view: BaseView = ..., suppress_embeds: bool = ..., ephemeral: bool = ..., silent: bool = ..., @@ -664,7 +664,7 @@ async def reply( allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: View = ..., + view: BaseView = ..., suppress_embeds: bool = ..., ephemeral: bool = ..., silent: bool = ..., @@ -686,7 +686,7 @@ async def reply( allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: View = ..., + view: BaseView = ..., suppress_embeds: bool = ..., ephemeral: bool = ..., silent: bool = ..., @@ -708,7 +708,7 @@ async def reply( allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: View = ..., + view: BaseView = ..., suppress_embeds: bool = ..., ephemeral: bool = ..., silent: bool = ..., @@ -831,7 +831,7 @@ async def send( allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: View = ..., + view: BaseView = ..., suppress_embeds: bool = ..., ephemeral: bool = ..., silent: bool = ..., @@ -853,7 +853,7 @@ async def send( allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: View = ..., + view: BaseView = ..., suppress_embeds: bool = ..., ephemeral: bool = ..., silent: bool = ..., @@ -875,7 +875,7 @@ async def send( allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: View = ..., + view: BaseView = ..., suppress_embeds: bool = ..., ephemeral: bool = ..., silent: bool = ..., @@ -897,7 +897,7 @@ async def send( allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: View = ..., + view: BaseView = ..., suppress_embeds: bool = ..., ephemeral: bool = ..., silent: bool = ..., @@ -920,7 +920,7 @@ async def send( allowed_mentions: Optional[AllowedMentions] = None, reference: Optional[Union[Message, MessageReference, PartialMessage]] = None, mention_author: Optional[bool] = None, - view: Optional[View] = None, + view: Optional[BaseView] = None, suppress_embeds: bool = False, ephemeral: bool = False, silent: bool = False, diff --git a/discord/ui/view.py b/discord/ui/view.py index d0f187187f0b..aff9310091fb 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -733,7 +733,7 @@ def to_components(self): child.to_component_dict(), ) - return child + return components def add_item(self, item: Item[Any]) -> Self: if len(self._children) >= 10: From 59991e9ed7ce411dc87470b9695434030e4b65d3 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Fri, 7 Mar 2025 16:06:37 +0100 Subject: [PATCH 043/272] chore: fix Container.to_components returning NotImplemented --- discord/ui/container.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index 1b50eceb9c32..da1770028322 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -26,7 +26,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Type, TypeVar from .item import Item -from .view import View, _component_to_item, LayoutView +from .view import BaseView, _component_to_item, LayoutView from .dynamic import DynamicItem from ..enums import ComponentType from ..utils import MISSING @@ -42,7 +42,7 @@ __all__ = ('Container',) -class Container(View, Item[V]): +class Container(BaseView, Item[V]): """Represents a UI container. .. versionadded:: 2.6 @@ -59,9 +59,6 @@ class Container(View, Item[V]): spoiler: :class:`bool` Whether to flag this container as a spoiler. Defaults to ``False``. - timeout: Optional[:class:`float`] - Timeout in seconds from last interaction with the UI before no longer accepting input. - If ``None`` then there is no timeout. row: Optional[:class:`int`] The relative row this container belongs to. By default items are arranged automatically into those rows. If you'd @@ -73,8 +70,6 @@ class Container(View, Item[V]): The ID of this component. This must be unique across the view. """ - __discord_ui_container__ = True - def __init__( self, children: List[Item[Any]] = MISSING, @@ -82,11 +77,10 @@ def __init__( accent_colour: Optional[Colour] = None, accent_color: Optional[Color] = None, spoiler: bool = False, - timeout: Optional[float] = 180, row: Optional[int] = None, id: Optional[str] = None, ) -> None: - super().__init__(timeout=timeout) + super().__init__(timeout=None) if children is not MISSING: if len(children) + len(self._children) > 10: raise ValueError('maximum number of components exceeded') @@ -134,8 +128,14 @@ def _is_v2(self) -> bool: def is_dispatchable(self) -> bool: return any(c.is_dispatchable() for c in self.children) + def to_components(self) -> List[Dict[str, Any]]: + components = [] + for child in self._children: + components.append(child.to_component_dict()) + return components + def to_component_dict(self) -> Dict[str, Any]: - components = super().to_components() + components = self.to_components() return { 'type': self.type.value, 'accent_color': self._colour.value if self._colour else None, From 502051af7132b55d67504f140b7e25e53afa91c5 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Fri, 7 Mar 2025 16:27:19 +0100 Subject: [PATCH 044/272] chore: update ActionRow and View --- discord/ui/__init__.py | 1 + discord/ui/action_row.py | 28 +++++++++++----------------- discord/ui/view.py | 7 +++---- 3 files changed, 15 insertions(+), 21 deletions(-) diff --git a/discord/ui/__init__.py b/discord/ui/__init__.py index 62a78634c72d..4d613f14faf0 100644 --- a/discord/ui/__init__.py +++ b/discord/ui/__init__.py @@ -23,3 +23,4 @@ from .separator import * from .text_display import * from .thumbnail import * +from .action_row import * diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 5727860562ba..a7017159a177 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -43,7 +43,7 @@ ) from .item import Item, ItemCallbackType -from .button import Button +from .button import Button, button as _button from .dynamic import DynamicItem from .select import select as _select, Select, UserSelect, RoleSelect, ChannelSelect, MentionableSelect from ..components import ActionRow as ActionRowComponent @@ -281,22 +281,16 @@ def button( """ def decorator(func: ItemCallbackType[V, Button[V]]) -> ItemCallbackType[V, Button[V]]: - if not inspect.iscoroutinefunction(func): - raise TypeError('button function must be a coroutine function') - - func.__discord_ui_parent__ = self - func.__discord_ui_modal_type__ = Button - func.__discord_ui_model_kwargs__ = { - 'style': style, - 'custom_id': custom_id, - 'url': None, - 'disabled': disabled, - 'label': label, - 'emoji': emoji, - 'row': None, - 'sku_id': None, - } - return func + ret = _button( + label=label, + custom_id=custom_id, + disabled=disabled, + style=style, + emoji=emoji, + row=None, + )(func) + ret.__discord_ui_parent__ = self # type: ignore + return ret # type: ignore return decorator # type: ignore diff --git a/discord/ui/view.py b/discord/ui/view.py index aff9310091fb..bafcfedff4d0 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -223,7 +223,7 @@ def _init_children(self) -> List[Item[Self]]: parent = getattr(raw, '__discord_ui_parent__', None) if parent and parent._view is None: parent._view = self - item = raw + children.append(raw) else: item: Item = raw.__discord_ui_model_type__(**raw.__discord_ui_model_kwargs__) item.callback = _ViewCallback(raw, self, item) # type: ignore @@ -231,10 +231,9 @@ def _init_children(self) -> List[Item[Self]]: setattr(self, raw.__name__, item) parent = getattr(raw, '__discord_ui_parent__', None) if parent: - if not self._is_v2(): - raise RuntimeError('This view cannot have v2 items') parent._children.append(item) - children.append(item) + continue + children.append(item) return children From f1f6ef82ab440931abf8be49e34f735149f7be0d Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Fri, 7 Mar 2025 16:29:19 +0100 Subject: [PATCH 045/272] chore: remove unused imports --- discord/ui/action_row.py | 1 - 1 file changed, 1 deletion(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index a7017159a177..4daf0283973d 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -23,7 +23,6 @@ """ from __future__ import annotations -import inspect import os from typing import ( TYPE_CHECKING, From 9e18c5af8142ead1030a3df2f7bb587043d3055d Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Fri, 7 Mar 2025 16:42:24 +0100 Subject: [PATCH 046/272] chore: typing stuff --- discord/abc.py | 10 +++++----- discord/channel.py | 4 ++-- discord/http.py | 4 ++-- discord/interactions.py | 10 +++++----- discord/message.py | 14 +++++++------- discord/webhook/async_.py | 8 ++++---- discord/webhook/sync.py | 6 ++++-- 7 files changed, 29 insertions(+), 27 deletions(-) diff --git a/discord/abc.py b/discord/abc.py index 70531fb2005e..666120c543e3 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -95,7 +95,7 @@ ) from .poll import Poll from .threads import Thread - from .ui.view import View + from .ui.view import BaseView from .types.channel import ( PermissionOverwrite as PermissionOverwritePayload, Channel as ChannelPayload, @@ -1388,7 +1388,7 @@ async def send( allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: View = ..., + view: BaseView = ..., suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., @@ -1409,7 +1409,7 @@ async def send( allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: View = ..., + view: BaseView = ..., suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., @@ -1430,7 +1430,7 @@ async def send( allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: View = ..., + view: BaseView = ..., suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., @@ -1451,7 +1451,7 @@ async def send( allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: View = ..., + view: BaseView = ..., suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., diff --git a/discord/channel.py b/discord/channel.py index a306707d6fdb..3dc43d388549 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -100,7 +100,7 @@ from .file import File from .user import ClientUser, User, BaseUser from .guild import Guild, GuildChannel as GuildChannelType - from .ui.view import View + from .ui.view import BaseView from .types.channel import ( TextChannel as TextChannelPayload, NewsChannel as NewsChannelPayload, @@ -2857,7 +2857,7 @@ async def create_thread( allowed_mentions: AllowedMentions = MISSING, mention_author: bool = MISSING, applied_tags: Sequence[ForumTag] = MISSING, - view: View = MISSING, + view: BaseView = MISSING, suppress_embeds: bool = False, reason: Optional[str] = None, ) -> ThreadWithMessage: diff --git a/discord/http.py b/discord/http.py index c6e4d1377277..e0fca595861d 100644 --- a/discord/http.py +++ b/discord/http.py @@ -64,7 +64,7 @@ if TYPE_CHECKING: from typing_extensions import Self - from .ui.view import View + from .ui.view import BaseView from .embeds import Embed from .message import Attachment from .poll import Poll @@ -150,7 +150,7 @@ def handle_message_parameters( embed: Optional[Embed] = MISSING, embeds: Sequence[Embed] = MISSING, attachments: Sequence[Union[Attachment, File]] = MISSING, - view: Optional[View] = MISSING, + view: Optional[BaseView] = MISSING, allowed_mentions: Optional[AllowedMentions] = MISSING, message_reference: Optional[message.MessageReference] = MISSING, stickers: Optional[SnowflakeList] = MISSING, diff --git a/discord/interactions.py b/discord/interactions.py index b9d9a4d11ea7..ddc5094a4d2d 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -76,7 +76,7 @@ from .mentions import AllowedMentions from aiohttp import ClientSession from .embeds import Embed - from .ui.view import View + from .ui.view import BaseView from .app_commands.models import Choice, ChoiceT from .ui.modal import Modal from .channel import VoiceChannel, StageChannel, TextChannel, ForumChannel, CategoryChannel, DMChannel, GroupChannel @@ -476,7 +476,7 @@ async def edit_original_response( embeds: Sequence[Embed] = MISSING, embed: Optional[Embed] = MISSING, attachments: Sequence[Union[Attachment, File]] = MISSING, - view: Optional[View] = MISSING, + view: Optional[BaseView] = MISSING, allowed_mentions: Optional[AllowedMentions] = None, poll: Poll = MISSING, ) -> InteractionMessage: @@ -897,7 +897,7 @@ async def send_message( embeds: Sequence[Embed] = MISSING, file: File = MISSING, files: Sequence[File] = MISSING, - view: View = MISSING, + view: BaseView = MISSING, tts: bool = False, ephemeral: bool = False, allowed_mentions: AllowedMentions = MISSING, @@ -1046,7 +1046,7 @@ async def edit_message( embed: Optional[Embed] = MISSING, embeds: Sequence[Embed] = MISSING, attachments: Sequence[Union[Attachment, File]] = MISSING, - view: Optional[View] = MISSING, + view: Optional[BaseView] = MISSING, allowed_mentions: Optional[AllowedMentions] = MISSING, delete_after: Optional[float] = None, suppress_embeds: bool = MISSING, @@ -1334,7 +1334,7 @@ async def edit( embeds: Sequence[Embed] = MISSING, embed: Optional[Embed] = MISSING, attachments: Sequence[Union[Attachment, File]] = MISSING, - view: Optional[View] = MISSING, + view: Optional[BaseView] = MISSING, allowed_mentions: Optional[AllowedMentions] = None, delete_after: Optional[float] = None, poll: Poll = MISSING, diff --git a/discord/message.py b/discord/message.py index c0a853ce3cba..0dee8df9c426 100644 --- a/discord/message.py +++ b/discord/message.py @@ -101,7 +101,7 @@ from .mentions import AllowedMentions from .user import User from .role import Role - from .ui.view import View + from .ui.view import BaseView EmojiInputType = Union[Emoji, PartialEmoji, str] @@ -1305,7 +1305,7 @@ async def edit( attachments: Sequence[Union[Attachment, File]] = ..., delete_after: Optional[float] = ..., allowed_mentions: Optional[AllowedMentions] = ..., - view: Optional[View] = ..., + view: Optional[BaseView] = ..., ) -> Message: ... @@ -1318,7 +1318,7 @@ async def edit( attachments: Sequence[Union[Attachment, File]] = ..., delete_after: Optional[float] = ..., allowed_mentions: Optional[AllowedMentions] = ..., - view: Optional[View] = ..., + view: Optional[BaseView] = ..., ) -> Message: ... @@ -1331,7 +1331,7 @@ async def edit( attachments: Sequence[Union[Attachment, File]] = MISSING, delete_after: Optional[float] = None, allowed_mentions: Optional[AllowedMentions] = MISSING, - view: Optional[View] = MISSING, + view: Optional[BaseView] = MISSING, ) -> Message: """|coro| @@ -2839,7 +2839,7 @@ async def edit( suppress: bool = ..., delete_after: Optional[float] = ..., allowed_mentions: Optional[AllowedMentions] = ..., - view: Optional[View] = ..., + view: Optional[BaseView] = ..., ) -> Message: ... @@ -2853,7 +2853,7 @@ async def edit( suppress: bool = ..., delete_after: Optional[float] = ..., allowed_mentions: Optional[AllowedMentions] = ..., - view: Optional[View] = ..., + view: Optional[BaseView] = ..., ) -> Message: ... @@ -2867,7 +2867,7 @@ async def edit( suppress: bool = False, delete_after: Optional[float] = None, allowed_mentions: Optional[AllowedMentions] = MISSING, - view: Optional[View] = MISSING, + view: Optional[BaseView] = MISSING, ) -> Message: """|coro| diff --git a/discord/webhook/async_.py b/discord/webhook/async_.py index f1cfb573bb71..2ddc451f6d04 100644 --- a/discord/webhook/async_.py +++ b/discord/webhook/async_.py @@ -71,7 +71,7 @@ from ..emoji import Emoji from ..channel import VoiceChannel from ..abc import Snowflake - from ..ui.view import View + from ..ui.view import BaseView from ..poll import Poll import datetime from ..types.webhook import ( @@ -1619,7 +1619,7 @@ async def send( embed: Embed = MISSING, embeds: Sequence[Embed] = MISSING, allowed_mentions: AllowedMentions = MISSING, - view: View = MISSING, + view: BaseView = MISSING, thread: Snowflake = MISSING, thread_name: str = MISSING, wait: Literal[True], @@ -1644,7 +1644,7 @@ async def send( embed: Embed = MISSING, embeds: Sequence[Embed] = MISSING, allowed_mentions: AllowedMentions = MISSING, - view: View = MISSING, + view: BaseView = MISSING, thread: Snowflake = MISSING, thread_name: str = MISSING, wait: Literal[False] = ..., @@ -1668,7 +1668,7 @@ async def send( embed: Embed = MISSING, embeds: Sequence[Embed] = MISSING, allowed_mentions: AllowedMentions = MISSING, - view: View = MISSING, + view: BaseView = MISSING, thread: Snowflake = MISSING, thread_name: str = MISSING, wait: bool = False, diff --git a/discord/webhook/sync.py b/discord/webhook/sync.py index 171931b12ea2..db59b4659866 100644 --- a/discord/webhook/sync.py +++ b/discord/webhook/sync.py @@ -66,7 +66,7 @@ from ..message import Attachment from ..abc import Snowflake from ..state import ConnectionState - from ..ui import View + from ..ui.view import BaseView from ..types.webhook import ( Webhook as WebhookPayload, ) @@ -876,6 +876,7 @@ def send( silent: bool = MISSING, applied_tags: List[ForumTag] = MISSING, poll: Poll = MISSING, + view: BaseView = MISSING, ) -> SyncWebhookMessage: ... @@ -899,6 +900,7 @@ def send( silent: bool = MISSING, applied_tags: List[ForumTag] = MISSING, poll: Poll = MISSING, + view: BaseView = MISSING, ) -> None: ... @@ -921,7 +923,7 @@ def send( silent: bool = False, applied_tags: List[ForumTag] = MISSING, poll: Poll = MISSING, - view: View = MISSING, + view: BaseView = MISSING, ) -> Optional[SyncWebhookMessage]: """Sends a message using the webhook. From e660010c2501e0be7bd9cceef6b84264e1e81b9a Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Fri, 7 Mar 2025 16:46:32 +0100 Subject: [PATCH 047/272] chore: more typing stuff --- discord/abc.py | 2 +- discord/message.py | 8 ++++---- discord/webhook/async_.py | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/discord/abc.py b/discord/abc.py index 666120c543e3..5d264283e69b 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -1473,7 +1473,7 @@ async def send( allowed_mentions: Optional[AllowedMentions] = None, reference: Optional[Union[Message, MessageReference, PartialMessage]] = None, mention_author: Optional[bool] = None, - view: Optional[View] = None, + view: Optional[BaseView] = None, suppress_embeds: bool = False, silent: bool = False, poll: Optional[Poll] = None, diff --git a/discord/message.py b/discord/message.py index 0dee8df9c426..b3b807610676 100644 --- a/discord/message.py +++ b/discord/message.py @@ -1760,7 +1760,7 @@ async def reply( allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: View = ..., + view: BaseView = ..., suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., @@ -1781,7 +1781,7 @@ async def reply( allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: View = ..., + view: BaseView = ..., suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., @@ -1802,7 +1802,7 @@ async def reply( allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: View = ..., + view: BaseView = ..., suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., @@ -1823,7 +1823,7 @@ async def reply( allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: View = ..., + view: BaseView = ..., suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., diff --git a/discord/webhook/async_.py b/discord/webhook/async_.py index 2ddc451f6d04..d62807779051 100644 --- a/discord/webhook/async_.py +++ b/discord/webhook/async_.py @@ -552,7 +552,7 @@ def interaction_message_response_params( embed: Optional[Embed] = MISSING, embeds: Sequence[Embed] = MISSING, attachments: Sequence[Union[Attachment, File]] = MISSING, - view: Optional[View] = MISSING, + view: Optional[BaseView] = MISSING, allowed_mentions: Optional[AllowedMentions] = MISSING, previous_allowed_mentions: Optional[AllowedMentions] = None, poll: Poll = MISSING, @@ -809,7 +809,7 @@ async def edit( embeds: Sequence[Embed] = MISSING, embed: Optional[Embed] = MISSING, attachments: Sequence[Union[Attachment, File]] = MISSING, - view: Optional[View] = MISSING, + view: Optional[BaseView] = MISSING, allowed_mentions: Optional[AllowedMentions] = None, ) -> WebhookMessage: """|coro| @@ -1946,7 +1946,7 @@ async def edit_message( embeds: Sequence[Embed] = MISSING, embed: Optional[Embed] = MISSING, attachments: Sequence[Union[Attachment, File]] = MISSING, - view: Optional[View] = MISSING, + view: Optional[BaseView] = MISSING, allowed_mentions: Optional[AllowedMentions] = None, thread: Snowflake = MISSING, ) -> WebhookMessage: From c48c512d889139eeda732ce4fc146bd50f39583e Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 9 Mar 2025 22:07:39 +0100 Subject: [PATCH 048/272] chore: some fixes of bugs reported on the bikeshedding post --- discord/ui/action_row.py | 11 ++--- discord/ui/button.py | 7 ++- discord/ui/container.py | 90 ++++++++++++++++++++++++++++++------- discord/ui/file.py | 4 +- discord/ui/item.py | 9 ++-- discord/ui/media_gallery.py | 4 +- discord/ui/section.py | 4 +- discord/ui/select.py | 44 +++++++++++++++++- discord/ui/separator.py | 4 +- discord/ui/text_display.py | 4 +- discord/ui/thumbnail.py | 8 ++-- discord/ui/view.py | 8 ++++ 12 files changed, 155 insertions(+), 42 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 4daf0283973d..510d6175bf1a 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -94,19 +94,20 @@ class ActionRow(Item[V]): Parameters ---------- - id: Optional[:class:`str`] - The ID of this action row. Defaults to ``None``. + id: Optional[:class:`int`] + The ID of this component. This must be unique across the view. """ __action_row_children_items__: ClassVar[List[ItemCallbackType[Any, Any]]] = [] __discord_ui_action_row__: ClassVar[bool] = True + __pending_view__: ClassVar[bool] = True - def __init__(self, *, id: Optional[str] = None) -> None: + def __init__(self, *, id: Optional[int] = None) -> None: super().__init__() - - self.id: str = id or os.urandom(16).hex() self._children: List[Item[Any]] = self._init_children() + self.id = id + def __init_subclass__(cls) -> None: super().__init_subclass__() diff --git a/discord/ui/button.py b/discord/ui/button.py index df21c770fc4b..82a485f9110f 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -83,6 +83,10 @@ class Button(Item[V]): nor ``custom_id``. .. versionadded:: 2.4 + id: Optional[:class:`int`] + The ID of this component. This must be unique across the view. + + .. versionadded:: 2.6 """ __item_repr_attributes__: Tuple[str, ...] = ( @@ -106,6 +110,7 @@ def __init__( emoji: Optional[Union[str, Emoji, PartialEmoji]] = None, row: Optional[int] = None, sku_id: Optional[int] = None, + id: Optional[int] = None, ): super().__init__() if custom_id is not None and (url is not None or sku_id is not None): @@ -147,7 +152,7 @@ def __init__( ) self._parent: Optional[ActionRow] = None self.row = row - self.id = custom_id + self.id = id @property def style(self) -> ButtonStyle: diff --git a/discord/ui/container.py b/discord/ui/container.py index da1770028322..b60c1ec407fc 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -23,10 +23,10 @@ """ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Type, TypeVar +from typing import TYPE_CHECKING, Any, ClassVar, Coroutine, Dict, List, Literal, Optional, Tuple, Type, TypeVar, Union -from .item import Item -from .view import BaseView, _component_to_item, LayoutView +from .item import Item, ItemCallbackType +from .view import _component_to_item, LayoutView from .dynamic import DynamicItem from ..enums import ComponentType from ..utils import MISSING @@ -36,13 +36,26 @@ from ..colour import Colour, Color from ..components import Container as ContainerComponent + from ..interactions import Interaction V = TypeVar('V', bound='LayoutView', covariant=True) __all__ = ('Container',) -class Container(BaseView, Item[V]): +class _ContainerCallback: + __slots__ = ('container', 'callback', 'item') + + def __init__(self, callback: ItemCallbackType[Any, Any], container: Container, item: Item[Any]) -> None: + self.callback: ItemCallbackType[Any, Any] = callback + self.container: Container = container + self.item: Item[Any] = item + + def __call__(self, interaction: Interaction) -> Coroutine[Any, Any, Any]: + return self.callback(self.container, interaction, self.item) + + +class Container(Item[V]): """Represents a UI container. .. versionadded:: 2.6 @@ -66,41 +79,86 @@ class Container(BaseView, Item[V]): 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 9 (i.e. zero indexed) - id: Optional[:class:`str`] + id: Optional[:class:`int`] The ID of this component. This must be unique across the view. """ + __container_children_items__: ClassVar[List[Union[ItemCallbackType[Any, Any], Item[Any]]]] = [] + __pending_view__: ClassVar[bool] = True + def __init__( self, - children: List[Item[Any]] = MISSING, + children: List[Item[V]] = MISSING, *, accent_colour: Optional[Colour] = None, accent_color: Optional[Color] = None, spoiler: bool = False, row: Optional[int] = None, - id: Optional[str] = None, + id: Optional[int] = None, ) -> None: - super().__init__(timeout=None) + self._children: List[Item[V]] = self._init_children() + if children is not MISSING: if len(children) + len(self._children) > 10: - raise ValueError('maximum number of components exceeded') - self._children.extend(children) + raise ValueError('maximum number of children exceeded') self.spoiler: bool = spoiler self._colour = accent_colour or accent_color self._view: Optional[V] = None - self._row: Optional[int] = None - self._rendered_row: Optional[int] = None - self.row: Optional[int] = row - self.id: Optional[str] = id + self.row = row + self.id = id + + def _init_children(self) -> List[Item[Any]]: + children = [] + + for raw in self.__container_children_items__: + if isinstance(raw, Item): + children.append(raw) + else: + # action rows can be created inside containers, and then callbacks can exist here + # so we create items based off them + item: Item = raw.__discord_ui_model_type__(**raw.__discord_ui_model_kwargs__) + item.callback = _ContainerCallback(raw, self, item) # type: ignore + setattr(self, raw.__name__, item) + # this should not fail because in order for a function to be here it should be from + # an action row and must have passed the check in __init_subclass__, but still + # guarding it + parent = getattr(raw, '__discord_ui_parent__', None) + if parent is None: + raise RuntimeError(f'{raw.__name__} is not a valid item for a Container') + parent._children.append(item) + # we donnot append it to the children list because technically these buttons and + # selects are not from the container but the action row itself. + + return children + + def __init_subclass__(cls) -> None: + super().__init_subclass__() + + children: Dict[str, Union[ItemCallbackType[Any, Any], Item[Any]]] = {} + for base in reversed(cls.__mro__): + for name, member in base.__dict__.items(): + if isinstance(member, Item): + children[name] = member + if hasattr(member, '__discord_ui_model_type__') and hasattr(member, '__discord_ui_parent__'): + children[name] = member + + cls.__container_children_items__ = list(children.values()) + + def _update_children_view(self, view) -> None: + for child in self._children: + child._view = view + if getattr(child, '__pending_view__', False): + # if the item is an action row which child's view can be updated, then update it + child._update_children_view(view) # type: ignore @property - def children(self) -> List[Item[Self]]: + def children(self) -> List[Item[V]]: """List[:class:`Item`]: The children of this container.""" return self._children.copy() @children.setter - def children(self, value: List[Item[Any]]) -> None: + def children(self, value: List[Item[V]]) -> None: self._children = value @property diff --git a/discord/ui/file.py b/discord/ui/file.py index 2654d351c305..7d065f0ffcbb 100644 --- a/discord/ui/file.py +++ b/discord/ui/file.py @@ -59,7 +59,7 @@ class File(Item[V]): 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 9 (i.e. zero indexed) - id: Optional[:class:`str`] + id: Optional[:class:`int`] The ID of this component. This must be unique across the view. """ @@ -69,7 +69,7 @@ def __init__( *, spoiler: bool = False, row: Optional[int] = None, - id: Optional[str] = None, + id: Optional[int] = None, ) -> None: super().__init__() self._underlying = FileComponent._raw_construct( diff --git a/discord/ui/item.py b/discord/ui/item.py index 1fa68b68c701..bcee854a87aa 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -70,7 +70,7 @@ def __init__(self): # actually affect the intended purpose of this check because from_component is # only called upon edit and we're mainly interested during initial creation time. self._provided_custom_id: bool = False - self._id: Optional[str] = None + self._id: Optional[int] = None self._max_row: int = 5 if not self._is_v2() else 10 def to_component_dict(self) -> Dict[str, Any]: @@ -126,14 +126,13 @@ def view(self) -> Optional[V]: return self._view @property - def id(self) -> Optional[str]: - """Optional[:class:`str`]: The ID of this component. For non v2 components this is the - equivalent to ``custom_id``. + def id(self) -> Optional[int]: + """Optional[:class:`int`]: The ID of this component. """ return self._id @id.setter - def id(self, value: Optional[str]) -> None: + def id(self, value: Optional[int]) -> None: self._id = value async def callback(self, interaction: Interaction[ClientT]) -> Any: diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index f9e1fb2644c7..ee0fb3cf0cb0 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -60,7 +60,7 @@ class MediaGallery(Item[V]): 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 9 (i.e. zero indexed) - id: Optional[:class:`str`] + id: Optional[:class:`int`] The ID of this component. This must be unique across the view. """ @@ -69,7 +69,7 @@ def __init__( items: List[MediaGalleryItem], *, row: Optional[int] = None, - id: Optional[str] = None, + id: Optional[int] = None, ) -> None: super().__init__() diff --git a/discord/ui/section.py b/discord/ui/section.py index ba919beb81b6..0aa164d883af 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -59,7 +59,7 @@ class Section(Item[V]): 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 9 (i.e. zero indexed) - id: Optional[:class:`str`] + id: Optional[:class:`int`] The ID of this component. This must be unique across the view. """ @@ -74,7 +74,7 @@ def __init__( *, accessory: Item[Any], row: Optional[int] = None, - id: Optional[str] = None, + id: Optional[int] = None, ) -> None: super().__init__() self._children: List[Item[Any]] = [] diff --git a/discord/ui/select.py b/discord/ui/select.py index f5a9fcbee2e1..efa8a9e6880e 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -239,6 +239,7 @@ def __init__( options: List[SelectOption] = MISSING, channel_types: List[ChannelType] = MISSING, default_values: Sequence[SelectDefaultValue] = MISSING, + id: Optional[int] = None, ) -> None: super().__init__() self._provided_custom_id = custom_id is not MISSING @@ -259,7 +260,7 @@ def __init__( ) self.row = row - self.id = custom_id if custom_id is not MISSING else None + self.id = id self._parent: Optional[ActionRow] = None self._values: List[PossibleValue] = [] @@ -393,6 +394,10 @@ class Select(BaseSelect[V]): 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). + id: Optional[:class:`int`] + The ID of the component. This must be unique across the view. + + .. versionadded:: 2.6 """ __component_attributes__ = BaseSelect.__component_attributes__ + ('options',) @@ -407,6 +412,7 @@ def __init__( options: List[SelectOption] = MISSING, disabled: bool = False, row: Optional[int] = None, + id: Optional[int] = None, ) -> None: super().__init__( self.type, @@ -417,6 +423,7 @@ def __init__( disabled=disabled, options=options, row=row, + id=id, ) @property @@ -548,6 +555,10 @@ class UserSelect(BaseSelect[V]): 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). + id: Optional[:class:`int`] + The ID of the component. This must be unique across the view. + + .. versionadded:: 2.6 """ __component_attributes__ = BaseSelect.__component_attributes__ + ('default_values',) @@ -562,6 +573,7 @@ def __init__( disabled: bool = False, row: Optional[int] = None, default_values: Sequence[ValidDefaultValues] = MISSING, + id: Optional[int] = None, ) -> None: super().__init__( self.type, @@ -572,6 +584,7 @@ def __init__( disabled=disabled, row=row, default_values=_handle_select_defaults(default_values, self.type), + id=id, ) @property @@ -640,6 +653,10 @@ class RoleSelect(BaseSelect[V]): 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). + id: Optional[:class:`int`] + The ID of the component. This must be unique across the view. + + .. versionadded:: 2.6 """ __component_attributes__ = BaseSelect.__component_attributes__ + ('default_values',) @@ -654,6 +671,7 @@ def __init__( disabled: bool = False, row: Optional[int] = None, default_values: Sequence[ValidDefaultValues] = MISSING, + id: Optional[int] = None, ) -> None: super().__init__( self.type, @@ -664,6 +682,7 @@ def __init__( disabled=disabled, row=row, default_values=_handle_select_defaults(default_values, self.type), + id=id, ) @property @@ -728,6 +747,10 @@ class MentionableSelect(BaseSelect[V]): 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). + id: Optional[:class:`int`] + The ID of the component. This must be unique across the view. + + .. versionadded:: 2.6 """ __component_attributes__ = BaseSelect.__component_attributes__ + ('default_values',) @@ -742,6 +765,7 @@ def __init__( disabled: bool = False, row: Optional[int] = None, default_values: Sequence[ValidDefaultValues] = MISSING, + id: Optional[int] = None, ) -> None: super().__init__( self.type, @@ -752,6 +776,7 @@ def __init__( disabled=disabled, row=row, default_values=_handle_select_defaults(default_values, self.type), + id=id, ) @property @@ -822,6 +847,10 @@ class ChannelSelect(BaseSelect[V]): 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). + id: Optional[:class:`int`] + The ID of the component. This must be unique across the view. + + .. versionadded:: 2.6 """ __component_attributes__ = BaseSelect.__component_attributes__ + ( @@ -840,6 +869,7 @@ def __init__( disabled: bool = False, row: Optional[int] = None, default_values: Sequence[ValidDefaultValues] = MISSING, + id: Optional[int] = None, ) -> None: super().__init__( self.type, @@ -851,6 +881,7 @@ def __init__( row=row, channel_types=channel_types, default_values=_handle_select_defaults(default_values, self.type), + id=id, ) @property @@ -902,6 +933,7 @@ def select( max_values: int = ..., disabled: bool = ..., row: Optional[int] = ..., + id: Optional[int] = ..., ) -> SelectCallbackDecorator[V, SelectT]: ... @@ -919,6 +951,7 @@ def select( disabled: bool = ..., default_values: Sequence[ValidDefaultValues] = ..., row: Optional[int] = ..., + id: Optional[int] = ..., ) -> SelectCallbackDecorator[V, UserSelectT]: ... @@ -936,6 +969,7 @@ def select( disabled: bool = ..., default_values: Sequence[ValidDefaultValues] = ..., row: Optional[int] = ..., + id: Optional[int] = ..., ) -> SelectCallbackDecorator[V, RoleSelectT]: ... @@ -953,6 +987,7 @@ def select( disabled: bool = ..., default_values: Sequence[ValidDefaultValues] = ..., row: Optional[int] = ..., + id: Optional[int] = ..., ) -> SelectCallbackDecorator[V, ChannelSelectT]: ... @@ -970,6 +1005,7 @@ def select( disabled: bool = ..., default_values: Sequence[ValidDefaultValues] = ..., row: Optional[int] = ..., + id: Optional[int] = ..., ) -> SelectCallbackDecorator[V, MentionableSelectT]: ... @@ -986,6 +1022,7 @@ def select( disabled: bool = False, default_values: Sequence[ValidDefaultValues] = MISSING, row: Optional[int] = None, + id: Optional[int] = None, ) -> SelectCallbackDecorator[V, BaseSelectT]: """A decorator that attaches a select menu to a component. @@ -1065,6 +1102,10 @@ async def select_channels(self, interaction: discord.Interaction, select: Channe Number of items must be in range of ``min_values`` and ``max_values``. .. versionadded:: 2.4 + id: Optional[:class:`int`] + The ID of the component. This must be unique across the view. + + .. versionadded:: 2.6 """ def decorator(func: ItemCallbackType[V, BaseSelectT]) -> ItemCallbackType[V, BaseSelectT]: @@ -1083,6 +1124,7 @@ def decorator(func: ItemCallbackType[V, BaseSelectT]) -> ItemCallbackType[V, Bas 'min_values': min_values, 'max_values': max_values, 'disabled': disabled, + 'id': id, } if issubclass(callback_cls, Select): func.__discord_ui_model_kwargs__['options'] = options diff --git a/discord/ui/separator.py b/discord/ui/separator.py index b9ff955adcc5..394e9ac78df5 100644 --- a/discord/ui/separator.py +++ b/discord/ui/separator.py @@ -58,7 +58,7 @@ class Separator(Item[V]): 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 9 (i.e. zero indexed) - id: Optional[:class:`str`] + id: Optional[:class:`int`] The ID of this component. This must be unique across the view. """ @@ -68,7 +68,7 @@ def __init__( visible: bool = True, spacing: SeparatorSize = SeparatorSize.small, row: Optional[int] = None, - id: Optional[str] = None, + id: Optional[int] = None, ) -> None: super().__init__() self._underlying = SeparatorComponent._raw_construct( diff --git a/discord/ui/text_display.py b/discord/ui/text_display.py index e55c72ba4970..8e22905ebc47 100644 --- a/discord/ui/text_display.py +++ b/discord/ui/text_display.py @@ -55,11 +55,11 @@ class TextDisplay(Item[V]): 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 9 (i.e. zero indexed) - id: Optional[:class:`str`] + id: Optional[:class:`int`] The ID of this component. This must be unique across the view. """ - def __init__(self, content: str, *, row: Optional[int] = None, id: Optional[str] = None) -> None: + def __init__(self, content: str, *, row: Optional[int] = None, id: Optional[int] = None) -> None: super().__init__() self.content: str = content diff --git a/discord/ui/thumbnail.py b/discord/ui/thumbnail.py index 0e7def382559..e9a2c13f5a9b 100644 --- a/discord/ui/thumbnail.py +++ b/discord/ui/thumbnail.py @@ -48,8 +48,8 @@ class Thumbnail(Item[V]): Parameters ---------- media: Union[:class:`str`, :class:`discord.UnfurledMediaItem`] - The media of the thumbnail. This can be a string that points to a local - attachment uploaded within this item. URLs must match the ``attachment://file-name.extension`` + The media of the thumbnail. This can be a URL or a reference + to an attachment that matches the ``attachment://filename.extension`` structure. description: Optional[:class:`str`] The description of this thumbnail. Defaults to ``None``. @@ -62,7 +62,7 @@ class Thumbnail(Item[V]): 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 9 (i.e. zero indexed) - id: Optional[:class:`str`] + id: Optional[:class:`int`] The ID of this component. This must be unique across the view. """ @@ -73,7 +73,7 @@ def __init__( description: Optional[str] = None, spoiler: bool = False, row: Optional[int] = None, - id: Optional[str] = None, + id: Optional[int] = None, ) -> None: super().__init__() diff --git a/discord/ui/view.py b/discord/ui/view.py index bafcfedff4d0..9b0709fd4baa 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -223,6 +223,8 @@ def _init_children(self) -> List[Item[Self]]: parent = getattr(raw, '__discord_ui_parent__', None) if parent and parent._view is None: parent._view = self + if getattr(raw, '__pending_view__', False): + raw._update_children_view(self) # type: ignore children.append(raw) else: item: Item = raw.__discord_ui_model_type__(**raw.__discord_ui_model_kwargs__) @@ -581,6 +583,8 @@ def __init_subclass__(cls) -> None: for name, member in base.__dict__.items(): if hasattr(member, '__discord_ui_model_type__'): children[name] = member + elif isinstance(member, Item) and member._is_v2(): + raise RuntimeError(f'{name} cannot be added to this View') if len(children) > 25: raise TypeError('View cannot have more than 25 children') @@ -707,10 +711,14 @@ def __init__(self, *, timeout: Optional[float] = 180.0) -> None: def __init_subclass__(cls) -> None: children: Dict[str, Item[Any]] = {} + row = 0 + for base in reversed(cls.__mro__): for name, member in base.__dict__.items(): if isinstance(member, Item): + member._rendered_row = member._row or row children[name] = member + row += 1 elif hasattr(member, '__discord_ui_model_type__') and getattr(member, '__discord_ui_parent__', None): children[name] = member From 8cb80bf8f7824d8636581c8d12c5af16cfd0f0c9 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 9 Mar 2025 22:08:16 +0100 Subject: [PATCH 049/272] chore: improve check on container.__init_subclass__ --- discord/ui/container.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index b60c1ec407fc..cc2405a757bb 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -140,7 +140,7 @@ def __init_subclass__(cls) -> None: for name, member in base.__dict__.items(): if isinstance(member, Item): children[name] = member - if hasattr(member, '__discord_ui_model_type__') and hasattr(member, '__discord_ui_parent__'): + if hasattr(member, '__discord_ui_model_type__') and getattr(member, '__discord_ui_parent__', None): children[name] = member cls.__container_children_items__ = list(children.values()) From 7601533fe96bd2ee43c2a55eccbfe02fc433be97 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 9 Mar 2025 22:35:37 +0100 Subject: [PATCH 050/272] chore: add id attr to components and black item.py --- discord/components.py | 112 +++++++++++++++++++++++++++++++++++---- discord/ui/action_row.py | 5 +- discord/ui/item.py | 3 +- 3 files changed, 107 insertions(+), 13 deletions(-) diff --git a/discord/components.py b/discord/components.py index ef7d676700ac..a9a6de24ba5a 100644 --- a/discord/components.py +++ b/discord/components.py @@ -177,13 +177,18 @@ class ActionRow(Component): ------------ children: List[Union[:class:`Button`, :class:`SelectMenu`, :class:`TextInput`]] The children components that this holds, if any. + id: Optional[:class:`int`] + The ID of this component. + + .. versionadded:: 2.6 """ - __slots__: Tuple[str, ...] = ('children',) + __slots__: Tuple[str, ...] = ('children', 'id') __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ def __init__(self, data: ActionRowPayload, /) -> None: + self.id: Optional[int] = data.get('id') self.children: List[ActionRowChildComponentType] = [] for component_data in data.get('components', []): @@ -198,10 +203,13 @@ def type(self) -> Literal[ComponentType.action_row]: return ComponentType.action_row def to_dict(self) -> ActionRowPayload: - return { + payload: ActionRowPayload = { 'type': self.type.value, 'components': [child.to_dict() for child in self.children], } + if self.id is not None: + payload['id'] = self.id + return payload class Button(Component): @@ -235,6 +243,10 @@ class Button(Component): The SKU ID this button sends you to, if available. .. versionadded:: 2.4 + id: Optional[:class:`int`] + The ID of this component. + + .. versionadded:: 2.6 """ __slots__: Tuple[str, ...] = ( @@ -245,11 +257,13 @@ class Button(Component): 'label', 'emoji', 'sku_id', + 'id', ) __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ def __init__(self, data: ButtonComponentPayload, /) -> None: + self.id: Optional[int] = data.get('id') self.style: ButtonStyle = try_enum(ButtonStyle, data['style']) self.custom_id: Optional[str] = data.get('custom_id') self.url: Optional[str] = data.get('url') @@ -278,6 +292,9 @@ def to_dict(self) -> ButtonComponentPayload: 'disabled': self.disabled, } + if self.id is not None: + payload['id'] = self.id + if self.sku_id: payload['sku_id'] = str(self.sku_id) @@ -329,6 +346,10 @@ class SelectMenu(Component): Whether the select is disabled or not. channel_types: List[:class:`.ChannelType`] A list of channel types that are allowed to be chosen in this select menu. + id: Optional[:class:`int`] + The ID of this component. + + .. versionadded:: 2.6 """ __slots__: Tuple[str, ...] = ( @@ -341,6 +362,7 @@ class SelectMenu(Component): 'disabled', 'channel_types', 'default_values', + 'id', ) __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ @@ -357,6 +379,7 @@ def __init__(self, data: SelectMenuPayload, /) -> None: self.default_values: List[SelectDefaultValue] = [ SelectDefaultValue.from_dict(d) for d in data.get('default_values', []) ] + self.id: Optional[int] = data.get('id') def to_dict(self) -> SelectMenuPayload: payload: SelectMenuPayload = { @@ -366,6 +389,8 @@ def to_dict(self) -> SelectMenuPayload: 'max_values': self.max_values, 'disabled': self.disabled, } + if self.id is not None: + payload['id'] = self.id if self.placeholder: payload['placeholder'] = self.placeholder if self.options: @@ -531,6 +556,10 @@ class TextInput(Component): The minimum length of the text input. max_length: Optional[:class:`int`] The maximum length of the text input. + id: Optional[:class:`int`] + The ID of this component. + + .. versionadded:: 2.6 """ __slots__: Tuple[str, ...] = ( @@ -542,6 +571,7 @@ class TextInput(Component): 'required', 'min_length', 'max_length', + 'id', ) __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ @@ -555,6 +585,7 @@ def __init__(self, data: TextInputPayload, /) -> None: self.required: bool = data.get('required', True) self.min_length: Optional[int] = data.get('min_length') self.max_length: Optional[int] = data.get('max_length') + self.id: Optional[int] = data.get('id') @property def type(self) -> Literal[ComponentType.text_input]: @@ -570,6 +601,9 @@ def to_dict(self) -> TextInputPayload: 'required': self.required, } + if self.id is not None: + payload['id'] = self.id + if self.placeholder: payload['placeholder'] = self.placeholder @@ -721,11 +755,14 @@ class SectionComponent(Component): The components on this section. accessory: :class:`Component` The section accessory. + id: Optional[:class:`int`] + The ID of this component. """ __slots__ = ( 'components', 'accessory', + 'id', ) __repr_info__ = __slots__ @@ -733,6 +770,7 @@ class SectionComponent(Component): def __init__(self, data: SectionComponentPayload, state: Optional[ConnectionState]) -> None: self.components: List[SectionComponentType] = [] self.accessory: Component = _component_factory(data['accessory'], state) # type: ignore + self.id: Optional[int] = data.get('id') for component_data in data['components']: component = _component_factory(component_data, state) @@ -749,6 +787,10 @@ def to_dict(self) -> SectionComponentPayload: 'components': [c.to_dict() for c in self.components], 'accessory': self.accessory.to_dict(), } + + if self.id is not None: + payload['id'] = self.id + return payload @@ -772,12 +814,15 @@ class ThumbnailComponent(Component): The description shown within this thumbnail. spoiler: :class:`bool` Whether this thumbnail is flagged as a spoiler. + id: Optional[:class:`int`] + The ID of this component. """ __slots__ = ( 'media', 'spoiler', 'description', + 'id', ) __repr_info__ = __slots__ @@ -790,19 +835,25 @@ def __init__( self.media: UnfurledMediaItem = UnfurledMediaItem._from_data(data['media'], state) self.description: Optional[str] = data.get('description') self.spoiler: bool = data.get('spoiler', False) + self.id: Optional[int] = data.get('id') @property def type(self) -> Literal[ComponentType.thumbnail]: return ComponentType.thumbnail def to_dict(self) -> ThumbnailComponentPayload: - return { - 'media': self.media.to_dict(), # pyright: ignore[reportReturnType] + payload = { + 'media': self.media.to_dict(), 'description': self.description, 'spoiler': self.spoiler, 'type': self.type.value, } + if self.id is not None: + payload['id'] = self.id + + return payload # type: ignore + class TextDisplay(Component): """Represents a text display from the Discord Bot UI Kit. @@ -820,24 +871,30 @@ class TextDisplay(Component): ---------- content: :class:`str` The content that this display shows. + id: Optional[:class:`int`] + The ID of this component. """ - __slots__ = ('content',) + __slots__ = ('content', 'id') __repr_info__ = __slots__ def __init__(self, data: TextComponentPayload) -> None: self.content: str = data['content'] + self.id: Optional[int] = data.get('id') @property def type(self) -> Literal[ComponentType.text_display]: return ComponentType.text_display def to_dict(self) -> TextComponentPayload: - return { + payload: TextComponentPayload = { 'type': self.type.value, 'content': self.content, } + if self.id is not None: + payload['id'] = self.id + return payload class UnfurledMediaItem(AssetMixin): @@ -1006,24 +1063,30 @@ class MediaGalleryComponent(Component): ---------- items: List[:class:`MediaGalleryItem`] The items this gallery has. + id: Optional[:class:`int`] + The ID of this component. """ - __slots__ = ('items',) + __slots__ = ('items', 'id') __repr_info__ = __slots__ def __init__(self, data: MediaGalleryComponentPayload, state: Optional[ConnectionState]) -> None: self.items: List[MediaGalleryItem] = MediaGalleryItem._from_gallery(data['items'], state) + self.id: Optional[int] = data.get('id') @property def type(self) -> Literal[ComponentType.media_gallery]: return ComponentType.media_gallery def to_dict(self) -> MediaGalleryComponentPayload: - return { + payload: MediaGalleryComponentPayload = { 'type': self.type.value, 'items': [item.to_dict() for item in self.items], } + if self.id is not None: + payload['id'] = self.id + return payload class FileComponent(Component): @@ -1044,11 +1107,14 @@ class FileComponent(Component): The unfurled attachment contents of the file. spoiler: :class:`bool` Whether this file is flagged as a spoiler. + id: Optional[:class:`int`] + The ID of this component. """ __slots__ = ( 'media', 'spoiler', + 'id', ) __repr_info__ = __slots__ @@ -1056,17 +1122,21 @@ class FileComponent(Component): def __init__(self, data: FileComponentPayload, state: Optional[ConnectionState]) -> None: self.media: UnfurledMediaItem = UnfurledMediaItem._from_data(data['file'], state) self.spoiler: bool = data.get('spoiler', False) + self.id: Optional[int] = data.get('id') @property def type(self) -> Literal[ComponentType.file]: return ComponentType.file def to_dict(self) -> FileComponentPayload: - return { + payload: FileComponentPayload = { 'type': self.type.value, 'file': self.media.to_dict(), # type: ignore 'spoiler': self.spoiler, } + if self.id is not None: + payload['id'] = self.id + return payload class SeparatorComponent(Component): @@ -1087,11 +1157,14 @@ class SeparatorComponent(Component): The spacing size of the separator. visible: :class:`bool` Whether this separator is visible and shows a divider. + id: Optional[:class:`int`] + The ID of this component. """ __slots__ = ( 'spacing', 'visible', + 'id', ) __repr_info__ = __slots__ @@ -1102,17 +1175,21 @@ def __init__( ) -> None: self.spacing: SeparatorSize = try_enum(SeparatorSize, data.get('spacing', 1)) self.visible: bool = data.get('divider', True) + self.id: Optional[int] = data.get('id') @property def type(self) -> Literal[ComponentType.separator]: return ComponentType.separator def to_dict(self) -> SeparatorComponentPayload: - return { + payload: SeparatorComponentPayload = { 'type': self.type.value, 'divider': self.visible, 'spacing': self.spacing.value, } + if self.id is not None: + payload['id'] = self.id + return payload class Container(Component): @@ -1133,10 +1210,13 @@ class Container(Component): This container's children. spoiler: :class:`bool` Whether this container is flagged as a spoiler. + id: Optional[:class:`int`] + The ID of this component. """ def __init__(self, data: ContainerComponentPayload, state: Optional[ConnectionState]) -> None: self.children: List[Component] = [] + self.id: Optional[int] = data.get('id') for child in data['components']: comp = _component_factory(child, state) @@ -1158,6 +1238,18 @@ def accent_colour(self) -> Optional[Colour]: accent_color = accent_colour + def to_dict(self) -> ContainerComponentPayload: + payload: ContainerComponentPayload = { + 'type': self.type.value, # type: ignore + 'spoiler': self.spoiler, + 'components': [c.to_dict() for c in self.children], + } + if self.id is not None: + payload['id'] = self.id + if self._colour: + payload['accent_color'] = self._colour.value + return payload + def _component_factory(data: ComponentPayload, state: Optional[ConnectionState] = None) -> Optional[Component]: if data['type'] == 1: diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 510d6175bf1a..b13948899e91 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -234,10 +234,13 @@ def to_component_dict(self) -> Dict[str, Any]: for item in self._children: components.append(item.to_component_dict()) - return { + base = { 'type': self.type.value, 'components': components, } + if self.id is not None: + base['id'] = self.id + return base def button( self, diff --git a/discord/ui/item.py b/discord/ui/item.py index bcee854a87aa..854affa396f4 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -127,8 +127,7 @@ def view(self) -> Optional[V]: @property def id(self) -> Optional[int]: - """Optional[:class:`int`]: The ID of this component. - """ + """Optional[:class:`int`]: The ID of this component.""" return self._id @id.setter From 9891f85c8b8507bbc7ec7ae6667f43b0f5f6a054 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 9 Mar 2025 22:40:15 +0100 Subject: [PATCH 051/272] chore: add id to every item --- discord/ui/button.py | 2 ++ discord/ui/container.py | 6 +++++- discord/ui/file.py | 2 ++ discord/ui/media_gallery.py | 2 ++ discord/ui/section.py | 3 +++ discord/ui/select.py | 2 ++ discord/ui/separator.py | 2 ++ discord/ui/text_display.py | 6 +++++- discord/ui/text_input.py | 8 ++++++++ discord/ui/thumbnail.py | 6 +++++- 10 files changed, 36 insertions(+), 3 deletions(-) diff --git a/discord/ui/button.py b/discord/ui/button.py index 82a485f9110f..7a60333db414 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -149,6 +149,7 @@ def __init__( style=style, emoji=emoji, sku_id=sku_id, + id=id, ) self._parent: Optional[ActionRow] = None self.row = row @@ -250,6 +251,7 @@ def from_component(cls, button: ButtonComponent) -> Self: emoji=button.emoji, row=None, sku_id=button.sku_id, + id=button.id, ) @property diff --git a/discord/ui/container.py b/discord/ui/container.py index cc2405a757bb..b4aa574b616b 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -194,12 +194,15 @@ def to_components(self) -> List[Dict[str, Any]]: def to_component_dict(self) -> Dict[str, Any]: components = self.to_components() - return { + base = { 'type': self.type.value, 'accent_color': self._colour.value if self._colour else None, 'spoiler': self.spoiler, 'components': components, } + if self.id is not None: + base['id'] = self.id + return base def _update_store_data( self, @@ -222,4 +225,5 @@ def from_component(cls, component: ContainerComponent) -> Self: children=[_component_to_item(c) for c in component.children], accent_colour=component.accent_colour, spoiler=component.spoiler, + id=component.id, ) diff --git a/discord/ui/file.py b/discord/ui/file.py index 7d065f0ffcbb..2e34c316d687 100644 --- a/discord/ui/file.py +++ b/discord/ui/file.py @@ -75,6 +75,7 @@ def __init__( self._underlying = FileComponent._raw_construct( media=UnfurledMediaItem(media) if isinstance(media, str) else media, spoiler=spoiler, + id=id, ) self.row = row @@ -126,4 +127,5 @@ def from_component(cls, component: FileComponent) -> Self: return cls( media=component.media, spoiler=component.spoiler, + id=component.id, ) diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index ee0fb3cf0cb0..3deca63c86cf 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -75,6 +75,7 @@ def __init__( self._underlying = MediaGalleryComponent._raw_construct( items=items, + id=id, ) self.row = row @@ -183,4 +184,5 @@ def width(self): def from_component(cls, component: MediaGalleryComponent) -> Self: return cls( items=component.items, + id=component.id, ) diff --git a/discord/ui/section.py b/discord/ui/section.py index 0aa164d883af..a034a1c08781 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -174,6 +174,7 @@ def from_component(cls, component: SectionComponent) -> Self: return cls( children=[_component_to_item(c) for c in component.components], accessory=_component_to_item(component.accessory), + id=component.id, ) def to_component_dict(self) -> Dict[str, Any]: @@ -182,4 +183,6 @@ def to_component_dict(self) -> Dict[str, Any]: 'components': [c.to_component_dict() for c in self._children], 'accessory': self.accessory.to_component_dict(), } + if self.id is not None: + data['id'] = self.id return data diff --git a/discord/ui/select.py b/discord/ui/select.py index efa8a9e6880e..e2d3d34d2583 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -224,6 +224,7 @@ class BaseSelect(Item[V]): 'min_values', 'max_values', 'disabled', + 'id', ) def __init__( @@ -257,6 +258,7 @@ def __init__( channel_types=[] if channel_types is MISSING else channel_types, options=[] if options is MISSING else options, default_values=[] if default_values is MISSING else default_values, + id=id, ) self.row = row diff --git a/discord/ui/separator.py b/discord/ui/separator.py index 394e9ac78df5..e212f4b4e4e5 100644 --- a/discord/ui/separator.py +++ b/discord/ui/separator.py @@ -74,6 +74,7 @@ def __init__( self._underlying = SeparatorComponent._raw_construct( spacing=spacing, visible=visible, + id=id, ) self.row = row @@ -120,4 +121,5 @@ def from_component(cls, component: SeparatorComponent) -> Self: return cls( visible=component.visible, spacing=component.spacing, + id=component.id, ) diff --git a/discord/ui/text_display.py b/discord/ui/text_display.py index 8e22905ebc47..409b68272187 100644 --- a/discord/ui/text_display.py +++ b/discord/ui/text_display.py @@ -67,10 +67,13 @@ def __init__(self, content: str, *, row: Optional[int] = None, id: Optional[int] self.id = id def to_component_dict(self): - return { + base = { 'type': self.type.value, 'content': self.content, } + if self.id is not None: + base['id'] = self.id + return base @property def width(self): @@ -87,4 +90,5 @@ def _is_v2(self) -> bool: def from_component(cls, component: TextDisplayComponent) -> Self: return cls( content=component.content, + id=component.id, ) diff --git a/discord/ui/text_input.py b/discord/ui/text_input.py index 96b4581f40b0..86f7373ee11e 100644 --- a/discord/ui/text_input.py +++ b/discord/ui/text_input.py @@ -92,6 +92,10 @@ class TextInput(Item[V]): 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). + id: Optional[:class:`int`] + The ID of the component. This must be unique across the view. + + .. versionadded:: 2.6 """ __item_repr_attributes__: Tuple[str, ...] = ( @@ -112,6 +116,7 @@ def __init__( min_length: Optional[int] = None, max_length: Optional[int] = None, row: Optional[int] = None, + id: Optional[int] = None, ) -> None: super().__init__() self._value: Optional[str] = default @@ -129,8 +134,10 @@ def __init__( required=required, min_length=min_length, max_length=max_length, + id=id, ) self.row = row + self.id = id def __str__(self) -> str: return self.value @@ -241,6 +248,7 @@ def from_component(cls, component: TextInputComponent) -> Self: min_length=component.min_length, max_length=component.max_length, row=None, + id=component.id, ) @property diff --git a/discord/ui/thumbnail.py b/discord/ui/thumbnail.py index e9a2c13f5a9b..7f21edd3aad7 100644 --- a/discord/ui/thumbnail.py +++ b/discord/ui/thumbnail.py @@ -96,12 +96,15 @@ def _is_v2(self) -> bool: return True def to_component_dict(self) -> Dict[str, Any]: - return { + base = { 'type': self.type.value, 'spoiler': self.spoiler, 'media': self.media.to_dict(), 'description': self.description, } + if self.id is not None: + base['id'] = self.id + return base @classmethod def from_component(cls, component: ThumbnailComponent) -> Self: @@ -109,4 +112,5 @@ def from_component(cls, component: ThumbnailComponent) -> Self: media=component.media.url, description=component.description, spoiler=component.spoiler, + id=component.id, ) From c93ee07ca9654a486bd59b9349cabcab5ecafe9b Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 9 Mar 2025 23:11:27 +0100 Subject: [PATCH 052/272] fix: Container._colour raising ValueError --- discord/components.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/discord/components.py b/discord/components.py index a9a6de24ba5a..5ed52891fa83 100644 --- a/discord/components.py +++ b/discord/components.py @@ -1225,11 +1225,11 @@ def __init__(self, data: ContainerComponentPayload, state: Optional[ConnectionSt self.children.append(comp) self.spoiler: bool = data.get('spoiler', False) - self._colour: Optional[Colour] - try: - self._colour = Colour(data['accent_color']) # type: ignore - except KeyError: - self._colour = None + + colour = data.get('accent_color') + self._colour: Optional[Colour] = None + if colour is not None: + self._colour = Colour(colour) @property def accent_colour(self) -> Optional[Colour]: From 09fceae041a8957a41c6dce6f97e4788494385cd Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 9 Mar 2025 23:12:55 +0100 Subject: [PATCH 053/272] fix: Container.is_dispatchable making buttons not work --- discord/ui/container.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index b4aa574b616b..256e3cfc5d4e 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -183,9 +183,6 @@ def width(self): def _is_v2(self) -> bool: return True - def is_dispatchable(self) -> bool: - return any(c.is_dispatchable() for c in self.children) - def to_components(self) -> List[Dict[str, Any]]: components = [] for child in self._children: From 8399677445d34377f6804055b641a8cf5d0561a1 Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 9 Mar 2025 23:14:59 +0100 Subject: [PATCH 054/272] fix: Container children not being added to view store --- discord/ui/container.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/discord/ui/container.py b/discord/ui/container.py index 256e3cfc5d4e..9750010ad44b 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -85,6 +85,7 @@ class Container(Item[V]): __container_children_items__: ClassVar[List[Union[ItemCallbackType[Any, Any], Item[Any]]]] = [] __pending_view__: ClassVar[bool] = True + __discord_ui_container__: ClassVar[bool] = True def __init__( self, @@ -132,6 +133,9 @@ def _init_children(self) -> List[Item[Any]]: return children + def is_dispatchable(self) -> bool: + return True + def __init_subclass__(cls) -> None: super().__init_subclass__() From 97006066c06de47d995975c02b53955b4ca74818 Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 9 Mar 2025 23:22:37 +0100 Subject: [PATCH 055/272] chore: Update Container._update_store_data --- discord/ui/container.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index 9750010ad44b..d546d593ad47 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -97,6 +97,7 @@ def __init__( row: Optional[int] = None, id: Optional[int] = None, ) -> None: + self.__dispatchable: List[Item[V]] = [] self._children: List[Item[V]] = self._init_children() if children is not MISSING: @@ -130,6 +131,7 @@ def _init_children(self) -> List[Item[Any]]: parent._children.append(item) # we donnot append it to the children list because technically these buttons and # selects are not from the container but the action row itself. + self.__dispatchable.append(item) return children @@ -211,7 +213,7 @@ def _update_store_data( dynamic_items: Dict[Any, Type[DynamicItem]], ) -> bool: is_fully_dynamic = True - for item in self._children: + for item in self.__dispatchable: if isinstance(item, DynamicItem): pattern = item.__discord_ui_compiled_template__ dynamic_items[pattern] = item.__class__ From 0f7d72bc0bf533801de5c3716dd161c25582f789 Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 9 Mar 2025 23:24:33 +0100 Subject: [PATCH 056/272] chore: Update Container.is_dispatchable --- discord/ui/container.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index d546d593ad47..0376895c0ac5 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -136,7 +136,7 @@ def _init_children(self) -> List[Item[Any]]: return children def is_dispatchable(self) -> bool: - return True + return bool(self.__dispatchable) def __init_subclass__(cls) -> None: super().__init_subclass__() From cf4db91fa256c81ea82455c04933cb1da7ea0c48 Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Mon, 10 Mar 2025 00:04:55 +0100 Subject: [PATCH 057/272] chore: Remove unused imports --- discord/ui/action_row.py | 1 - 1 file changed, 1 deletion(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index b13948899e91..74dc151cea2e 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -23,7 +23,6 @@ """ from __future__ import annotations -import os from typing import ( TYPE_CHECKING, Any, From 6d50c883abd8e1c72b6fa2a9236fcd246a4e6f98 Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Mon, 10 Mar 2025 00:38:39 +0100 Subject: [PATCH 058/272] chore: Metadata for Section --- discord/ui/section.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/discord/ui/section.py b/discord/ui/section.py index a034a1c08781..bbaef2994948 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -23,7 +23,7 @@ """ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, TypeVar, Union +from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, TypeVar, Union, ClassVar from .item import Item from .text_display import TextDisplay @@ -63,6 +63,8 @@ class Section(Item[V]): The ID of this component. This must be unique across the view. """ + __discord_ui_section__: ClassVar[bool] = True + __slots__ = ( '_children', 'accessory', From 9655749ae33b6347dff7f872461064190f109775 Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Mon, 10 Mar 2025 00:41:25 +0100 Subject: [PATCH 059/272] fix: Section.accessory not being dispatched --- discord/ui/view.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/discord/ui/view.py b/discord/ui/view.py index 9b0709fd4baa..1acf58870c0c 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -844,6 +844,9 @@ def add_view(self, view: BaseView, message_id: Optional[int] = None) -> None: ) or is_fully_dynamic ) + elif getattr(item, '__discord_ui_section__', False): + accessory = item.accessory. # type: ignore + dispatch_info[(accessory.type.value, accessory.custom_id)] = accessory # type: ignore else: dispatch_info[(item.type.value, item.custom_id)] = item # type: ignore is_fully_dynamic = False From 5120b0d5dfa40fe8ef30b64cde20a3bfd8a61c74 Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Mon, 10 Mar 2025 09:50:10 +0100 Subject: [PATCH 060/272] chore: Update ViewStore to handle Section.accessory properly --- discord/ui/view.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index 1acf58870c0c..1eadf0a8b364 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -845,8 +845,12 @@ def add_view(self, view: BaseView, message_id: Optional[int] = None) -> None: or is_fully_dynamic ) elif getattr(item, '__discord_ui_section__', False): - accessory = item.accessory. # type: ignore - dispatch_info[(accessory.type.value, accessory.custom_id)] = accessory # type: ignore + accessory = item.accessory # type: ignore + if isinstance(accessory, DynamicItem): + pattern = accessory.__discord_ui_compiled_pattern__ + self._dynamic_items[pattern] = accessory.__class__ + else: + dispatch_info[(accessory.type.value, accessory.custom_id)] = accessory # type: ignore else: dispatch_info[(item.type.value, item.custom_id)] = item # type: ignore is_fully_dynamic = False From 4c668bae5736adcce29bb12019a8cfcfd909aac0 Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Mon, 10 Mar 2025 09:54:25 +0100 Subject: [PATCH 061/272] template --- discord/ui/view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index 1eadf0a8b364..e3771a8fe82f 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -847,7 +847,7 @@ def add_view(self, view: BaseView, message_id: Optional[int] = None) -> None: elif getattr(item, '__discord_ui_section__', False): accessory = item.accessory # type: ignore if isinstance(accessory, DynamicItem): - pattern = accessory.__discord_ui_compiled_pattern__ + pattern = accessory.__discord_ui_compiled_template__ self._dynamic_items[pattern] = accessory.__class__ else: dispatch_info[(accessory.type.value, accessory.custom_id)] = accessory # type: ignore From 84ad47ffc269e528e9fa91ccca331f8e46279892 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Mon, 10 Mar 2025 17:20:04 +0100 Subject: [PATCH 062/272] chore: Remove unneccessary # type: ignore --- discord/ui/view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index e3771a8fe82f..dcd9d90e797f 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -850,7 +850,7 @@ def add_view(self, view: BaseView, message_id: Optional[int] = None) -> None: pattern = accessory.__discord_ui_compiled_template__ self._dynamic_items[pattern] = accessory.__class__ else: - dispatch_info[(accessory.type.value, accessory.custom_id)] = accessory # type: ignore + dispatch_info[(accessory.type.value, accessory.custom_id)] = accessory else: dispatch_info[(item.type.value, item.custom_id)] = item # type: ignore is_fully_dynamic = False From 7433ad05623d73db2c610d744d29b42656ff6007 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Mon, 10 Mar 2025 20:32:12 +0100 Subject: [PATCH 063/272] chore: Fix Section.accessory raising an error when clicked --- discord/ui/view.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index dcd9d90e797f..7016ef9d606d 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -845,12 +845,14 @@ def add_view(self, view: BaseView, message_id: Optional[int] = None) -> None: or is_fully_dynamic ) elif getattr(item, '__discord_ui_section__', False): - accessory = item.accessory # type: ignore + accessory: Item = item.accessory # type: ignore + accessory._view = view + if isinstance(accessory, DynamicItem): pattern = accessory.__discord_ui_compiled_template__ self._dynamic_items[pattern] = accessory.__class__ else: - dispatch_info[(accessory.type.value, accessory.custom_id)] = accessory + dispatch_info[(accessory.type.value, accessory.custom_id)] = accessory # type: ignore else: dispatch_info[(item.type.value, item.custom_id)] = item # type: ignore is_fully_dynamic = False From 810fe57283ba0264eb9ac9d7ef6960496504ddce Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Mon, 10 Mar 2025 20:37:11 +0100 Subject: [PATCH 064/272] chore: Update container to also take in account section accessories --- discord/ui/container.py | 8 ++++++-- discord/ui/section.py | 8 +++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index 0376895c0ac5..810f05b55816 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -218,8 +218,12 @@ def _update_store_data( pattern = item.__discord_ui_compiled_template__ dynamic_items[pattern] = item.__class__ elif item.is_dispatchable(): - dispatch_info[(item.type.value, item.custom_id)] = item # type: ignore - is_fully_dynamic = False + if getattr(item, '__discord_ui_section__', False): + accessory = item.accessory # type: ignore + dispatch_info[(accessory.type.value, accessory.custom_id)] = accessory + else: + dispatch_info[(item.type.value, item.custom_id)] = item # type: ignore + is_fully_dynamic = False return is_fully_dynamic @classmethod diff --git a/discord/ui/section.py b/discord/ui/section.py index bbaef2994948..1cd972d5dec9 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -64,6 +64,7 @@ class Section(Item[V]): """ __discord_ui_section__: ClassVar[bool] = True + __pending_view__: ClassVar[bool] = True __slots__ = ( '_children', @@ -107,9 +108,10 @@ def _is_v2(self) -> bool: # be accessory component callback, only called if accessory is # dispatchable? def is_dispatchable(self) -> bool: - if self.accessory: - return self.accessory.is_dispatchable() - return False + return self.accessory.is_dispatchable() + + def _update_children_view(self, view) -> None: + self.accessory._view = view def add_item(self, item: Union[str, Item[Any]]) -> Self: """Adds an item to this section. From 52f9b6a88c3a6c28ebad428fb7b445bd93f5440b Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Mon, 10 Mar 2025 20:40:12 +0100 Subject: [PATCH 065/272] chore: Some changes on how Section.accessory is handled in Container --- discord/ui/container.py | 11 +++++------ discord/ui/section.py | 1 - 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index 810f05b55816..8218e1de6ebe 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -116,6 +116,9 @@ def _init_children(self) -> List[Item[Any]]: for raw in self.__container_children_items__: if isinstance(raw, Item): children.append(raw) + + if getattr(raw, '__discord_ui_section__', False) and raw.accessory.is_dispatchable(): # type: ignore + self.__dispatchable.append(raw.accessory) # type: ignore else: # action rows can be created inside containers, and then callbacks can exist here # so we create items based off them @@ -218,12 +221,8 @@ def _update_store_data( pattern = item.__discord_ui_compiled_template__ dynamic_items[pattern] = item.__class__ elif item.is_dispatchable(): - if getattr(item, '__discord_ui_section__', False): - accessory = item.accessory # type: ignore - dispatch_info[(accessory.type.value, accessory.custom_id)] = accessory - else: - dispatch_info[(item.type.value, item.custom_id)] = item # type: ignore - is_fully_dynamic = False + dispatch_info[(item.type.value, item.custom_id)] = item # type: ignore + is_fully_dynamic = False return is_fully_dynamic @classmethod diff --git a/discord/ui/section.py b/discord/ui/section.py index 1cd972d5dec9..981d06e93928 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -64,7 +64,6 @@ class Section(Item[V]): """ __discord_ui_section__: ClassVar[bool] = True - __pending_view__: ClassVar[bool] = True __slots__ = ( '_children', From 8561953222c29248e1c7755e8add398852b5b5c4 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Mon, 10 Mar 2025 20:54:33 +0100 Subject: [PATCH 066/272] chore: Add container add/remove/clear_item(s) --- discord/ui/container.py | 60 +++++++++++++++++++++++++++++++++++++++++ discord/ui/section.py | 2 +- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index 8218e1de6ebe..fd9dd0c49e7b 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -233,3 +233,63 @@ def from_component(cls, component: ContainerComponent) -> Self: spoiler=component.spoiler, id=component.id, ) + + def add_item(self, item: Item[Any]) -> Self: + """Adds an item to this container. + + This function returns the class instance to allow for fluent-style + chaining. + + Parameters + ---------- + item: :class:`Item` + The item to append. + + Raises + ------ + TypeError + An :class:`Item` was not passed. + ValueError + Maximum number of children has been exceeded (10). + """ + + if len(self._children) >= 10: + raise ValueError('maximum number of children exceeded') + + if not isinstance(item, Item): + raise TypeError(f'expected Item not {item.__class__.__name__}') + + self._children.append(item) + + if item.is_dispatchable(): + if getattr(item, '__discord_ui_section__', False): + self.__dispatchable.append(item.accessory) # type: ignore + + return self + + def remove_item(self, item: Item[Any]) -> Self: + """Removes an item from this container. + + This function returns the class instance to allow for fluent-style + chaining. + + Parameters + ---------- + item: :class:`TextDisplay` + The item to remove from the section. + """ + + try: + self._children.remove(item) + except ValueError: + pass + return self + + def clear_items(self) -> Self: + """Removes all the items from the container. + + This function returns the class instance to allow for fluent-style + chaining. + """ + self._children.clear() + return self diff --git a/discord/ui/section.py b/discord/ui/section.py index 981d06e93928..16b5fb6c52df 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -121,7 +121,7 @@ def add_item(self, item: Union[str, Item[Any]]) -> Self: Parameters ---------- item: Union[:class:`str`, :class:`Item`] - The items to append, if it is a string it automatically wrapped around + The item to append, if it is a string it automatically wrapped around :class:`TextDisplay`. Raises From 8926f28a3a756f5e08c4db55f0d74439217f0fed Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Mon, 10 Mar 2025 21:00:04 +0100 Subject: [PATCH 067/272] fix: Section.accessory._view being None when in a container --- discord/ui/section.py | 1 + 1 file changed, 1 insertion(+) diff --git a/discord/ui/section.py b/discord/ui/section.py index 16b5fb6c52df..be53d662001c 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -64,6 +64,7 @@ class Section(Item[V]): """ __discord_ui_section__: ClassVar[bool] = True + __pending_view__: ClassVar[bool] = True __slots__ = ( '_children', From b1e8aefd538d2141037975c7f23842ab81702bcd Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Mon, 10 Mar 2025 22:21:43 +0100 Subject: [PATCH 068/272] fix: Containers not dispatching ActionRow items correctly --- discord/ui/container.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/discord/ui/container.py b/discord/ui/container.py index fd9dd0c49e7b..1e4aa0bf7ce9 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -119,6 +119,8 @@ def _init_children(self) -> List[Item[Any]]: if getattr(raw, '__discord_ui_section__', False) and raw.accessory.is_dispatchable(): # type: ignore self.__dispatchable.append(raw.accessory) # type: ignore + elif getattr(raw, '__discord_ui_action_row__', False) and raw.is_dispatchable(): + self.__dispatchable.extend(raw._children) # type: ignore else: # action rows can be created inside containers, and then callbacks can exist here # so we create items based off them From 4c662a9c24593cea1e21048d8567e80d884b722b Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 20 Mar 2025 18:58:43 +0100 Subject: [PATCH 069/272] chore: Some changes, fixes, and typo corrections --- discord/ui/action_row.py | 2 +- discord/ui/container.py | 4 ++-- discord/ui/file.py | 2 +- discord/ui/item.py | 8 ++++++++ discord/ui/section.py | 2 +- discord/ui/view.py | 28 +++++++++++++++++----------- 6 files changed, 30 insertions(+), 16 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 74dc151cea2e..4edf78b603f3 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -99,7 +99,7 @@ class ActionRow(Item[V]): __action_row_children_items__: ClassVar[List[ItemCallbackType[Any, Any]]] = [] __discord_ui_action_row__: ClassVar[bool] = True - __pending_view__: ClassVar[bool] = True + __discord_ui_update_view__: ClassVar[bool] = True def __init__(self, *, id: Optional[int] = None) -> None: super().__init__() diff --git a/discord/ui/container.py b/discord/ui/container.py index fd9dd0c49e7b..7a034e3c60be 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -84,7 +84,7 @@ class Container(Item[V]): """ __container_children_items__: ClassVar[List[Union[ItemCallbackType[Any, Any], Item[Any]]]] = [] - __pending_view__: ClassVar[bool] = True + __discord_ui_update_view__: ClassVar[bool] = True __discord_ui_container__: ClassVar[bool] = True def __init__( @@ -157,7 +157,7 @@ def __init_subclass__(cls) -> None: def _update_children_view(self, view) -> None: for child in self._children: child._view = view - if getattr(child, '__pending_view__', False): + if getattr(child, '__discord_ui_update_view__', False): # if the item is an action row which child's view can be updated, then update it child._update_children_view(view) # type: ignore diff --git a/discord/ui/file.py b/discord/ui/file.py index 2e34c316d687..0f6875421521 100644 --- a/discord/ui/file.py +++ b/discord/ui/file.py @@ -47,7 +47,7 @@ class File(Item[V]): Parameters ---------- media: Union[:class:`str`, :class:`.UnfurledMediaItem`] - This file's media. If this is a string itmust point to a local + This file's media. If this is a string it must point to a local file uploaded within the parent view of this item, and must meet the ``attachment://file-name.extension`` structure. spoiler: :class:`bool` diff --git a/discord/ui/item.py b/discord/ui/item.py index 854affa396f4..597be4dab11b 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -53,6 +53,14 @@ class Item(Generic[V]): - :class:`discord.ui.Button` - :class:`discord.ui.Select` - :class:`discord.ui.TextInput` + - :class:`discord.ui.ActionRow` + - :class:`discord.ui.Container` + - :class:`discord.ui.File` + - :class:`discord.ui.MediaGallery` + - :class:`discord.ui.Section` + - :class:`discord.ui.Separator` + - :class:`discord.ui.TextDisplay` + - :class:`discord.ui.Thumbnail` .. versionadded:: 2.0 """ diff --git a/discord/ui/section.py b/discord/ui/section.py index be53d662001c..88fe03e5dcfe 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -64,7 +64,7 @@ class Section(Item[V]): """ __discord_ui_section__: ClassVar[bool] = True - __pending_view__: ClassVar[bool] = True + __discord_ui_update_view__: ClassVar[bool] = True __slots__ = ( '_children', diff --git a/discord/ui/view.py b/discord/ui/view.py index 7016ef9d606d..4a3e2ac8d730 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -23,6 +23,8 @@ """ from __future__ import annotations + +import warnings from typing import ( Any, Callable, @@ -183,10 +185,10 @@ def v2_weights(self) -> bool: class _ViewCallback: __slots__ = ('view', 'callback', 'item') - def __init__(self, callback: ItemCallbackType[Any, Any], view: View, item: Item[View]) -> None: + def __init__(self, callback: ItemCallbackType[Any, Any], view: BaseView, item: Item[BaseView]) -> None: self.callback: ItemCallbackType[Any, Any] = callback - self.view: View = view - self.item: Item[View] = item + self.view: BaseView = view + self.item: Item[BaseView] = item def __call__(self, interaction: Interaction) -> Coroutine[Any, Any, Any]: return self.callback(self.view, interaction, self.item) @@ -223,7 +225,7 @@ def _init_children(self) -> List[Item[Self]]: parent = getattr(raw, '__discord_ui_parent__', None) if parent and parent._view is None: parent._view = self - if getattr(raw, '__pending_view__', False): + if getattr(raw, '__discord_ui_update_view__', False): raw._update_children_view(self) # type: ignore children.append(raw) else: @@ -559,13 +561,15 @@ async def wait(self) -> bool: return await self.__stopped -class View(BaseView): # NOTE: maybe add a deprecation warning in favour of LayoutView? +class View(BaseView): """Represents a UI view. This object must be inherited to create a UI within Discord. .. versionadded:: 2.0 + .. deprecated:: 2.6 + Parameters ----------- timeout: Optional[:class:`float`] @@ -576,6 +580,10 @@ class View(BaseView): # NOTE: maybe add a deprecation warning in favour of Layo __discord_ui_view__: ClassVar[bool] = True def __init_subclass__(cls) -> None: + warnings.warn( + 'discord.ui.View and subclasses are deprecated, use discord.ui.LayoutView instead', + DeprecationWarning, + ) super().__init_subclass__() children: Dict[str, ItemCallbackType[Any, Any]] = {} @@ -691,10 +699,7 @@ def clear_items(self) -> Self: class LayoutView(BaseView): - """Represents a layout view for components v2. - - Unline :class:`View` this allows for components v2 to exist - within it. + """Represents a layout view for components. .. versionadded:: 2.6 @@ -710,6 +715,7 @@ def __init__(self, *, timeout: Optional[float] = 180.0) -> None: def __init_subclass__(cls) -> None: children: Dict[str, Item[Any]] = {} + callback_children: Dict[str, ItemCallbackType[Any, Any]] = {} row = 0 @@ -720,12 +726,12 @@ def __init_subclass__(cls) -> None: children[name] = member row += 1 elif hasattr(member, '__discord_ui_model_type__') and getattr(member, '__discord_ui_parent__', None): - children[name] = member + callback_children[name] = member if len(children) > 10: raise TypeError('LayoutView cannot have more than 10 top-level children') - cls.__view_children_items__ = list(children.values()) + cls.__view_children_items__ = list(children.values()) + list(callback_children.values()) def _is_v2(self) -> bool: return True From 4ef1e4642637e251af2d7ae3cf028ae954963bf9 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 20 Mar 2025 19:02:37 +0100 Subject: [PATCH 070/272] chore: Add ActionRow to docs --- docs/interactions/api.rst | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/interactions/api.rst b/docs/interactions/api.rst index a4005882341b..b75d33044b71 100644 --- a/docs/interactions/api.rst +++ b/docs/interactions/api.rst @@ -778,6 +778,16 @@ Thumbnail :members: :inherited-members: + +ActionRow +~~~~~~~~~ + +.. attributetable:: discord.ui.ActionRow + +.. autoclass:: discord.ui.ActionRow + :members: + :inherited-members: + .. _discord_app_commands: Application Commands From 86dd8d8b9ab3230267453d634af3f315f1b782ef Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Wed, 16 Apr 2025 11:06:20 +0200 Subject: [PATCH 071/272] chore: Add get_item_by_id to remaining items --- discord/ui/action_row.py | 22 +++++++++++++++++++++- discord/ui/container.py | 22 +++++++++++++++++++++- discord/ui/section.py | 22 +++++++++++++++++++++- 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 4edf78b603f3..481337384b7d 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -47,7 +47,7 @@ from ..components import ActionRow as ActionRowComponent from ..enums import ButtonStyle, ComponentType, ChannelType from ..partial_emoji import PartialEmoji -from ..utils import MISSING +from ..utils import MISSING, get as _utils_get if TYPE_CHECKING: from typing_extensions import Self @@ -218,6 +218,26 @@ def remove_item(self, item: Item[Any]) -> Self: pass return self + def get_item_by_id(self, id: str, /) -> Optional[Item[V]]: + """Gets an item with :attr:`Item.id` set as ``id``, or ``None`` if + not found. + + .. warning:: + + This is **not the same** as ``custom_id``. + + Parameters + ---------- + id: :class:`str` + The ID of the component. + + Returns + ------- + Optional[:class:`Item`] + The item found, or ``None``. + """ + return _utils_get(self._children, id=id) + def clear_items(self) -> Self: """Removes all items from the row. diff --git a/discord/ui/container.py b/discord/ui/container.py index 40583e17afba..20aff903c09c 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -29,7 +29,7 @@ from .view import _component_to_item, LayoutView from .dynamic import DynamicItem from ..enums import ComponentType -from ..utils import MISSING +from ..utils import MISSING, get as _utils_get if TYPE_CHECKING: from typing_extensions import Self @@ -287,6 +287,26 @@ def remove_item(self, item: Item[Any]) -> Self: pass return self + def get_item_by_id(self, id: str, /) -> Optional[Item[V]]: + """Gets an item with :attr:`Item.id` set as ``id``, or ``None`` if + not found. + + .. warning:: + + This is **not the same** as ``custom_id``. + + Parameters + ---------- + id: :class:`str` + The ID of the component. + + Returns + ------- + Optional[:class:`Item`] + The item found, or ``None``. + """ + return _utils_get(self._children, id=id) + def clear_items(self) -> Self: """Removes all the items from the container. diff --git a/discord/ui/section.py b/discord/ui/section.py index 88fe03e5dcfe..5a3104af8fab 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -28,7 +28,7 @@ from .item import Item from .text_display import TextDisplay from ..enums import ComponentType -from ..utils import MISSING +from ..utils import MISSING, get as _utils_get if TYPE_CHECKING: from typing_extensions import Self @@ -162,6 +162,26 @@ def remove_item(self, item: Item[Any]) -> Self: pass return self + def get_item_by_id(self, id: str, /) -> Optional[Item[V]]: + """Gets an item with :attr:`Item.id` set as ``id``, or ``None`` if + not found. + + .. warning:: + + This is **not the same** as ``custom_id``. + + Parameters + ---------- + id: :class:`str` + The ID of the component. + + Returns + ------- + Optional[:class:`Item`] + The item found, or ``None``. + """ + return _utils_get(self._children, id=id) + def clear_items(self) -> Self: """Removes all the items from the section. From cd9f7768fb37d537586f6815c423efd575444472 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Fri, 18 Apr 2025 22:53:35 +0200 Subject: [PATCH 072/272] some fixes and typings --- discord/ui/action_row.py | 6 +++--- discord/ui/container.py | 7 ++++--- discord/ui/item.py | 7 +++++++ discord/ui/section.py | 12 +++++++++--- discord/ui/view.py | 4 ++-- 5 files changed, 25 insertions(+), 11 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 481337384b7d..70384cf9a0b7 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -140,7 +140,7 @@ def _update_store_data(self, dispatch_info: Dict, dynamic_items: Dict) -> bool: pattern = item.__discord_ui_compiled_template__ dynamic_items[pattern] = item.__class__ elif item.is_dispatchable(): - dispatch_info[(item.type.value, item.custom_id)] = item # type: ignore + dispatch_info[(item.type.value, item.custom_id)] = item is_fully_dynamic = False return is_fully_dynamic @@ -218,7 +218,7 @@ def remove_item(self, item: Item[Any]) -> Self: pass return self - def get_item_by_id(self, id: str, /) -> Optional[Item[V]]: + def get_item_by_id(self, id: int, /) -> Optional[Item[V]]: """Gets an item with :attr:`Item.id` set as ``id``, or ``None`` if not found. @@ -228,7 +228,7 @@ def get_item_by_id(self, id: str, /) -> Optional[Item[V]]: Parameters ---------- - id: :class:`str` + id: :class:`int` The ID of the component. Returns diff --git a/discord/ui/container.py b/discord/ui/container.py index 20aff903c09c..198973dbeedc 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -97,6 +97,7 @@ def __init__( row: Optional[int] = None, id: Optional[int] = None, ) -> None: + super().__init__() self.__dispatchable: List[Item[V]] = [] self._children: List[Item[V]] = self._init_children() @@ -196,7 +197,7 @@ def _is_v2(self) -> bool: def to_components(self) -> List[Dict[str, Any]]: components = [] - for child in self._children: + for child in sorted(self._children, key=lambda i: i._rendered_row or 0): components.append(child.to_component_dict()) return components @@ -287,7 +288,7 @@ def remove_item(self, item: Item[Any]) -> Self: pass return self - def get_item_by_id(self, id: str, /) -> Optional[Item[V]]: + def get_item_by_id(self, id: int, /) -> Optional[Item[V]]: """Gets an item with :attr:`Item.id` set as ``id``, or ``None`` if not found. @@ -297,7 +298,7 @@ def get_item_by_id(self, id: str, /) -> Optional[Item[V]]: Parameters ---------- - id: :class:`str` + id: :class:`int` The ID of the component. Returns diff --git a/discord/ui/item.py b/discord/ui/item.py index 597be4dab11b..614859d726d2 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -24,6 +24,7 @@ from __future__ import annotations +import os from typing import Any, Callable, Coroutine, Dict, Generic, Optional, TYPE_CHECKING, Tuple, Type, TypeVar from ..interactions import Interaction @@ -81,6 +82,9 @@ def __init__(self): self._id: Optional[int] = None self._max_row: int = 5 if not self._is_v2() else 10 + if self._is_v2(): + self.custom_id: str = os.urandom(16).hex() + def to_component_dict(self) -> Dict[str, Any]: raise NotImplementedError @@ -124,6 +128,9 @@ def row(self, value: Optional[int]) -> None: else: raise ValueError(f'row cannot be negative or greater than or equal to {self._max_row}') + if self._rendered_row is None: + self._rendered_row = value + @property def width(self) -> int: return 1 diff --git a/discord/ui/section.py b/discord/ui/section.py index 5a3104af8fab..bacda788329d 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -162,7 +162,7 @@ def remove_item(self, item: Item[Any]) -> Self: pass return self - def get_item_by_id(self, id: str, /) -> Optional[Item[V]]: + def get_item_by_id(self, id: int, /) -> Optional[Item[V]]: """Gets an item with :attr:`Item.id` set as ``id``, or ``None`` if not found. @@ -172,7 +172,7 @@ def get_item_by_id(self, id: str, /) -> Optional[Item[V]]: Parameters ---------- - id: :class:`str` + id: :class:`int` The ID of the component. Returns @@ -204,7 +204,13 @@ def from_component(cls, component: SectionComponent) -> Self: def to_component_dict(self) -> Dict[str, Any]: data = { 'type': self.type.value, - 'components': [c.to_component_dict() for c in self._children], + 'components': [ + c.to_component_dict() for c in + sorted( + self._children, + key=lambda i: i._rendered_row or 0, + ) + ], 'accessory': self.accessory.to_component_dict(), } if self.id is not None: diff --git a/discord/ui/view.py b/discord/ui/view.py index 4a3e2ac8d730..e9bd6f773ac0 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -372,7 +372,7 @@ def clear_items(self) -> Self: self._children.clear() return self - def get_item_by_id(self, id: str, /) -> Optional[Item[Self]]: + def get_item_by_id(self, id: int, /) -> Optional[Item[Self]]: """Gets an item with :attr:`Item.id` set as ``id``, or ``None`` if not found. @@ -384,7 +384,7 @@ def get_item_by_id(self, id: str, /) -> Optional[Item[Self]]: Parameters ---------- - id: :class:`str` + id: :class:`int` The ID of the component. Returns From 5dddf65c4b653b7deca9977cfa14e0e529d6f229 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Fri, 18 Apr 2025 22:54:45 +0200 Subject: [PATCH 073/272] run black --- discord/ui/section.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/ui/section.py b/discord/ui/section.py index bacda788329d..13d13169cb15 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -205,8 +205,8 @@ def to_component_dict(self) -> Dict[str, Any]: data = { 'type': self.type.value, 'components': [ - c.to_component_dict() for c in - sorted( + c.to_component_dict() + for c in sorted( self._children, key=lambda i: i._rendered_row or 0, ) From a1216e7c365805911aca3bcf6feeb767adaa9734 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Fri, 18 Apr 2025 22:59:44 +0200 Subject: [PATCH 074/272] fix error when using Message.components --- discord/components.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/discord/components.py b/discord/components.py index 5ed52891fa83..80842f7fcff1 100644 --- a/discord/components.py +++ b/discord/components.py @@ -1214,6 +1214,20 @@ class Container(Component): The ID of this component. """ + __slots__ = ( + 'children', + 'id', + 'spoiler', + '_colour', + ) + + __repr_info__ = ( + 'children', + 'id', + 'spoiler', + 'accent_colour', + ) + def __init__(self, data: ContainerComponentPayload, state: Optional[ConnectionState]) -> None: self.children: List[Component] = [] self.id: Optional[int] = data.get('id') From cba602d472d0a53811d444e6d0f6d9346081ce96 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Fri, 18 Apr 2025 23:28:07 +0200 Subject: [PATCH 075/272] chore: Add more params to MessageFlags.components_v2 docstring --- discord/flags.py | 4 +++- discord/ui/view.py | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/discord/flags.py b/discord/flags.py index 1a9d612aac81..8bf4ee9c7326 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -503,7 +503,9 @@ def forwarded(self): def components_v2(self): """:class:`bool`: Returns ``True`` if the message has Discord's v2 components. - Does not allow sending any ``content``, ``embed``, or ``embeds``. + Does not allow sending any ``content``, ``embed``, ``embeds``, ``stickers``, or ``poll``. + + .. versionadded:: 2.6 """ return 32768 diff --git a/discord/ui/view.py b/discord/ui/view.py index e9bd6f773ac0..44a956b73207 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -567,8 +567,8 @@ class View(BaseView): This object must be inherited to create a UI within Discord. .. versionadded:: 2.0 - .. deprecated:: 2.6 + This class is deprecated and will be removed in a future version. Use :class:`LayoutView` instead. Parameters ----------- @@ -581,7 +581,8 @@ class View(BaseView): def __init_subclass__(cls) -> None: warnings.warn( - 'discord.ui.View and subclasses are deprecated, use discord.ui.LayoutView instead', + 'discord.ui.View and subclasses are deprecated and will be removed in' + 'a future version, use discord.ui.LayoutView instead', DeprecationWarning, ) super().__init_subclass__() From e9d942b233b7ea41bc33f7da1730eeb66715cdf7 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Fri, 18 Apr 2025 23:39:10 +0200 Subject: [PATCH 076/272] chore: typings --- discord/ui/container.py | 2 +- discord/ui/dynamic.py | 4 ++-- discord/ui/view.py | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index 198973dbeedc..c10d24119e72 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -224,7 +224,7 @@ def _update_store_data( pattern = item.__discord_ui_compiled_template__ dynamic_items[pattern] = item.__class__ elif item.is_dispatchable(): - dispatch_info[(item.type.value, item.custom_id)] = item # type: ignore + dispatch_info[(item.type.value, item.custom_id)] = item is_fully_dynamic = False return is_fully_dynamic diff --git a/discord/ui/dynamic.py b/discord/ui/dynamic.py index ee3ad30d50c1..b8aa78fdbe30 100644 --- a/discord/ui/dynamic.py +++ b/discord/ui/dynamic.py @@ -144,7 +144,7 @@ def is_persistent(self) -> bool: @property def custom_id(self) -> str: """:class:`str`: The ID of the dynamic item that gets received during an interaction.""" - return self.item.custom_id # type: ignore # This attribute exists for dispatchable items + return self.item.custom_id @custom_id.setter def custom_id(self, value: str) -> None: @@ -154,7 +154,7 @@ def custom_id(self, value: str) -> None: if not self.template.match(value): raise ValueError(f'custom_id must match the template {self.template.pattern!r}') - self.item.custom_id = value # type: ignore # This attribute exists for dispatchable items + self.item.custom_id = value self._provided_custom_id = True @property diff --git a/discord/ui/view.py b/discord/ui/view.py index 44a956b73207..5107716bd3b3 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -492,7 +492,7 @@ def _dispatch_item(self, item: Item, interaction: Interaction): def _refresh(self, components: List[Component]) -> None: # fmt: off old_state: Dict[str, Item[Any]] = { - item.custom_id: item # type: ignore + item.custom_id: item for item in self._children if item.is_dispatchable() } @@ -859,9 +859,9 @@ def add_view(self, view: BaseView, message_id: Optional[int] = None) -> None: pattern = accessory.__discord_ui_compiled_template__ self._dynamic_items[pattern] = accessory.__class__ else: - dispatch_info[(accessory.type.value, accessory.custom_id)] = accessory # type: ignore + dispatch_info[(accessory.type.value, accessory.custom_id)] = accessory else: - dispatch_info[(item.type.value, item.custom_id)] = item # type: ignore + dispatch_info[(item.type.value, item.custom_id)] = item is_fully_dynamic = False view._cache_key = message_id @@ -880,7 +880,7 @@ def remove_view(self, view: View) -> None: pattern = item.__discord_ui_compiled_template__ self._dynamic_items.pop(pattern, None) elif item.is_dispatchable(): - dispatch_info.pop((item.type.value, item.custom_id), None) # type: ignore + dispatch_info.pop((item.type.value, item.custom_id), None) if len(dispatch_info) == 0: self._views.pop(view._cache_key, None) From ec186ab18f0c605a6be25e8e705b04a31b1cb6c0 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 19 Apr 2025 00:13:27 +0200 Subject: [PATCH 077/272] chore: update docstrings --- discord/abc.py | 4 +++- discord/channel.py | 5 ++++- discord/client.py | 7 +++++-- discord/webhook/async_.py | 4 +++- discord/webhook/sync.py | 4 +++- 5 files changed, 18 insertions(+), 6 deletions(-) diff --git a/discord/abc.py b/discord/abc.py index b7fd0252ef91..748a021d7113 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -1546,10 +1546,12 @@ async def send( If set, overrides the :attr:`~discord.AllowedMentions.replied_user` attribute of ``allowed_mentions``. .. versionadded:: 1.6 - view: :class:`discord.ui.View` + view: Union[:class:`discord.ui.View`, :class:`discord.ui.LayoutView`] A Discord UI View to add to the message. .. versionadded:: 2.0 + .. versionchanged:: 2.6 + This parameter now accepts :class:`discord.ui.LayoutView` instances. stickers: Sequence[Union[:class:`~discord.GuildSticker`, :class:`~discord.StickerItem`]] A list of stickers to upload. Must be a maximum of 3. diff --git a/discord/channel.py b/discord/channel.py index 3dc43d388549..8833f566ea13 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -2907,8 +2907,11 @@ async def create_thread( If set, overrides the :attr:`~discord.AllowedMentions.replied_user` attribute of ``allowed_mentions``. applied_tags: List[:class:`discord.ForumTag`] A list of tags to apply to the thread. - view: :class:`discord.ui.View` + view: Union[:class:`discord.ui.View`, :class:`discord.ui.LayoutView`] A Discord UI View to add to the message. + + .. versionchanged:: 2.6 + This parameter now accepts :class:`discord.ui.LayoutView` instances. stickers: Sequence[Union[:class:`~discord.GuildSticker`, :class:`~discord.StickerItem`]] A list of stickers to upload. Must be a maximum of 3. suppress_embeds: :class:`bool` diff --git a/discord/client.py b/discord/client.py index 2eaae2455b76..c620dc23a9b5 100644 --- a/discord/client.py +++ b/discord/client.py @@ -3159,8 +3159,11 @@ def add_view(self, view: BaseView, *, message_id: Optional[int] = None) -> None: Parameters ------------ - view: :class:`discord.ui.View` + view: Union[:class:`discord.ui.View`, :class:`discord.ui.LayoutView`] The view to register for dispatching. + + .. versionchanged:: 2.6 + This now accepts :class:`discord.ui.LayoutView` instances. message_id: Optional[:class:`int`] The message ID that the view is attached to. This is currently used to refresh the view's state during message update events. If not given @@ -3188,7 +3191,7 @@ def add_view(self, view: BaseView, *, message_id: Optional[int] = None) -> None: @property def persistent_views(self) -> Sequence[BaseView]: - """Sequence[:class:`.View`]: A sequence of persistent views added to the client. + """Sequence[Union[:class:`.View`, :class:`.LayoutView`]]: A sequence of persistent views added to the client. .. versionadded:: 2.0 """ diff --git a/discord/webhook/async_.py b/discord/webhook/async_.py index d62807779051..c2c40ada5980 100644 --- a/discord/webhook/async_.py +++ b/discord/webhook/async_.py @@ -1734,12 +1734,14 @@ async def send( Controls the mentions being processed in this message. .. versionadded:: 1.4 - view: :class:`discord.ui.View` + view: Union[:class:`discord.ui.View`, :class:`discord.ui.LayoutView`] The view to send with the message. If the webhook is partial or is not managed by the library, then you can only send URL buttons. Otherwise, you can send views with any type of components. .. versionadded:: 2.0 + .. versionchanged:: 2.6 + This now accepts :class:`discord.ui.LayoutView` instances. thread: :class:`~discord.abc.Snowflake` The thread to send this webhook to. diff --git a/discord/webhook/sync.py b/discord/webhook/sync.py index db59b4659866..90459776102f 100644 --- a/discord/webhook/sync.py +++ b/discord/webhook/sync.py @@ -996,13 +996,15 @@ def send( When sending a Poll via webhook, you cannot manually end it. .. versionadded:: 2.4 - view: :class:`~discord.ui.View` + view: Union[:class:`~discord.ui.View`, :class:`~discord.ui.LayoutView`] The view to send with the message. This can only have URL buttons, which donnot require a state to be attached to it. If you want to send a view with any component attached to it, check :meth:`Webhook.send`. .. versionadded:: 2.5 + .. versionchanged:: 2.6 + This now accepts :class:`discord.ui.LayoutView` instances. Raises -------- From b0bab6d50d449f1ac11d79379137884b5ffcf0e6 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 19 Apr 2025 09:42:32 +0200 Subject: [PATCH 078/272] fix: `children` parameter being ignored on Container --- discord/ui/container.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/discord/ui/container.py b/discord/ui/container.py index c10d24119e72..96bafde0d020 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -104,6 +104,9 @@ def __init__( if children is not MISSING: if len(children) + len(self._children) > 10: raise ValueError('maximum number of children exceeded') + for child in children: + self.add_item(child) + self.spoiler: bool = spoiler self._colour = accent_colour or accent_color From 412caa6c2e26cf3a6213ab283a8bf48e2bd816b1 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 19 Apr 2025 09:54:06 +0200 Subject: [PATCH 079/272] update ActionRow.select docstring --- discord/ui/action_row.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 70384cf9a0b7..9b01cd3a0ab5 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -411,7 +411,7 @@ def select( """A decorator that attaches a select menu to a component. The function being decorated should have three parameters, ``self`` representing - the :class:`discord.ui.View`, the :class:`discord.Interaction` you receive and + the :class:`discord.ui.LayoutView`, the :class:`discord.Interaction` you receive and the chosen select class. To obtain the selected values inside the callback, you can use the ``values`` attribute of the chosen class in the callback. The list of values From 9026bcbb1d45ba41df09ce9fddff231268fd4987 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 19 Apr 2025 09:57:02 +0200 Subject: [PATCH 080/272] add note about Item.custom_id --- discord/ui/item.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/discord/ui/item.py b/discord/ui/item.py index 614859d726d2..d735641db181 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -83,6 +83,8 @@ def __init__(self): self._max_row: int = 5 if not self._is_v2() else 10 if self._is_v2(): + # this is done so v2 components can be stored on ViewStore._views + # and does not break v1 components custom_id property self.custom_id: str = os.urandom(16).hex() def to_component_dict(self) -> Dict[str, Any]: From cf949c689fbfd360c4e99984f10562c71f1940f5 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 19 Apr 2025 10:57:06 +0200 Subject: [PATCH 081/272] chore: some bunch fixes and make interaction_check's work on every item --- discord/ui/action_row.py | 26 +++++++++++++------------- discord/ui/button.py | 10 ++++++++-- discord/ui/container.py | 7 ++++--- discord/ui/item.py | 13 ++++++++++++- discord/ui/select.py | 16 ++++++++-------- discord/ui/view.py | 14 +++++++------- 6 files changed, 52 insertions(+), 34 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 9b01cd3a0ab5..9fa8541c7f23 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -75,8 +75,8 @@ class _ActionRowCallback: __slots__ = ('row', 'callback', 'item') - def __init__(self, callback: ItemCallbackType[Any, Any], row: ActionRow, item: Item[Any]) -> None: - self.callback: ItemCallbackType[Any, Any] = callback + def __init__(self, callback: ItemCallbackType[Any], row: ActionRow, item: Item[Any]) -> None: + self.callback: ItemCallbackType[Any] = callback self.row: ActionRow = row self.item: Item[Any] = item @@ -97,7 +97,7 @@ class ActionRow(Item[V]): The ID of this component. This must be unique across the view. """ - __action_row_children_items__: ClassVar[List[ItemCallbackType[Any, Any]]] = [] + __action_row_children_items__: ClassVar[List[ItemCallbackType[Any]]] = [] __discord_ui_action_row__: ClassVar[bool] = True __discord_ui_update_view__: ClassVar[bool] = True @@ -110,7 +110,7 @@ def __init__(self, *, id: Optional[int] = None) -> None: def __init_subclass__(cls) -> None: super().__init_subclass__() - children: Dict[str, ItemCallbackType[Any, Any]] = {} + children: Dict[str, ItemCallbackType[Any]] = {} for base in reversed(cls.__mro__): for name, member in base.__dict__.items(): if hasattr(member, '__discord_ui_model_type__'): @@ -269,7 +269,7 @@ def button( disabled: bool = False, style: ButtonStyle = ButtonStyle.secondary, emoji: Optional[Union[str, Emoji, PartialEmoji]] = None, - ) -> Callable[[ItemCallbackType[V, Button[V]]], Button[V]]: + ) -> Callable[[ItemCallbackType[Button[V]]], Button[V]]: """A decorator that attaches a button to a component. The function being decorated should have three parameters, ``self`` representing @@ -302,7 +302,7 @@ def button( or a full :class:`.Emoji`. """ - def decorator(func: ItemCallbackType[V, Button[V]]) -> ItemCallbackType[V, Button[V]]: + def decorator(func: ItemCallbackType[Button[V]]) -> ItemCallbackType[Button[V]]: ret = _button( label=label, custom_id=custom_id, @@ -328,7 +328,7 @@ def select( min_values: int = ..., max_values: int = ..., disabled: bool = ..., - ) -> SelectCallbackDecorator[V, SelectT]: + ) -> SelectCallbackDecorator[SelectT]: ... @overload @@ -344,7 +344,7 @@ def select( max_values: int = ..., disabled: bool = ..., default_values: Sequence[ValidDefaultValues] = ..., - ) -> SelectCallbackDecorator[V, UserSelectT]: + ) -> SelectCallbackDecorator[UserSelectT]: ... @overload @@ -360,7 +360,7 @@ def select( max_values: int = ..., disabled: bool = ..., default_values: Sequence[ValidDefaultValues] = ..., - ) -> SelectCallbackDecorator[V, RoleSelectT]: + ) -> SelectCallbackDecorator[RoleSelectT]: ... @overload @@ -376,7 +376,7 @@ def select( max_values: int = ..., disabled: bool = ..., default_values: Sequence[ValidDefaultValues] = ..., - ) -> SelectCallbackDecorator[V, ChannelSelectT]: + ) -> SelectCallbackDecorator[ChannelSelectT]: ... @overload @@ -392,7 +392,7 @@ def select( max_values: int = ..., disabled: bool = ..., default_values: Sequence[ValidDefaultValues] = ..., - ) -> SelectCallbackDecorator[V, MentionableSelectT]: + ) -> SelectCallbackDecorator[MentionableSelectT]: ... def select( @@ -407,7 +407,7 @@ def select( max_values: int = 1, disabled: bool = False, default_values: Sequence[ValidDefaultValues] = MISSING, - ) -> SelectCallbackDecorator[V, BaseSelectT]: + ) -> SelectCallbackDecorator[BaseSelectT]: """A decorator that attaches a select menu to a component. The function being decorated should have three parameters, ``self`` representing @@ -477,7 +477,7 @@ async def select_channels(self, interaction: discord.Interaction, select: Channe Number of items must be in range of ``min_values`` and ``max_values``. """ - def decorator(func: ItemCallbackType[V, BaseSelectT]) -> ItemCallbackType[V, BaseSelectT]: + def decorator(func: ItemCallbackType[BaseSelectT]) -> ItemCallbackType[BaseSelectT]: r = _select( # type: ignore cls=cls, # type: ignore placeholder=placeholder, diff --git a/discord/ui/button.py b/discord/ui/button.py index 7a60333db414..46230d480d54 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -281,7 +281,8 @@ def button( style: ButtonStyle = ButtonStyle.secondary, emoji: Optional[Union[str, Emoji, PartialEmoji]] = None, row: Optional[int] = None, -) -> Callable[[ItemCallbackType[V, Button[V]]], Button[V]]: + id: Optional[int] = None, +) -> Callable[[ItemCallbackType[Button[V]]], Button[V]]: """A decorator that attaches a button to a component. The function being decorated should have three parameters, ``self`` representing @@ -318,9 +319,13 @@ def button( 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). + id: Optional[:class:`int`] + The ID of this component. This must be unique across the view. + + .. versionadded:: 2.6 """ - def decorator(func: ItemCallbackType[V, Button[V]]) -> ItemCallbackType[V, Button[V]]: + def decorator(func: ItemCallbackType[Button[V]]) -> ItemCallbackType[Button[V]]: if not inspect.iscoroutinefunction(func): raise TypeError('button function must be a coroutine function') @@ -334,6 +339,7 @@ def decorator(func: ItemCallbackType[V, Button[V]]) -> ItemCallbackType[V, Butto 'emoji': emoji, 'row': row, 'sku_id': None, + 'id': id, } return func diff --git a/discord/ui/container.py b/discord/ui/container.py index 96bafde0d020..58176d0f5326 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -46,8 +46,8 @@ class _ContainerCallback: __slots__ = ('container', 'callback', 'item') - def __init__(self, callback: ItemCallbackType[Any, Any], container: Container, item: Item[Any]) -> None: - self.callback: ItemCallbackType[Any, Any] = callback + def __init__(self, callback: ItemCallbackType[Any], container: Container, item: Item[Any]) -> None: + self.callback: ItemCallbackType[Any] = callback self.container: Container = container self.item: Item[Any] = item @@ -63,7 +63,7 @@ class Container(Item[V]): Parameters ---------- children: List[:class:`Item`] - The initial children or :class:`View` s of this container. Can have up to 10 + The initial children of this container. Can have up to 10 items. accent_colour: Optional[:class:`.Colour`] The colour of the container. Defaults to ``None``. @@ -124,6 +124,7 @@ def _init_children(self) -> List[Item[Any]]: if getattr(raw, '__discord_ui_section__', False) and raw.accessory.is_dispatchable(): # type: ignore self.__dispatchable.append(raw.accessory) # type: ignore elif getattr(raw, '__discord_ui_action_row__', False) and raw.is_dispatchable(): + raw._parent = self # type: ignore self.__dispatchable.extend(raw._children) # type: ignore else: # action rows can be created inside containers, and then callbacks can exist here diff --git a/discord/ui/item.py b/discord/ui/item.py index d735641db181..4206274c34dd 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -43,7 +43,7 @@ I = TypeVar('I', bound='Item[Any]') V = TypeVar('V', bound='BaseView', covariant=True) -ItemCallbackType = Callable[[V, Interaction[Any], I], Coroutine[Any, Any, Any]] +ItemCallbackType = Callable[[Any, Interaction[Any], I], Coroutine[Any, Any, Any]] class Item(Generic[V]): @@ -151,6 +151,17 @@ def id(self) -> Optional[int]: def id(self, value: Optional[int]) -> None: self._id = value + async def _run_checks(self, interaction: Interaction[ClientT]) -> bool: + can_run = await self.interaction_check(interaction) + + if can_run: + parent = getattr(self, '_parent', None) + + if parent is not None: + can_run = await parent._run_checks(interaction) + + return can_run + async def callback(self, interaction: Interaction[ClientT]) -> Any: """|coro| diff --git a/discord/ui/select.py b/discord/ui/select.py index e2d3d34d2583..40b8a26f38dc 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -109,7 +109,7 @@ RoleSelectT = TypeVar('RoleSelectT', bound='RoleSelect[Any]') ChannelSelectT = TypeVar('ChannelSelectT', bound='ChannelSelect[Any]') MentionableSelectT = TypeVar('MentionableSelectT', bound='MentionableSelect[Any]') -SelectCallbackDecorator: TypeAlias = Callable[[ItemCallbackType[V, BaseSelectT]], BaseSelectT] +SelectCallbackDecorator: TypeAlias = Callable[[ItemCallbackType[BaseSelectT]], BaseSelectT] DefaultSelectComponentTypes = Literal[ ComponentType.user_select, ComponentType.role_select, @@ -936,7 +936,7 @@ def select( disabled: bool = ..., row: Optional[int] = ..., id: Optional[int] = ..., -) -> SelectCallbackDecorator[V, SelectT]: +) -> SelectCallbackDecorator[SelectT]: ... @@ -954,7 +954,7 @@ def select( default_values: Sequence[ValidDefaultValues] = ..., row: Optional[int] = ..., id: Optional[int] = ..., -) -> SelectCallbackDecorator[V, UserSelectT]: +) -> SelectCallbackDecorator[UserSelectT]: ... @@ -972,7 +972,7 @@ def select( default_values: Sequence[ValidDefaultValues] = ..., row: Optional[int] = ..., id: Optional[int] = ..., -) -> SelectCallbackDecorator[V, RoleSelectT]: +) -> SelectCallbackDecorator[RoleSelectT]: ... @@ -990,7 +990,7 @@ def select( default_values: Sequence[ValidDefaultValues] = ..., row: Optional[int] = ..., id: Optional[int] = ..., -) -> SelectCallbackDecorator[V, ChannelSelectT]: +) -> SelectCallbackDecorator[ChannelSelectT]: ... @@ -1008,7 +1008,7 @@ def select( default_values: Sequence[ValidDefaultValues] = ..., row: Optional[int] = ..., id: Optional[int] = ..., -) -> SelectCallbackDecorator[V, MentionableSelectT]: +) -> SelectCallbackDecorator[MentionableSelectT]: ... @@ -1025,7 +1025,7 @@ def select( default_values: Sequence[ValidDefaultValues] = MISSING, row: Optional[int] = None, id: Optional[int] = None, -) -> SelectCallbackDecorator[V, BaseSelectT]: +) -> SelectCallbackDecorator[BaseSelectT]: """A decorator that attaches a select menu to a component. The function being decorated should have three parameters, ``self`` representing @@ -1110,7 +1110,7 @@ async def select_channels(self, interaction: discord.Interaction, select: Channe .. versionadded:: 2.6 """ - def decorator(func: ItemCallbackType[V, BaseSelectT]) -> ItemCallbackType[V, BaseSelectT]: + def decorator(func: ItemCallbackType[BaseSelectT]) -> ItemCallbackType[BaseSelectT]: if not inspect.iscoroutinefunction(func): raise TypeError('select function must be a coroutine function') callback_cls = getattr(cls, '__origin__', cls) diff --git a/discord/ui/view.py b/discord/ui/view.py index 5107716bd3b3..0fb57787113c 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -84,7 +84,7 @@ from ..state import ConnectionState from .modal import Modal - ItemLike = Union[ItemCallbackType[Any, Any], Item[Any]] + ItemLike = Union[ItemCallbackType[Any], Item[Any]] _log = logging.getLogger(__name__) @@ -185,8 +185,8 @@ def v2_weights(self) -> bool: class _ViewCallback: __slots__ = ('view', 'callback', 'item') - def __init__(self, callback: ItemCallbackType[Any, Any], view: BaseView, item: Item[BaseView]) -> None: - self.callback: ItemCallbackType[Any, Any] = callback + def __init__(self, callback: ItemCallbackType[Any], view: BaseView, item: Item[BaseView]) -> None: + self.callback: ItemCallbackType[Any] = callback self.view: BaseView = view self.item: Item[BaseView] = item @@ -452,7 +452,7 @@ async def _scheduled_task(self, item: Item, interaction: Interaction): try: item._refresh_state(interaction, interaction.data) # type: ignore - allow = await item.interaction_check(interaction) and await self.interaction_check(interaction) + allow = await item._run_checks(interaction) and await self.interaction_check(interaction) if not allow: return @@ -581,13 +581,13 @@ class View(BaseView): def __init_subclass__(cls) -> None: warnings.warn( - 'discord.ui.View and subclasses are deprecated and will be removed in' + 'discord.ui.View and subclasses are deprecated and will be removed in ' 'a future version, use discord.ui.LayoutView instead', DeprecationWarning, ) super().__init_subclass__() - children: Dict[str, ItemCallbackType[Any, Any]] = {} + children: Dict[str, ItemCallbackType[Any]] = {} for base in reversed(cls.__mro__): for name, member in base.__dict__.items(): if hasattr(member, '__discord_ui_model_type__'): @@ -716,7 +716,7 @@ def __init__(self, *, timeout: Optional[float] = 180.0) -> None: def __init_subclass__(cls) -> None: children: Dict[str, Item[Any]] = {} - callback_children: Dict[str, ItemCallbackType[Any, Any]] = {} + callback_children: Dict[str, ItemCallbackType[Any]] = {} row = 0 From fb8e85da7cb681ec15d09456e9dcf2ac2215c2d2 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 19 Apr 2025 10:59:41 +0200 Subject: [PATCH 082/272] fix: typings --- discord/ui/container.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index 58176d0f5326..d2fdf5e5ea19 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -83,7 +83,7 @@ class Container(Item[V]): The ID of this component. This must be unique across the view. """ - __container_children_items__: ClassVar[List[Union[ItemCallbackType[Any, Any], Item[Any]]]] = [] + __container_children_items__: ClassVar[List[Union[ItemCallbackType[Any], Item[Any]]]] = [] __discord_ui_update_view__: ClassVar[bool] = True __discord_ui_container__: ClassVar[bool] = True @@ -151,7 +151,7 @@ def is_dispatchable(self) -> bool: def __init_subclass__(cls) -> None: super().__init_subclass__() - children: Dict[str, Union[ItemCallbackType[Any, Any], Item[Any]]] = {} + children: Dict[str, Union[ItemCallbackType[Any], Item[Any]]] = {} for base in reversed(cls.__mro__): for name, member in base.__dict__.items(): if isinstance(member, Item): From fe7d7f2ce6b7942609c04470ff2a5c3b1bb6cbe0 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 19 Apr 2025 12:15:57 +0200 Subject: [PATCH 083/272] chore: Update view param docstring on send methods --- discord/abc.py | 2 +- discord/channel.py | 2 +- discord/ext/commands/context.py | 4 +++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/discord/abc.py b/discord/abc.py index 748a021d7113..505552684b68 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -1551,7 +1551,7 @@ async def send( .. versionadded:: 2.0 .. versionchanged:: 2.6 - This parameter now accepts :class:`discord.ui.LayoutView` instances. + This now accepts :class:`discord.ui.LayoutView` instances. stickers: Sequence[Union[:class:`~discord.GuildSticker`, :class:`~discord.StickerItem`]] A list of stickers to upload. Must be a maximum of 3. diff --git a/discord/channel.py b/discord/channel.py index 8833f566ea13..f17a74ca81d5 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -2911,7 +2911,7 @@ async def create_thread( A Discord UI View to add to the message. .. versionchanged:: 2.6 - This parameter now accepts :class:`discord.ui.LayoutView` instances. + This now accepts :class:`discord.ui.LayoutView` instances. stickers: Sequence[Union[:class:`~discord.GuildSticker`, :class:`~discord.StickerItem`]] A list of stickers to upload. Must be a maximum of 3. suppress_embeds: :class:`bool` diff --git a/discord/ext/commands/context.py b/discord/ext/commands/context.py index 20eb7059962d..931142b38872 100644 --- a/discord/ext/commands/context.py +++ b/discord/ext/commands/context.py @@ -986,10 +986,12 @@ async def send( This is ignored for interaction based contexts. .. versionadded:: 1.6 - view: :class:`discord.ui.View` + view: Union[:class:`discord.ui.View`, :class:`discord.ui.LayoutView`] A Discord UI View to add to the message. .. versionadded:: 2.0 + .. versionchanged:: 2.6 + This now accepts :class:`discord.ui.LayoutView` instances. embeds: List[:class:`~discord.Embed`] A list of embeds to upload. Must be a maximum of 10. From 195b9e75b6680b8698dafb02bb6e306443108ca3 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 19 Apr 2025 17:32:39 +0200 Subject: [PATCH 084/272] chore: Allow ints on accent_colo(u)r on Container's --- discord/ui/container.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index d2fdf5e5ea19..e45c1dac60e4 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -65,9 +65,9 @@ class Container(Item[V]): children: List[:class:`Item`] The initial children of this container. Can have up to 10 items. - accent_colour: Optional[:class:`.Colour`] + accent_colour: Optional[Union[:class:`.Colour`, :class:`int`]] The colour of the container. Defaults to ``None``. - accent_color: Optional[:class:`.Colour`] + accent_color: Optional[Union[:class:`.Colour`, :class:`int`]] The color of the container. Defaults to ``None``. spoiler: :class:`bool` Whether to flag this container as a spoiler. Defaults @@ -91,8 +91,8 @@ def __init__( self, children: List[Item[V]] = MISSING, *, - accent_colour: Optional[Colour] = None, - accent_color: Optional[Color] = None, + accent_colour: Optional[Union[Colour, int]] = None, + accent_color: Optional[Union[Color, int]] = None, spoiler: bool = False, row: Optional[int] = None, id: Optional[int] = None, @@ -178,12 +178,12 @@ def children(self, value: List[Item[V]]) -> None: self._children = value @property - def accent_colour(self) -> Optional[Colour]: - """Optional[:class:`discord.Colour`]: The colour of the container, or ``None``.""" + def accent_colour(self) -> Optional[Union[Colour, int]]: + """Optional[Union[:class:`discord.Colour`, :class:`int`]]: The colour of the container, or ``None``.""" return self._colour @accent_colour.setter - def accent_colour(self, value: Optional[Colour]) -> None: + def accent_colour(self, value: Optional[Union[Colour, int]]) -> None: self._colour = value accent_color = accent_colour @@ -207,9 +207,14 @@ def to_components(self) -> List[Dict[str, Any]]: def to_component_dict(self) -> Dict[str, Any]: components = self.to_components() + + colour = None + if self._colour: + colour = self._colour if isinstance(self._colour, int) else self._colour.value + base = { 'type': self.type.value, - 'accent_color': self._colour.value if self._colour else None, + 'accent_color': colour, 'spoiler': self.spoiler, 'components': components, } From 70289119d261e0331d9895536038229a3a882b49 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 19 Apr 2025 22:00:27 +0200 Subject: [PATCH 085/272] fix: Item.view not being correctly set when using 'add_item' methods --- discord/ui/action_row.py | 1 + discord/ui/container.py | 4 +++- discord/ui/section.py | 6 +++--- discord/ui/view.py | 4 ++++ 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 9fa8541c7f23..b3c9837ad1bb 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -197,6 +197,7 @@ def add_item(self, item: Item[Any]) -> Self: if not isinstance(item, Item): raise TypeError(f'expected Item not {item.__class__.__name__}') + item._view = self._view self._children.append(item) return self diff --git a/discord/ui/container.py b/discord/ui/container.py index e45c1dac60e4..260577de9180 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -110,7 +110,6 @@ def __init__( self.spoiler: bool = spoiler self._colour = accent_colour or accent_color - self._view: Optional[V] = None self.row = row self.id = id @@ -277,6 +276,9 @@ def add_item(self, item: Item[Any]) -> Self: if getattr(item, '__discord_ui_section__', False): self.__dispatchable.append(item.accessory) # type: ignore + if getattr(item, '__discord_ui_update_view__', False): + item._update_children_view(self.view) # type: ignore + return self def remove_item(self, item: Item[Any]) -> Self: diff --git a/discord/ui/section.py b/discord/ui/section.py index 13d13169cb15..98784d50adf0 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -139,9 +139,9 @@ def add_item(self, item: Union[str, Item[Any]]) -> Self: if not isinstance(item, (Item, str)): raise TypeError(f'expected Item or str not {item.__class__.__name__}') - self._children.append( - item if isinstance(item, Item) else TextDisplay(item), - ) + item = item if isinstance(item, Item) else TextDisplay(item) + item._view = self.view + self._children.append(item) return self def remove_item(self, item: Item[Any]) -> Self: diff --git a/discord/ui/view.py b/discord/ui/view.py index 0fb57787113c..716576ccbbfe 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -342,6 +342,10 @@ def add_item(self, item: Item[Any]) -> Self: raise ValueError('v2 items cannot be added to this view') item._view = self + + if getattr(item, '__discord_ui_update_view__', False): + item._update_children_view(self) # type: ignore + self._children.append(item) return self From 86ec83471b65b64e7f7f17d0239d76facffc4320 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 19 Apr 2025 22:04:21 +0200 Subject: [PATCH 086/272] chore: Update BaseView.__repr__ --- discord/ui/view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index 716576ccbbfe..dc6b581cc7a1 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -214,7 +214,7 @@ def _is_v2(self) -> bool: return False def __repr__(self) -> str: - return f'<{self.__class__.__name__} timeout={self.timeout} children={len(self._children)}' + return f'<{self.__class__.__name__} timeout={self.timeout} children={len(self._children)}>' def _init_children(self) -> List[Item[Self]]: children = [] From 8376dbfd496e4f597034cb5d415a8ed6b8ccc535 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 20 Apr 2025 12:25:23 +0200 Subject: [PATCH 087/272] chore: Add Thumbnail.description char limit to docs --- discord/ui/thumbnail.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/thumbnail.py b/discord/ui/thumbnail.py index 7f21edd3aad7..f639269918a4 100644 --- a/discord/ui/thumbnail.py +++ b/discord/ui/thumbnail.py @@ -52,7 +52,7 @@ class Thumbnail(Item[V]): to an attachment that matches the ``attachment://filename.extension`` structure. description: Optional[:class:`str`] - The description of this thumbnail. Defaults to ``None``. + The description of this thumbnail. Up to 256 characters. Defaults to ``None``. spoiler: :class:`bool` Whether to flag this thumbnail as a spoiler. Defaults to ``False``. row: Optional[:class:`int`] From 9f3f8f1c38ab0e58722b2a8c47ceb17ec10864c4 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 20 Apr 2025 12:27:19 +0200 Subject: [PATCH 088/272] chore: Add MediaGalleryItem.description char limit to docs --- discord/components.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/discord/components.py b/discord/components.py index 80842f7fcff1..22c3d714cb54 100644 --- a/discord/components.py +++ b/discord/components.py @@ -993,7 +993,8 @@ class MediaGalleryItem: file uploaded as an attachment in the message, that can be accessed using the ``attachment://file-name.extension`` format. description: Optional[:class:`str`] - The description to show within this item. + The description to show within this item. Up to 256 characters. Defaults + to ``None``. spoiler: :class:`bool` Whether this item should be flagged as a spoiler. """ From e0c07539a98af319b5b5cff321928fec5d991772 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 20 Apr 2025 13:51:42 +0200 Subject: [PATCH 089/272] chore: Update interactions docs --- discord/interactions.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/discord/interactions.py b/discord/interactions.py index e02be7359480..658ec4127887 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -929,8 +929,11 @@ async def send_message( A list of files to upload. Must be a maximum of 10. tts: :class:`bool` Indicates if the message should be sent using text-to-speech. - view: :class:`discord.ui.View` + view: Union[:class:`discord.ui.View`, :class:`discord.ui.LayoutView`] The view to send with the message. + + .. versionchanged:: 2.6 + This now accepts :class:`discord.ui.LayoutView` instances. ephemeral: :class:`bool` Indicates if the message should only be visible to the user who started the interaction. If a view is sent with an ephemeral message and it has no timeout set then the timeout @@ -1076,9 +1079,12 @@ async def edit_message( New files will always appear after current attachments. - view: Optional[:class:`~discord.ui.View`] + view: Optional[Union[:class:`~discord.ui.View`, :class:`~discord.ui.LayoutView`]] The updated view to update this message with. If ``None`` is passed then the view is removed. + + .. versionchanged:: 2.6 + This now accepts :class:`~discord.ui.LayoutView` instances. allowed_mentions: Optional[:class:`~discord.AllowedMentions`] Controls the mentions being processed in this message. See :meth:`.Message.edit` for more information. @@ -1363,9 +1369,12 @@ async def edit( allowed_mentions: :class:`AllowedMentions` Controls the mentions being processed in this message. See :meth:`.abc.Messageable.send` for more information. - view: Optional[:class:`~discord.ui.View`] + view: Optional[Union[:class:`~discord.ui.View`, :class:`~discord.ui.LayoutView`]] The updated view to update this message with. If ``None`` is passed then the view is removed. + + .. versionchanged:: 2.6 + This now accepts :class:`~discord.ui.LayoutView` instances. delete_after: Optional[:class:`float`] If provided, the number of seconds to wait in the background before deleting the message we just sent. If the deletion fails, From 2248df00a310e0e819d465c6a4b14583a573c015 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 20 Apr 2025 13:52:05 +0200 Subject: [PATCH 090/272] chore: Add char limit to TextDisplay --- discord/ui/text_display.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/text_display.py b/discord/ui/text_display.py index 409b68272187..f311cf66c4b3 100644 --- a/discord/ui/text_display.py +++ b/discord/ui/text_display.py @@ -47,7 +47,7 @@ class TextDisplay(Item[V]): Parameters ---------- content: :class:`str` - The content of this text display. + The content of this text display. Up to 4000 characters. row: Optional[:class:`int`] The relative row this text display belongs to. By default items are arranged automatically into those rows. If you'd From 22e473891824c8ea8f92e5a000ed7c651af5a20a Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 20 Apr 2025 14:12:16 +0200 Subject: [PATCH 091/272] chore: Fix interaction_check not being called correctly --- discord/ui/action_row.py | 1 + discord/ui/container.py | 6 ++++++ discord/ui/item.py | 8 +++----- discord/ui/section.py | 1 + 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index b3c9837ad1bb..b2d713f5a651 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -198,6 +198,7 @@ def add_item(self, item: Item[Any]) -> Self: raise TypeError(f'expected Item not {item.__class__.__name__}') item._view = self._view + item._parent = self self._children.append(item) return self diff --git a/discord/ui/container.py b/discord/ui/container.py index 260577de9180..84147d88228b 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -275,10 +275,16 @@ def add_item(self, item: Item[Any]) -> Self: if item.is_dispatchable(): if getattr(item, '__discord_ui_section__', False): self.__dispatchable.append(item.accessory) # type: ignore + elif hasattr(item, '_children'): + self.__dispatchable.extend([i for i in item._children if i.is_dispatchable()]) # type: ignore + else: + self.__dispatchable.append(item) if getattr(item, '__discord_ui_update_view__', False): item._update_children_view(self.view) # type: ignore + item._view = self.view + item._parent = self return self def remove_item(self, item: Item[Any]) -> Self: diff --git a/discord/ui/item.py b/discord/ui/item.py index 4206274c34dd..47a31633bd0b 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -81,6 +81,7 @@ def __init__(self): self._provided_custom_id: bool = False self._id: Optional[int] = None self._max_row: int = 5 if not self._is_v2() else 10 + self._parent: Optional[Item] = None if self._is_v2(): # this is done so v2 components can be stored on ViewStore._views @@ -154,11 +155,8 @@ def id(self, value: Optional[int]) -> None: async def _run_checks(self, interaction: Interaction[ClientT]) -> bool: can_run = await self.interaction_check(interaction) - if can_run: - parent = getattr(self, '_parent', None) - - if parent is not None: - can_run = await parent._run_checks(interaction) + if can_run and self._parent: + can_run = await self._parent._run_checks(interaction) return can_run diff --git a/discord/ui/section.py b/discord/ui/section.py index 98784d50adf0..53d433c3ea77 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -141,6 +141,7 @@ def add_item(self, item: Union[str, Item[Any]]) -> Self: item = item if isinstance(item, Item) else TextDisplay(item) item._view = self.view + item._parent = self self._children.append(item) return self From 92cb5575e31ebf2621a337b400984f120ce9313e Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 20 Apr 2025 14:23:08 +0200 Subject: [PATCH 092/272] chore: Remove leftover code --- discord/http.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/discord/http.py b/discord/http.py index e0fca595861d..4e12de8bd47c 100644 --- a/discord/http.py +++ b/discord/http.py @@ -192,8 +192,6 @@ def handle_message_parameters( if view is not MISSING: if view is not None: - if getattr(view, '__discord_ui_container__', False): - raise TypeError('Containers must be wrapped around Views') payload['components'] = view.to_components() if view.has_components_v2(): From 876397e5ad191a788643ee6a1a555638605a9779 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 22 Apr 2025 18:28:36 +0200 Subject: [PATCH 093/272] chore: Improve Items documentation --- discord/ui/action_row.py | 36 ++++++++++++++++++++++++++++++++++-- discord/ui/container.py | 35 ++++++++++++++++++++++++++++++++++- discord/ui/file.py | 15 +++++++++++++++ discord/ui/view.py | 4 ++++ 4 files changed, 87 insertions(+), 3 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index b2d713f5a651..22df88f85618 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -87,10 +87,42 @@ def __call__(self, interaction: Interaction) -> Coroutine[Any, Any, Any]: class ActionRow(Item[V]): """Represents a UI action row. - This object can be inherited. + This is a top-level layout component that can only be used on :class:`LayoutView` + and can contain :class:`Button` 's and :class:`Select` 's in it. + + This can be inherited. + + .. note:: + + Action rows can contain up to 5 components, which is, 5 buttons or 1 select. .. versionadded:: 2.6 + Examples + -------- + + .. code-block:: python3 + + import discord + from discord import ui + + # you can subclass it and add components with the decorators + class MyActionRow(ui.ActionRow): + @ui.button(label='Click Me!') + async def click_me(self, interaction: discord.Interaction, button: discord.ui.Button): + await interaction.response.send_message('You clicked me!') + + # or use it directly on LayoutView + class MyView(ui.LayoutView): + row = ui.ActionRow() + # or you can use your subclass: + # row = MyActionRow() + + # you can create items with row.button and row.select + @row.button(label='A button!') + async def row_button(self, interaction: discord.Interaction, button: discord.ui.Button): + await interaction.response.send_message('You clicked a button!') + Parameters ---------- id: Optional[:class:`int`] @@ -127,7 +159,7 @@ def _init_children(self) -> List[Item[Any]]: for func in self.__action_row_children_items__: item: Item = func.__discord_ui_model_type__(**func.__discord_ui_model_kwargs__) item.callback = _ActionRowCallback(func, self, item) # type: ignore - item._parent = getattr(func, '__discord_ui_parent__', self) # type: ignore + item._parent = getattr(func, '__discord_ui_parent__', self) setattr(self, func.__name__, item) children.append(item) return children diff --git a/discord/ui/container.py b/discord/ui/container.py index 84147d88228b..a90df10b93f0 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -58,8 +58,41 @@ def __call__(self, interaction: Interaction) -> Coroutine[Any, Any, Any]: class Container(Item[V]): """Represents a UI container. + This is a top-level layout component that can only be used on :class:`LayoutView` + and can contain :class:`ActionRow` 's, :class:`TextDisplay` 's, :class:`Section` 's, + :class:`MediaGallery` 's, and :class:`File` 's in it. + + This can be inherited. + + .. note:: + + Containers can contain up to 10 top-level components. + .. versionadded:: 2.6 + Examples + -------- + + .. code-block:: python3 + + import discord + from discord import ui + + # you can subclass it and add components as you would add them + # in a LayoutView + class MyContainer(ui.Container): + action_row = ui.ActionRow() + + @action_row.button(label='A button in a container!') + async def a_button(self, interaction: discord.Interaction, button: discord.ui.Button): + await interaction.response.send_message('You clicked a button!') + + # or use it directly on LayoutView + class MyView(ui.LayoutView): + container = ui.Container([ui.TextDisplay('I am a text display on a container!')]) + # or you can use your subclass: + # container = MyContainer() + Parameters ---------- children: List[:class:`Item`] @@ -123,7 +156,7 @@ def _init_children(self) -> List[Item[Any]]: if getattr(raw, '__discord_ui_section__', False) and raw.accessory.is_dispatchable(): # type: ignore self.__dispatchable.append(raw.accessory) # type: ignore elif getattr(raw, '__discord_ui_action_row__', False) and raw.is_dispatchable(): - raw._parent = self # type: ignore + raw._parent = self self.__dispatchable.extend(raw._children) # type: ignore else: # action rows can be created inside containers, and then callbacks can exist here diff --git a/discord/ui/file.py b/discord/ui/file.py index 0f6875421521..2e10ff98999d 100644 --- a/discord/ui/file.py +++ b/discord/ui/file.py @@ -42,8 +42,23 @@ class File(Item[V]): """Represents a UI file component. + This is a top-level layout component that can only be used on :class:`LayoutView`. + .. versionadded:: 2.6 + Example + ------- + + .. code-block:: python3 + + import discord + from discord import ui + + class MyView(ui.LayoutView): + file = ui.File('attachment://file.txt') + # attachment://file.txt points to an attachment uploaded alongside + # this view + Parameters ---------- media: Union[:class:`str`, :class:`.UnfurledMediaItem`] diff --git a/discord/ui/view.py b/discord/ui/view.py index dc6b581cc7a1..61abd98757ef 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -706,6 +706,10 @@ def clear_items(self) -> Self: class LayoutView(BaseView): """Represents a layout view for components. + This object must be inherited to create a UI within Discord. + + + .. versionadded:: 2.6 Parameters From 4cb3b410a7f61d080e84823274a5a489abd3cf68 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 22 Apr 2025 23:08:29 +0200 Subject: [PATCH 094/272] chore: more docs things ig --- discord/ui/file.py | 3 +-- discord/ui/media_gallery.py | 4 +++- discord/ui/section.py | 2 ++ discord/ui/separator.py | 2 ++ discord/ui/text_display.py | 2 ++ 5 files changed, 10 insertions(+), 3 deletions(-) diff --git a/discord/ui/file.py b/discord/ui/file.py index 2e10ff98999d..341860cc739d 100644 --- a/discord/ui/file.py +++ b/discord/ui/file.py @@ -56,8 +56,7 @@ class File(Item[V]): class MyView(ui.LayoutView): file = ui.File('attachment://file.txt') - # attachment://file.txt points to an attachment uploaded alongside - # this view + # attachment://file.txt points to an attachment uploaded alongside this view Parameters ---------- diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index 3deca63c86cf..e3db92215470 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -45,7 +45,9 @@ class MediaGallery(Item[V]): """Represents a UI media gallery. - This can contain up to 10 :class:`.MediaGalleryItem` s. + Can contain up to 10 :class:`.MediaGalleryItem` 's. + + This is a top-level layout component that can only be used on :class:`LayoutView`. .. versionadded:: 2.6 diff --git a/discord/ui/section.py b/discord/ui/section.py index 53d433c3ea77..11de2ec18dc6 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -44,6 +44,8 @@ class Section(Item[V]): """Represents a UI section. + This is a top-level layout component that can only be used on :class:`LayoutView` + .. versionadded:: 2.6 Parameters diff --git a/discord/ui/separator.py b/discord/ui/separator.py index e212f4b4e4e5..48908df9d6eb 100644 --- a/discord/ui/separator.py +++ b/discord/ui/separator.py @@ -42,6 +42,8 @@ class Separator(Item[V]): """Represents a UI separator. + This is a top-level layout component that can only be used on :class:`LayoutView`. + .. versionadded:: 2.6 Parameters diff --git a/discord/ui/text_display.py b/discord/ui/text_display.py index f311cf66c4b3..beff74c6eac3 100644 --- a/discord/ui/text_display.py +++ b/discord/ui/text_display.py @@ -42,6 +42,8 @@ class TextDisplay(Item[V]): """Represents a UI text display. + This is a top-level layout component that can only be used on :class:`LayoutView`. + .. versionadded:: 2.6 Parameters From f5ec966a7b00caf552f10e1524cbfacf4eb19ece Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 22 Apr 2025 23:53:06 +0200 Subject: [PATCH 095/272] chore: typings and docs and idk what more --- discord/abc.py | 42 ++++++++++-- discord/channel.py | 42 +++++++++++- discord/ext/commands/context.py | 52 ++++++++++++-- discord/interactions.py | 118 +++++++++++++++++++++++++++++++- discord/message.py | 64 ++++++++++++++--- discord/ui/view.py | 2 +- discord/webhook/async_.py | 72 +++++++++++++++++-- discord/webhook/sync.py | 44 +++++++++++- 8 files changed, 405 insertions(+), 31 deletions(-) diff --git a/discord/abc.py b/discord/abc.py index 505552684b68..5ea20b558a70 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -95,7 +95,7 @@ ) from .poll import Poll from .threads import Thread - from .ui.view import BaseView + from .ui.view import BaseView, View, LayoutView from .types.channel import ( PermissionOverwrite as PermissionOverwritePayload, Channel as ChannelPayload, @@ -1374,6 +1374,38 @@ class Messageable: async def _get_channel(self) -> MessageableChannel: raise NotImplementedError + @overload + async def send( + self, + *, + file: File = ..., + delete_after: float = ..., + nonce: Union[str, int] = ..., + allowed_mentions: AllowedMentions = ..., + reference: Union[Message, MessageReference, PartialMessage] = ..., + mention_author: bool = ..., + view: LayoutView, + suppress_embeds: bool = ..., + silent: bool = ..., + ) -> Message: + ... + + @overload + async def send( + self, + *, + files: Sequence[File] = ..., + delete_after: float = ..., + nonce: Union[str, int] = ..., + allowed_mentions: AllowedMentions = ..., + reference: Union[Message, MessageReference, PartialMessage] = ..., + mention_author: bool = ..., + view: LayoutView, + suppress_embeds: bool = ..., + silent: bool = ..., + ) -> Message: + ... + @overload async def send( self, @@ -1388,7 +1420,7 @@ async def send( allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: BaseView = ..., + view: View = ..., suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., @@ -1409,7 +1441,7 @@ async def send( allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: BaseView = ..., + view: View = ..., suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., @@ -1430,7 +1462,7 @@ async def send( allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: BaseView = ..., + view: View = ..., suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., @@ -1451,7 +1483,7 @@ async def send( allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: BaseView = ..., + view: View = ..., suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., diff --git a/discord/channel.py b/discord/channel.py index f17a74ca81d5..314c46faec4c 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -100,7 +100,7 @@ from .file import File from .user import ClientUser, User, BaseUser from .guild import Guild, GuildChannel as GuildChannelType - from .ui.view import BaseView + from .ui.view import BaseView, View, LayoutView from .types.channel import ( TextChannel as TextChannelPayload, NewsChannel as NewsChannelPayload, @@ -2841,6 +2841,46 @@ async def create_tag( return result + @overload + async def create_thread( + self, + *, + name: str, + auto_archive_duration: ThreadArchiveDuration = MISSING, + slowmode_delay: Optional[int] = None, + file: File = MISSING, + files: Sequence[File] = MISSING, + allowed_mentions: AllowedMentions = MISSING, + mention_author: bool = MISSING, + view: LayoutView, + suppress_embeds: bool = False, + reason: Optional[str] = None, + ) -> ThreadWithMessage: + ... + + @overload + async def create_thread( + self, + *, + name: str, + auto_archive_duration: ThreadArchiveDuration = MISSING, + slowmode_delay: Optional[int] = None, + content: Optional[str] = None, + tts: bool = False, + embed: Embed = MISSING, + embeds: Sequence[Embed] = MISSING, + file: File = MISSING, + files: Sequence[File] = MISSING, + stickers: Sequence[Union[GuildSticker, StickerItem]] = MISSING, + allowed_mentions: AllowedMentions = MISSING, + mention_author: bool = MISSING, + applied_tags: Sequence[ForumTag] = MISSING, + view: View = MISSING, + suppress_embeds: bool = False, + reason: Optional[str] = None, + ) -> ThreadWithMessage: + ... + async def create_thread( self, *, diff --git a/discord/ext/commands/context.py b/discord/ext/commands/context.py index 931142b38872..1e957feb4f16 100644 --- a/discord/ext/commands/context.py +++ b/discord/ext/commands/context.py @@ -48,7 +48,7 @@ from discord.mentions import AllowedMentions from discord.sticker import GuildSticker, StickerItem from discord.message import MessageReference, PartialMessage - from discord.ui.view import BaseView + from discord.ui.view import BaseView, View, LayoutView from discord.types.interactions import ApplicationCommandInteractionData from discord.poll import Poll @@ -628,6 +628,40 @@ async def send_help(self, *args: Any) -> Any: except CommandError as e: await cmd.on_help_command_error(self, e) + @overload + async def reply( + self, + *, + file: File = ..., + delete_after: float = ..., + nonce: Union[str, int] = ..., + allowed_mentions: AllowedMentions = ..., + reference: Union[Message, MessageReference, PartialMessage] = ..., + mention_author: bool = ..., + view: LayoutView, + suppress_embeds: bool = ..., + ephemeral: bool = ..., + silent: bool = ..., + ) -> Message: + ... + + @overload + async def reply( + self, + *, + files: Sequence[File] = ..., + delete_after: float = ..., + nonce: Union[str, int] = ..., + allowed_mentions: AllowedMentions = ..., + reference: Union[Message, MessageReference, PartialMessage] = ..., + mention_author: bool = ..., + view: LayoutView, + suppress_embeds: bool = ..., + ephemeral: bool = ..., + silent: bool = ..., + ) -> Message: + ... + @overload async def reply( self, @@ -642,7 +676,7 @@ async def reply( allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: BaseView = ..., + view: View = ..., suppress_embeds: bool = ..., ephemeral: bool = ..., silent: bool = ..., @@ -664,7 +698,7 @@ async def reply( allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: BaseView = ..., + view: View = ..., suppress_embeds: bool = ..., ephemeral: bool = ..., silent: bool = ..., @@ -686,7 +720,7 @@ async def reply( allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: BaseView = ..., + view: View = ..., suppress_embeds: bool = ..., ephemeral: bool = ..., silent: bool = ..., @@ -708,7 +742,7 @@ async def reply( allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: BaseView = ..., + view: View = ..., suppress_embeds: bool = ..., ephemeral: bool = ..., silent: bool = ..., @@ -817,6 +851,14 @@ async def defer(self, *, ephemeral: bool = False) -> None: if self.interaction: await self.interaction.response.defer(ephemeral=ephemeral) + @overload + async def send( + self, + *, + view: LayoutView, + ) -> Message: + ... + @overload async def send( self, diff --git a/discord/interactions.py b/discord/interactions.py index 658ec4127887..7b0b9c493787 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -27,7 +27,7 @@ from __future__ import annotations import logging -from typing import Any, Dict, Optional, Generic, TYPE_CHECKING, Sequence, Tuple, Union, List +from typing import Any, Dict, Optional, Generic, TYPE_CHECKING, Sequence, Tuple, Union, List, overload import asyncio import datetime @@ -76,7 +76,7 @@ from .mentions import AllowedMentions from aiohttp import ClientSession from .embeds import Embed - from .ui.view import BaseView + from .ui.view import BaseView, View, LayoutView from .app_commands.models import Choice, ChoiceT from .ui.modal import Modal from .channel import VoiceChannel, StageChannel, TextChannel, ForumChannel, CategoryChannel, DMChannel, GroupChannel @@ -469,6 +469,30 @@ async def original_response(self) -> InteractionMessage: self._original_response = message return message + @overload + async def edit_original_response( + self, + *, + attachments: Sequence[Union[Attachment, File]] = MISSING, + view: LayoutView, + allowed_mentions: Optional[AllowedMentions] = None, + ) -> InteractionMessage: + ... + + @overload + async def edit_original_response( + self, + *, + content: Optional[str] = MISSING, + embeds: Sequence[Embed] = MISSING, + embed: Optional[Embed] = MISSING, + attachments: Sequence[Union[Attachment, File]] = MISSING, + view: Optional[View] = MISSING, + allowed_mentions: Optional[AllowedMentions] = None, + poll: Poll = MISSING, + ) -> InteractionMessage: + ... + async def edit_original_response( self, *, @@ -889,6 +913,41 @@ async def pong(self) -> None: ) self._response_type = InteractionResponseType.pong + @overload + async def send_message( + self, + *, + file: File = MISSING, + files: Sequence[File] = MISSING, + view: LayoutView, + ephemeral: bool = False, + allowed_mentions: AllowedMentions = MISSING, + suppress_embeds: bool = False, + silent: bool = False, + delete_after: Optional[float] = None, + ) -> InteractionCallbackResponse[ClientT]: + ... + + @overload + async def send_message( + self, + content: Optional[Any] = None, + *, + embed: Embed = MISSING, + embeds: Sequence[Embed] = MISSING, + file: File = MISSING, + files: Sequence[File] = MISSING, + view: View = MISSING, + tts: bool = False, + ephemeral: bool = False, + allowed_mentions: AllowedMentions = MISSING, + suppress_embeds: bool = False, + silent: bool = False, + delete_after: Optional[float] = None, + poll: Poll = MISSING, + ) -> InteractionCallbackResponse[ClientT]: + ... + async def send_message( self, content: Optional[Any] = None, @@ -1042,6 +1101,33 @@ async def inner_call(delay: float = delete_after): type=self._response_type, ) + @overload + async def edit_message( + self, + *, + attachments: Sequence[Union[Attachment, File]] = MISSING, + view: LayoutView, + allowed_mentions: Optional[AllowedMentions] = MISSING, + delete_after: Optional[float] = None, + suppress_embeds: bool = MISSING, + ) -> Optional[InteractionCallbackResponse[ClientT]]: + ... + + @overload + async def edit_message( + self, + *, + content: Optional[Any] = MISSING, + embed: Optional[Embed] = MISSING, + embeds: Sequence[Embed] = MISSING, + attachments: Sequence[Union[Attachment, File]] = MISSING, + view: Optional[View] = MISSING, + allowed_mentions: Optional[AllowedMentions] = MISSING, + delete_after: Optional[float] = None, + suppress_embeds: bool = MISSING, + ) -> Optional[InteractionCallbackResponse[ClientT]]: + ... + async def edit_message( self, *, @@ -1333,6 +1419,32 @@ class InteractionMessage(Message): __slots__ = () _state: _InteractionMessageState + @overload + async def edit( + self, + *, + attachments: Sequence[Union[Attachment, File]] = MISSING, + view: LayoutView, + allowed_mentions: Optional[AllowedMentions] = None, + delete_after: Optional[float] = None, + ) -> InteractionMessage: + ... + + @overload + async def edit( + self, + *, + content: Optional[str] = MISSING, + embeds: Sequence[Embed] = MISSING, + embed: Optional[Embed] = MISSING, + attachments: Sequence[Union[Attachment, File]] = MISSING, + view: Optional[View] = MISSING, + allowed_mentions: Optional[AllowedMentions] = None, + delete_after: Optional[float] = None, + poll: Poll = MISSING, + ) -> InteractionMessage: + ... + async def edit( self, *, @@ -1412,7 +1524,7 @@ async def edit( embeds=embeds, embed=embed, attachments=attachments, - view=view, + view=view, # type: ignore allowed_mentions=allowed_mentions, poll=poll, ) diff --git a/discord/message.py b/discord/message.py index e4f19a4dd6bd..f3d364ec81ea 100644 --- a/discord/message.py +++ b/discord/message.py @@ -101,7 +101,7 @@ from .mentions import AllowedMentions from .user import User from .role import Role - from .ui.view import BaseView + from .ui.view import BaseView, View, LayoutView EmojiInputType = Union[Emoji, PartialEmoji, str] @@ -534,7 +534,7 @@ def __init__(self, state: ConnectionState, data: MessageSnapshotPayload): for component_data in data.get('components', []): component = _component_factory(component_data, state) # type: ignore if component is not None: - self.components.append(component) # type: ignore + self.components.append(component) self._state: ConnectionState = state @@ -1302,6 +1302,17 @@ async def delete(delay: float): else: await self._state.http.delete_message(self.channel.id, self.id) + @overload + async def edit( + self, + *, + view: LayoutView, + attachments: Sequence[Union[Attachment, File]] = ..., + delete_after: Optional[float] = ..., + allowed_mentions: Optional[AllowedMentions] = ..., + ) -> Message: + ... + @overload async def edit( self, @@ -1311,7 +1322,7 @@ async def edit( attachments: Sequence[Union[Attachment, File]] = ..., delete_after: Optional[float] = ..., allowed_mentions: Optional[AllowedMentions] = ..., - view: Optional[BaseView] = ..., + view: Optional[View] = ..., ) -> Message: ... @@ -1324,7 +1335,7 @@ async def edit( attachments: Sequence[Union[Attachment, File]] = ..., delete_after: Optional[float] = ..., allowed_mentions: Optional[AllowedMentions] = ..., - view: Optional[BaseView] = ..., + view: Optional[View] = ..., ) -> Message: ... @@ -1387,10 +1398,13 @@ async def edit( are used instead. .. versionadded:: 1.4 - view: Optional[:class:`~discord.ui.View`] + view: Optional[Union[:class:`~discord.ui.View`, :class:`~discord.ui.LayoutView`]] The updated view to update this message with. If ``None`` is passed then the view is removed. + .. versionchanged:: 2.6 + This now accepts :class:`~discord.ui.LayoutView` instances. + Raises ------- HTTPException @@ -1752,6 +1766,38 @@ async def fetch_thread(self) -> Thread: return await self.guild.fetch_channel(self.id) # type: ignore # Can only be Thread in this case + @overload + async def reply( + self, + *, + file: File = ...,g + view: LayoutView, + delete_after: float = ..., + nonce: Union[str, int] = ..., + allowed_mentions: AllowedMentions = ..., + reference: Union[Message, MessageReference, PartialMessage] = ..., + mention_author: bool = ..., + suppress_embeds: bool = ..., + silent: bool = ..., + ) -> Message: + ... + + @overload + async def reply( + self, + *, + files: Sequence[File] = ..., + view: LayoutView, + delete_after: float = ..., + nonce: Union[str, int] = ..., + allowed_mentions: AllowedMentions = ..., + reference: Union[Message, MessageReference, PartialMessage] = ..., + mention_author: bool = ..., + suppress_embeds: bool = ..., + silent: bool = ..., + ) -> Message: + ... + @overload async def reply( self, @@ -1766,7 +1812,7 @@ async def reply( allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: BaseView = ..., + view: View = ..., suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., @@ -1787,7 +1833,7 @@ async def reply( allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: BaseView = ..., + view: View = ..., suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., @@ -1808,7 +1854,7 @@ async def reply( allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: BaseView = ..., + view: View = ..., suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., @@ -1829,7 +1875,7 @@ async def reply( allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: BaseView = ..., + view: View = ..., suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., diff --git a/discord/ui/view.py b/discord/ui/view.py index 61abd98757ef..e1f53bd8eb8d 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -708,7 +708,7 @@ class LayoutView(BaseView): This object must be inherited to create a UI within Discord. - + .. versionadded:: 2.6 diff --git a/discord/webhook/async_.py b/discord/webhook/async_.py index c2c40ada5980..897ddadd377c 100644 --- a/discord/webhook/async_.py +++ b/discord/webhook/async_.py @@ -71,7 +71,7 @@ from ..emoji import Emoji from ..channel import VoiceChannel from ..abc import Snowflake - from ..ui.view import BaseView + from ..ui.view import BaseView, View, LayoutView from ..poll import Poll import datetime from ..types.webhook import ( @@ -1605,6 +1605,44 @@ def _create_message(self, data, *, thread: Snowflake): # state is artificial return WebhookMessage(data=data, state=state, channel=channel) # type: ignore + @overload + async def send( + self, + *, + username: str = MISSING, + avatar_url: Any = MISSING, + file: File = MISSING, + files: Sequence[File] = MISSING, + allowed_mentions: AllowedMentions = MISSING, + view: LayoutView, + wait: Literal[True], + thread: Snowflake = MISSING, + thread_name: str = MISSING, + suppress_embeds: bool = MISSING, + silent: bool = MISSING, + applied_tags: List[ForumTag] = MISSING, + ) -> WebhookMessage: + ... + + @overload + async def send( + self, + *, + username: str = MISSING, + avatar_url: Any = MISSING, + file: File = MISSING, + files: Sequence[File] = MISSING, + allowed_mentions: AllowedMentions = MISSING, + view: LayoutView, + wait: Literal[False] = ..., + thread: Snowflake = MISSING, + thread_name: str = MISSING, + suppress_embeds: bool = MISSING, + silent: bool = MISSING, + applied_tags: List[ForumTag] = MISSING, + ) -> None: + ... + @overload async def send( self, @@ -1619,7 +1657,7 @@ async def send( embed: Embed = MISSING, embeds: Sequence[Embed] = MISSING, allowed_mentions: AllowedMentions = MISSING, - view: BaseView = MISSING, + view: View = MISSING, thread: Snowflake = MISSING, thread_name: str = MISSING, wait: Literal[True], @@ -1644,7 +1682,7 @@ async def send( embed: Embed = MISSING, embeds: Sequence[Embed] = MISSING, allowed_mentions: AllowedMentions = MISSING, - view: BaseView = MISSING, + view: View = MISSING, thread: Snowflake = MISSING, thread_name: str = MISSING, wait: Literal[False] = ..., @@ -1940,6 +1978,30 @@ async def fetch_message(self, id: int, /, *, thread: Snowflake = MISSING) -> Web ) return self._create_message(data, thread=thread) + @overload + async def edit_message( + self, + message_id: int, + *, + view: LayoutView, + ) -> WebhookMessage: + ... + + @overload + async def edit_message( + self, + message_id: int, + *, + content: Optional[str] = MISSING, + embeds: Sequence[Embed] = MISSING, + embed: Optional[Embed] = MISSING, + attachments: Sequence[Union[Attachment, File]] = MISSING, + view: Optional[View] = MISSING, + allowed_mentions: Optional[AllowedMentions] = None, + thread: Snowflake = MISSING, + ) -> WebhookMessage: + ... + async def edit_message( self, message_id: int, @@ -1987,12 +2049,14 @@ async def edit_message( allowed_mentions: :class:`AllowedMentions` Controls the mentions being processed in this message. See :meth:`.abc.Messageable.send` for more information. - view: Optional[:class:`~discord.ui.View`] + view: Optional[Union[:class:`~discord.ui.View`, :class:`~discord.ui.LayoutView`]] The updated view to update this message with. If ``None`` is passed then the view is removed. The webhook must have state attached, similar to :meth:`send`. .. versionadded:: 2.0 + .. versionchanged:: 2.6 + This now accepts :class:`~discord.ui.LayoutView` instances. thread: :class:`~discord.abc.Snowflake` The thread the webhook message belongs to. diff --git a/discord/webhook/sync.py b/discord/webhook/sync.py index 90459776102f..9c211898d812 100644 --- a/discord/webhook/sync.py +++ b/discord/webhook/sync.py @@ -66,7 +66,7 @@ from ..message import Attachment from ..abc import Snowflake from ..state import ConnectionState - from ..ui.view import BaseView + from ..ui.view import BaseView, View, LayoutView from ..types.webhook import ( Webhook as WebhookPayload, ) @@ -856,6 +856,44 @@ def _create_message(self, data: MessagePayload, *, thread: Snowflake = MISSING) # state is artificial return SyncWebhookMessage(data=data, state=state, channel=channel) # type: ignore + @overload + def send( + self, + *, + username: str = MISSING, + avatar_url: Any = MISSING, + file: File = MISSING, + files: Sequence[File] = MISSING, + allowed_mentions: AllowedMentions = MISSING, + view: LayoutView, + wait: Literal[True], + thread: Snowflake = MISSING, + thread_name: str = MISSING, + suppress_embeds: bool = MISSING, + silent: bool = MISSING, + applied_tags: List[ForumTag] = MISSING, + ) -> SyncWebhookMessage: + ... + + @overload + def send( + self, + *, + username: str = MISSING, + avatar_url: Any = MISSING, + file: File = MISSING, + files: Sequence[File] = MISSING, + allowed_mentions: AllowedMentions = MISSING, + view: LayoutView, + wait: Literal[False] = ..., + thread: Snowflake = MISSING, + thread_name: str = MISSING, + suppress_embeds: bool = MISSING, + silent: bool = MISSING, + applied_tags: List[ForumTag] = MISSING, + ) -> None: + ... + @overload def send( self, @@ -876,7 +914,7 @@ def send( silent: bool = MISSING, applied_tags: List[ForumTag] = MISSING, poll: Poll = MISSING, - view: BaseView = MISSING, + view: View = MISSING, ) -> SyncWebhookMessage: ... @@ -900,7 +938,7 @@ def send( silent: bool = MISSING, applied_tags: List[ForumTag] = MISSING, poll: Poll = MISSING, - view: BaseView = MISSING, + view: View = MISSING, ) -> None: ... From 0dbd46529ac83a9d81e97312320000e6b9dfeb53 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 22 Apr 2025 23:53:19 +0200 Subject: [PATCH 096/272] fix: g --- discord/message.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/message.py b/discord/message.py index f3d364ec81ea..f6fba87cba59 100644 --- a/discord/message.py +++ b/discord/message.py @@ -1770,7 +1770,7 @@ async def fetch_thread(self) -> Thread: async def reply( self, *, - file: File = ...,g + file: File = ..., view: LayoutView, delete_after: float = ..., nonce: Union[str, int] = ..., From af952d3066c04c8017f7f718bca5225e87372285 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 22 Apr 2025 23:55:34 +0200 Subject: [PATCH 097/272] chore: add LayoutView example --- examples/views/layout.py | 49 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 examples/views/layout.py diff --git a/examples/views/layout.py b/examples/views/layout.py new file mode 100644 index 000000000000..08d43c1af3e2 --- /dev/null +++ b/examples/views/layout.py @@ -0,0 +1,49 @@ +# This example requires the 'message_content' privileged intent to function. + +from discord.ext import commands + +import discord + + +class Bot(commands.Bot): + def __init__(self): + intents = discord.Intents.default() + intents.message_content = True + + super().__init__(command_prefix=commands.when_mentioned_or('$'), intents=intents) + + async def on_ready(self): + print(f'Logged in as {self.user} (ID: {self.user.id})') + print('------') + + +# Define a LayoutView, which will allow us to add v2 components to it. +class Layout(discord.ui.LayoutView): + # you can add any top-level component (ui.ActionRow, ui.Section, ui.Container, ui.File, etc.) here + + action_row = discord.ui.ActionRow() + + @action_row.button(label='Click Me!') + async def action_row_button(self, interaction: discord.Interaction, button: discord.ui.Button): + await interaction.response.send_message('Hi!', ephemeral=True) + + container = discord.ui.Container( + [ + discord.ui.TextDisplay( + 'Click the above button to receive a **very special** message!', + ), + ], + accent_colour=discord.Colour.blurple(), + ) + + +bot = Bot() + + +@bot.command() +async def layout(ctx: commands.Context): + """Sends a very special message!""" + await ctx.send(view=Layout()) # sending LayoutView's does not allow for sending any other content + + +bot.run('token') From f5415f5c59191bb2fdf2e6764286cfeca573630b Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Wed, 23 Apr 2025 02:00:14 +0200 Subject: [PATCH 098/272] chore: remove deprecation warning --- discord/ui/view.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index e1f53bd8eb8d..32dbfa167458 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -571,8 +571,6 @@ class View(BaseView): This object must be inherited to create a UI within Discord. .. versionadded:: 2.0 - .. deprecated:: 2.6 - This class is deprecated and will be removed in a future version. Use :class:`LayoutView` instead. Parameters ----------- @@ -584,11 +582,6 @@ class View(BaseView): __discord_ui_view__: ClassVar[bool] = True def __init_subclass__(cls) -> None: - warnings.warn( - 'discord.ui.View and subclasses are deprecated and will be removed in ' - 'a future version, use discord.ui.LayoutView instead', - DeprecationWarning, - ) super().__init_subclass__() children: Dict[str, ItemCallbackType[Any]] = {} From 952a623d232b90e53b35d12050ae999cd8143422 Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Wed, 23 Apr 2025 02:05:49 +0200 Subject: [PATCH 099/272] remove unused import --- discord/ui/view.py | 1 - 1 file changed, 1 deletion(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index 32dbfa167458..4fd528ee3e72 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -24,7 +24,6 @@ from __future__ import annotations -import warnings from typing import ( Any, Callable, From c5d7450d86d5a6f6d2dafd80e056a57f351836c7 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Wed, 23 Apr 2025 19:58:21 +0200 Subject: [PATCH 100/272] fix: strange error: https://discord.com/channels/336642139381301249/1345167602304946206/1364416832584421486 --- discord/ui/container.py | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index a90df10b93f0..4c8b254e13e6 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -23,6 +23,7 @@ """ from __future__ import annotations +import copy from typing import TYPE_CHECKING, Any, ClassVar, Coroutine, Dict, List, Literal, Optional, Tuple, Type, TypeVar, Union from .item import Item, ItemCallbackType @@ -116,7 +117,7 @@ class MyView(ui.LayoutView): The ID of this component. This must be unique across the view. """ - __container_children_items__: ClassVar[List[Union[ItemCallbackType[Any], Item[Any]]]] = [] + __container_children_items__: ClassVar[Dict[str, Union[ItemCallbackType[Any], Item[Any]]]] = {} __discord_ui_update_view__: ClassVar[bool] = True __discord_ui_container__: ClassVar[bool] = True @@ -148,16 +149,28 @@ def __init__( def _init_children(self) -> List[Item[Any]]: children = [] + parents = {} - for raw in self.__container_children_items__: + for name, raw in self.__container_children_items__.items(): if isinstance(raw, Item): - children.append(raw) + if getattr(raw, '__discord_ui_action_row__', False): + item = copy.deepcopy(raw) + # we need to deepcopy this object and set it later to prevent + # errors reported on the bikeshedding post + item._parent = self - if getattr(raw, '__discord_ui_section__', False) and raw.accessory.is_dispatchable(): # type: ignore - self.__dispatchable.append(raw.accessory) # type: ignore - elif getattr(raw, '__discord_ui_action_row__', False) and raw.is_dispatchable(): - raw._parent = self - self.__dispatchable.extend(raw._children) # type: ignore + if item.is_dispatchable(): + self.__dispatchable.extend(item._children) # type: ignore + else: + item = raw + + if getattr(item, '__discord_ui_section__', False) and item.accessory.is_dispatchable(): # type: ignore + self.__dispatchable.append(item.accessory) # type: ignore + + setattr(self, name, item) + children.append(item) + + parents[raw] = item else: # action rows can be created inside containers, and then callbacks can exist here # so we create items based off them @@ -170,7 +183,7 @@ def _init_children(self) -> List[Item[Any]]: parent = getattr(raw, '__discord_ui_parent__', None) if parent is None: raise RuntimeError(f'{raw.__name__} is not a valid item for a Container') - parent._children.append(item) + parents.get(parent, parent)._children.append(item) # we donnot append it to the children list because technically these buttons and # selects are not from the container but the action row itself. self.__dispatchable.append(item) @@ -189,9 +202,9 @@ def __init_subclass__(cls) -> None: if isinstance(member, Item): children[name] = member if hasattr(member, '__discord_ui_model_type__') and getattr(member, '__discord_ui_parent__', None): - children[name] = member + children[name] = copy.copy(member) - cls.__container_children_items__ = list(children.values()) + cls.__container_children_items__ = children def _update_children_view(self, view) -> None: for child in self._children: From dbd8cd6cd33f3e17ef961d275a14b75609e8a6a0 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Wed, 23 Apr 2025 21:33:37 +0200 Subject: [PATCH 101/272] chore: update container and things --- discord/ui/container.py | 9 ++++++++- discord/ui/section.py | 9 ++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index 4c8b254e13e6..c9222262b4d3 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -24,6 +24,7 @@ from __future__ import annotations import copy +import os from typing import TYPE_CHECKING, Any, ClassVar, Coroutine, Dict, List, Literal, Optional, Tuple, Type, TypeVar, Union from .item import Item, ItemCallbackType @@ -161,8 +162,14 @@ def _init_children(self) -> List[Item[Any]]: if item.is_dispatchable(): self.__dispatchable.extend(item._children) # type: ignore + if getattr(raw, '__discord_ui_section__', False): + item = copy.copy(raw) + if item.accessory.is_dispatchable(): # type: ignore + item.accessory = copy.deepcopy(item.accessory) # type: ignore + if item.accessory._provided_custom_id is False: # type: ignore + item.accessory.custom_id = os.urandom(16).hex() # type: ignore else: - item = raw + item = copy.copy(raw) if getattr(item, '__discord_ui_section__', False) and item.accessory.is_dispatchable(): # type: ignore self.__dispatchable.append(item.accessory) # type: ignore diff --git a/discord/ui/section.py b/discord/ui/section.py index 11de2ec18dc6..5d922a51a3b3 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -75,22 +75,21 @@ class Section(Item[V]): def __init__( self, - children: List[Union[Item[Any], str]] = MISSING, + children: List[Union[Item[V], str]] = MISSING, *, - accessory: Item[Any], + accessory: Item[V], row: Optional[int] = None, id: Optional[int] = None, ) -> None: super().__init__() - self._children: List[Item[Any]] = [] + self._children: List[Item[V]] = [] if children is not MISSING: if len(children) > 3: raise ValueError('maximum number of children exceeded') self._children.extend( [c if isinstance(c, Item) else TextDisplay(c) for c in children], ) - self.accessory: Item[Any] = accessory - + self.accessory: Item[V] = accessory self.row = row self.id = id From 7ed69ec7e514c9ecdc4dbbe7293b7750fa281038 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Wed, 23 Apr 2025 21:52:01 +0200 Subject: [PATCH 102/272] chore: children, * -> *children --- discord/ui/container.py | 3 +-- discord/ui/media_gallery.py | 5 ++--- discord/ui/section.py | 3 +-- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index c9222262b4d3..a804d1a91af2 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -124,8 +124,7 @@ class MyView(ui.LayoutView): def __init__( self, - children: List[Item[V]] = MISSING, - *, + *children: Item[V], accent_colour: Optional[Union[Colour, int]] = None, accent_color: Optional[Union[Color, int]] = None, spoiler: bool = False, diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index e3db92215470..b86bbd0b4e4d 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -68,15 +68,14 @@ class MediaGallery(Item[V]): def __init__( self, - items: List[MediaGalleryItem], - *, + *items: MediaGalleryItem, row: Optional[int] = None, id: Optional[int] = None, ) -> None: super().__init__() self._underlying = MediaGalleryComponent._raw_construct( - items=items, + items=list(items), id=id, ) diff --git a/discord/ui/section.py b/discord/ui/section.py index 5d922a51a3b3..ca4f1f2aa347 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -75,8 +75,7 @@ class Section(Item[V]): def __init__( self, - children: List[Union[Item[V], str]] = MISSING, - *, + *children: Union[Item[V], str], accessory: Item[V], row: Optional[int] = None, id: Optional[int] = None, From 95a22ced02815ea97b335fe7054e57db7858e2e0 Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 24 Apr 2025 22:43:46 +0200 Subject: [PATCH 103/272] . --- discord/ui/container.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index a804d1a91af2..617450773a4b 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -291,7 +291,7 @@ def _update_store_data( @classmethod def from_component(cls, component: ContainerComponent) -> Self: return cls( - children=[_component_to_item(c) for c in component.children], + *[_component_to_item(c) for c in component.children], accent_colour=component.accent_colour, spoiler=component.spoiler, id=component.id, From 776d5e173a4ba76fdc626397a7c99deaa31e7d70 Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 24 Apr 2025 22:44:10 +0200 Subject: [PATCH 104/272] unpack --- discord/ui/section.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/section.py b/discord/ui/section.py index ca4f1f2aa347..355beebc9161 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -197,7 +197,7 @@ def from_component(cls, component: SectionComponent) -> Self: from .view import _component_to_item # >circular import< return cls( - children=[_component_to_item(c) for c in component.components], + *[_component_to_item(c) for c in component.components], accessory=_component_to_item(component.accessory), id=component.id, ) From 038ca4a09c76c7c35118b536d5c44f2098e642b4 Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 24 Apr 2025 22:44:38 +0200 Subject: [PATCH 105/272] more unpack --- discord/ui/media_gallery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index b86bbd0b4e4d..badd495e0802 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -184,6 +184,6 @@ def width(self): @classmethod def from_component(cls, component: MediaGalleryComponent) -> Self: return cls( - items=component.items, + *component.items, id=component.id, ) From ab497987ac36d0928252b3766a18c60dff45a66c Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Fri, 25 Apr 2025 21:38:27 +0200 Subject: [PATCH 106/272] chore: Update examples and things --- discord/ui/view.py | 2 +- examples/views/layout.py | 10 ++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index 4fd528ee3e72..a131414327e3 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -700,7 +700,7 @@ class LayoutView(BaseView): This object must be inherited to create a UI within Discord. - + You can find usage examples in the :resource:`repository ` .. versionadded:: 2.6 diff --git a/examples/views/layout.py b/examples/views/layout.py index 08d43c1af3e2..70effc30cd31 100644 --- a/examples/views/layout.py +++ b/examples/views/layout.py @@ -28,11 +28,9 @@ async def action_row_button(self, interaction: discord.Interaction, button: disc await interaction.response.send_message('Hi!', ephemeral=True) container = discord.ui.Container( - [ - discord.ui.TextDisplay( - 'Click the above button to receive a **very special** message!', - ), - ], + discord.ui.TextDisplay( + 'Click the above button to receive a **very special** message!', + ), accent_colour=discord.Colour.blurple(), ) @@ -43,7 +41,7 @@ async def action_row_button(self, interaction: discord.Interaction, button: disc @bot.command() async def layout(ctx: commands.Context): """Sends a very special message!""" - await ctx.send(view=Layout()) # sending LayoutView's does not allow for sending any other content + await ctx.send(view=Layout()) # sending LayoutView's does not allow for sending any content, embed(s), stickers, or poll bot.run('token') From 5a1afb637ffa2a1b2e22adcec9562d2c982ae8e6 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Fri, 25 Apr 2025 21:41:47 +0200 Subject: [PATCH 107/272] chore: Update message.component doc types --- discord/message.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/message.py b/discord/message.py index f6fba87cba59..0057b06f8737 100644 --- a/discord/message.py +++ b/discord/message.py @@ -488,7 +488,7 @@ class MessageSnapshot: Extra features of the the message snapshot. stickers: List[:class:`StickerItem`] A list of sticker items given to the message. - components: List[Union[:class:`ActionRow`, :class:`Button`, :class:`SelectMenu`]] + components: List[Union[:class:`ActionRow`, :class:`Button`, :class:`SelectMenu`, :class:`Container`, :class:`SectionComponent`, :class:`TextDisplay`, :class:`MediaGalleryComponent`, :class:`FileComponent`, :class:`SeparatorComponent`, :class:`ThumbnailComponent`]] A list of components in the message. """ From de4d8c489d95333f311a755b37a418a9a6074763 Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 26 Apr 2025 13:40:13 +0200 Subject: [PATCH 108/272] =?UTF-8?q?fix:=20LayoutView=E2=80=99s=20duplicati?= =?UTF-8?q?ng=20items?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- discord/ui/view.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index a131414327e3..a3ec58928aa5 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -47,6 +47,7 @@ import sys import time import os +import copy from .item import Item, ItemCallbackType from .dynamic import DynamicItem from ..components import ( @@ -197,7 +198,7 @@ class BaseView: __discord_ui_view__: ClassVar[bool] = False __discord_ui_modal__: ClassVar[bool] = False __discord_ui_container__: ClassVar[bool] = False - __view_children_items__: ClassVar[List[ItemLike]] = [] + __view_children_items__: ClassVar[Dict[str, ItemLike]] = [] def __init__(self, *, timeout: Optional[float] = 180.0) -> None: self.__timeout = timeout @@ -218,15 +219,17 @@ def __repr__(self) -> str: def _init_children(self) -> List[Item[Self]]: children = [] - for raw in self.__view_children_items__: + for name, raw in self.__view_children_items__.items(): if isinstance(raw, Item): - raw._view = self - parent = getattr(raw, '__discord_ui_parent__', None) + item = copy.deepcopy(raw) + setattr(self, name, item) + item._view = self + parent = getattr(item, '__discord_ui_parent__', None) if parent and parent._view is None: parent._view = self - if getattr(raw, '__discord_ui_update_view__', False): - raw._update_children_view(self) # type: ignore - children.append(raw) + if getattr(item, '__discord_ui_update_view__', False): + item._update_children_view(self) # type: ignore + children.append(item) else: item: Item = raw.__discord_ui_model_type__(**raw.__discord_ui_model_kwargs__) item.callback = _ViewCallback(raw, self, item) # type: ignore @@ -594,7 +597,7 @@ def __init_subclass__(cls) -> None: if len(children) > 25: raise TypeError('View cannot have more than 25 children') - cls.__view_children_items__ = list(children.values()) + cls.__view_children_items__ = children def __init__(self, *, timeout: Optional[float] = 180.0): super().__init__(timeout=timeout) @@ -715,7 +718,7 @@ def __init__(self, *, timeout: Optional[float] = 180.0) -> None: super().__init__(timeout=timeout) def __init_subclass__(cls) -> None: - children: Dict[str, Item[Any]] = {} + children: Dict[str, ItemLike] = {} callback_children: Dict[str, ItemCallbackType[Any]] = {} row = 0 @@ -732,7 +735,8 @@ def __init_subclass__(cls) -> None: if len(children) > 10: raise TypeError('LayoutView cannot have more than 10 top-level children') - cls.__view_children_items__ = list(children.values()) + list(callback_children.values()) + children.update(callback_children) + cls.__view_children_items__ = children def _is_v2(self) -> bool: return True From 5162d17d4aa85433386006930b895768e7618d3e Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 26 Apr 2025 14:22:18 +0200 Subject: [PATCH 109/272] fix typings and errors --- discord/ui/view.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index a3ec58928aa5..2ece514d2d2a 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -218,6 +218,7 @@ def __repr__(self) -> str: def _init_children(self) -> List[Item[Self]]: children = [] + parents = {} for name, raw in self.__view_children_items__.items(): if isinstance(raw, Item): @@ -230,6 +231,7 @@ def _init_children(self) -> List[Item[Self]]: if getattr(item, '__discord_ui_update_view__', False): item._update_children_view(self) # type: ignore children.append(item) + parents[raw] = item else: item: Item = raw.__discord_ui_model_type__(**raw.__discord_ui_model_kwargs__) item.callback = _ViewCallback(raw, self, item) # type: ignore @@ -237,7 +239,7 @@ def _init_children(self) -> List[Item[Self]]: setattr(self, raw.__name__, item) parent = getattr(raw, '__discord_ui_parent__', None) if parent: - parent._children.append(item) + parents.get(parent, parent)._children.append(item) continue children.append(item) @@ -586,7 +588,7 @@ class View(BaseView): def __init_subclass__(cls) -> None: super().__init_subclass__() - children: Dict[str, ItemCallbackType[Any]] = {} + children: Dict[str, ItemLike] = {} for base in reversed(cls.__mro__): for name, member in base.__dict__.items(): if hasattr(member, '__discord_ui_model_type__'): From a8285e1931f1ffdfd25cc93a775c826368a21012 Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 26 Apr 2025 14:22:57 +0200 Subject: [PATCH 110/272] more typings --- discord/ui/view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index 2ece514d2d2a..cb82c8cfdea6 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -198,7 +198,7 @@ class BaseView: __discord_ui_view__: ClassVar[bool] = False __discord_ui_modal__: ClassVar[bool] = False __discord_ui_container__: ClassVar[bool] = False - __view_children_items__: ClassVar[Dict[str, ItemLike]] = [] + __view_children_items__: ClassVar[Dict[str, ItemLike]] = {} def __init__(self, *, timeout: Optional[float] = 180.0) -> None: self.__timeout = timeout From aa41094cc1c5c219f847079b8517d997638fbd36 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 29 Apr 2025 20:44:20 +0200 Subject: [PATCH 111/272] fix: Non-dispatchable items breaking persistent views --- discord/ui/item.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/discord/ui/item.py b/discord/ui/item.py index 47a31633bd0b..87cfc368133f 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -112,7 +112,9 @@ def is_dispatchable(self) -> bool: return False def is_persistent(self) -> bool: - return self._provided_custom_id + if self.is_dispatchable(): + return self._provided_custom_id + return True def __repr__(self) -> str: attrs = ' '.join(f'{key}={getattr(self, key)!r}' for key in self.__item_repr_attributes__) From 7741166e86b0ce96aa84a2ba250185fec4692689 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 29 Apr 2025 20:59:12 +0200 Subject: [PATCH 112/272] chore: Add (View|Container|ActionRow|Section).walk_children methods --- discord/ui/action_row.py | 14 ++++++++++++++ discord/ui/container.py | 19 ++++++++++++++++++- discord/ui/section.py | 21 ++++++++++++++++++++- discord/ui/view.py | 24 +++++++++++++++++++++++- 4 files changed, 75 insertions(+), 3 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 22df88f85618..946b6d3b9e14 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -30,6 +30,7 @@ ClassVar, Coroutine, Dict, + Generator, List, Literal, Optional, @@ -204,6 +205,19 @@ def children(self) -> List[Item[V]]: """List[:class:`Item`]: The list of children attached to this action row.""" return self._children.copy() + def walk_children(self) -> Generator[Item[V], Any, None]: + """An iterator that recursively walks through all the children of this view + and it's children, if applicable. + + Yields + ------ + :class:`Item` + An item in the action row. + """ + + for child in self.children: + yield child + def add_item(self, item: Item[Any]) -> Self: """Adds an item to this row. diff --git a/discord/ui/container.py b/discord/ui/container.py index 617450773a4b..0f362f898650 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -25,7 +25,7 @@ import copy import os -from typing import TYPE_CHECKING, Any, ClassVar, Coroutine, Dict, List, Literal, Optional, Tuple, Type, TypeVar, Union +from typing import TYPE_CHECKING, Any, ClassVar, Coroutine, Dict, Generator, List, Literal, Optional, Tuple, Type, TypeVar, Union from .item import Item, ItemCallbackType from .view import _component_to_item, LayoutView @@ -297,6 +297,23 @@ def from_component(cls, component: ContainerComponent) -> Self: id=component.id, ) + def walk_children(self) -> Generator[Item[V], Any, None]: + """An iterator that recursively walks through all the children of this container + and it's children, if applicable. + + Yields + ------ + :class:`Item` + An item in the container. + """ + + for child in self.children: + yield child + + if getattr(child, '__discord_ui_update_view__', False): + # if it has this attribute then it can contain children + yield from child.walk_children() # type: ignore + def add_item(self, item: Item[Any]) -> Self: """Adds an item to this container. diff --git a/discord/ui/section.py b/discord/ui/section.py index 355beebc9161..3f91514c7d8e 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -23,7 +23,7 @@ """ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, TypeVar, Union, ClassVar +from typing import TYPE_CHECKING, Any, Dict, Generator, List, Literal, Optional, TypeVar, Union, ClassVar from .item import Item from .text_display import TextDisplay @@ -96,6 +96,11 @@ def __init__( def type(self) -> Literal[ComponentType.section]: return ComponentType.section + @property + def children(self) -> List[Item[V]]: + """List[:class:`Item`]: The list of children attached to this section.""" + return self._children.copy() + @property def width(self): return 5 @@ -110,6 +115,20 @@ def _is_v2(self) -> bool: def is_dispatchable(self) -> bool: return self.accessory.is_dispatchable() + def walk_children(self) -> Generator[Item[V], Any, None]: + """An iterator that recursively walks through all the children of this section. + and it's children, if applicable. + + Yields + ------ + :class:`Item` + An item in this section. + """ + + for child in self.children: + yield child + yield self.accessory + def _update_children_view(self, view) -> None: self.accessory._view = view diff --git a/discord/ui/view.py b/discord/ui/view.py index cb82c8cfdea6..cbb28e0c8b07 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -638,6 +638,11 @@ def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) In order to modify and edit message components they must be converted into a :class:`View` first. + .. warning:: + + This **will not** take into account every v2 component, if you + want to edit them, use :meth:`LayoutView.from_message` instead. + Parameters ----------- message: :class:`discord.Message` @@ -770,7 +775,7 @@ def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) In order to modify and edit message components they must be converted into a :class:`LayoutView` first. - Unlike :meth:`View.from_message` this works for + Unlike :meth:`View.from_message` this converts v2 components. Parameters ----------- @@ -793,6 +798,23 @@ def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) return view + def walk_children(self): + """An iterator that recursively walks through all the children of this view + and it's children, if applicable. + + Yields + ------ + :class:`Item` + An item in the view. + """ + + for child in self.children: + yield child + + if getattr(child, '__discord_ui_update_view__', False): + # if it has this attribute then it can contain children + yield from child.walk_children() # type: ignore + class ViewStore: def __init__(self, state: ConnectionState): From 0621b38b1127c4e6215656dd4d088b048557ac99 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 29 Apr 2025 21:13:57 +0200 Subject: [PATCH 113/272] chore: Update overloads typings --- discord/channel.py | 46 +++++++++++++++++++-------------------- discord/webhook/async_.py | 17 +++++++++------ discord/webhook/sync.py | 37 ++++++++++++++++++++++++++++++- 3 files changed, 69 insertions(+), 31 deletions(-) diff --git a/discord/channel.py b/discord/channel.py index 314c46faec4c..168eee1f4ad6 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -2846,15 +2846,15 @@ async def create_thread( self, *, name: str, - auto_archive_duration: ThreadArchiveDuration = MISSING, - slowmode_delay: Optional[int] = None, - file: File = MISSING, - files: Sequence[File] = MISSING, - allowed_mentions: AllowedMentions = MISSING, - mention_author: bool = MISSING, + auto_archive_duration: ThreadArchiveDuration = ..., + slowmode_delay: Optional[int] = ..., + file: File = ..., + files: Sequence[File] = ..., + allowed_mentions: AllowedMentions = ..., + mention_author: bool = ..., view: LayoutView, - suppress_embeds: bool = False, - reason: Optional[str] = None, + suppress_embeds: bool = ..., + reason: Optional[str] = ..., ) -> ThreadWithMessage: ... @@ -2863,21 +2863,21 @@ async def create_thread( self, *, name: str, - auto_archive_duration: ThreadArchiveDuration = MISSING, - slowmode_delay: Optional[int] = None, - content: Optional[str] = None, - tts: bool = False, - embed: Embed = MISSING, - embeds: Sequence[Embed] = MISSING, - file: File = MISSING, - files: Sequence[File] = MISSING, - stickers: Sequence[Union[GuildSticker, StickerItem]] = MISSING, - allowed_mentions: AllowedMentions = MISSING, - mention_author: bool = MISSING, - applied_tags: Sequence[ForumTag] = MISSING, - view: View = MISSING, - suppress_embeds: bool = False, - reason: Optional[str] = None, + auto_archive_duration: ThreadArchiveDuration = ..., + slowmode_delay: Optional[int] = ..., + content: Optional[str] = ..., + tts: bool = ..., + embed: Embed = ..., + embeds: Sequence[Embed] = ..., + file: File = ..., + files: Sequence[File] = ..., + stickers: Sequence[Union[GuildSticker, StickerItem]] = ..., + allowed_mentions: AllowedMentions = ..., + mention_author: bool = ..., + applied_tags: Sequence[ForumTag] = ..., + view: View = ..., + suppress_embeds: bool = ..., + reason: Optional[str] = ..., ) -> ThreadWithMessage: ... diff --git a/discord/webhook/async_.py b/discord/webhook/async_.py index 897ddadd377c..df6055d9c520 100644 --- a/discord/webhook/async_.py +++ b/discord/webhook/async_.py @@ -1983,7 +1983,10 @@ async def edit_message( self, message_id: int, *, + attachments: Sequence[Union[Attachment, File]] = ..., view: LayoutView, + allowed_mentions: Optional[AllowedMentions] = ..., + thread: Snowflake = ..., ) -> WebhookMessage: ... @@ -1992,13 +1995,13 @@ async def edit_message( self, message_id: int, *, - content: Optional[str] = MISSING, - embeds: Sequence[Embed] = MISSING, - embed: Optional[Embed] = MISSING, - attachments: Sequence[Union[Attachment, File]] = MISSING, - view: Optional[View] = MISSING, - allowed_mentions: Optional[AllowedMentions] = None, - thread: Snowflake = MISSING, + content: Optional[str] = ..., + embeds: Sequence[Embed] = ..., + embed: Optional[Embed] = ..., + attachments: Sequence[Union[Attachment, File]] = ..., + view: Optional[View] = ..., + allowed_mentions: Optional[AllowedMentions] = ..., + thread: Snowflake = ..., ) -> WebhookMessage: ... diff --git a/discord/webhook/sync.py b/discord/webhook/sync.py index 9c211898d812..d5295c1fc0a6 100644 --- a/discord/webhook/sync.py +++ b/discord/webhook/sync.py @@ -1035,7 +1035,7 @@ def send( .. versionadded:: 2.4 view: Union[:class:`~discord.ui.View`, :class:`~discord.ui.LayoutView`] - The view to send with the message. This can only have URL buttons, which donnot + The view to send with the message. This can only have non-interactible items, which donnot require a state to be attached to it. If you want to send a view with any component attached to it, check :meth:`Webhook.send`. @@ -1185,6 +1185,33 @@ def fetch_message(self, id: int, /, *, thread: Snowflake = MISSING) -> SyncWebho ) return self._create_message(data, thread=thread) + @overload + def edit_message( + self, + message_id: int, + *, + attachments: Sequence[Union[Attachment, File]] = ..., + view: LayoutView, + allowed_mentions: Optional[AllowedMentions] = ..., + thread: Snowflake = ..., + ) -> SyncWebhookMessage: + ... + + @overload + def edit_message( + self, + message_id: int, + *, + content: Optional[str] = ..., + embeds: Sequence[Embed] = ..., + embed: Optional[Embed] = ..., + attachments: Sequence[Union[Attachment, File]] = ..., + view: Optional[View] = ..., + allowed_mentions: Optional[AllowedMentions] = ..., + thread: Snowflake = ..., + ) -> SyncWebhookMessage: + ... + def edit_message( self, message_id: int, @@ -1193,6 +1220,7 @@ def edit_message( embeds: Sequence[Embed] = MISSING, embed: Optional[Embed] = MISSING, attachments: Sequence[Union[Attachment, File]] = MISSING, + view: Optional[BaseView] = MISSING, allowed_mentions: Optional[AllowedMentions] = None, thread: Snowflake = MISSING, ) -> SyncWebhookMessage: @@ -1219,6 +1247,13 @@ def edit_message( then all attachments are removed. .. versionadded:: 2.0 + view: Optional[Union[:class:`~discord.ui.View`, :class:`~discord.ui.LayoutView`]] + The updated view to update this message with. This can only have non-interactible items, which donnot + require a state to be attached to it. If ``None`` is passed then the view is removed. + + If you want to edit a webhook message with any component attached to it, check :meth:`WebhookMessage.edit`. + + .. versionadded:: 2.6 allowed_mentions: :class:`AllowedMentions` Controls the mentions being processed in this message. See :meth:`.abc.Messageable.send` for more information. From 2da3a1467b0882258ad4ecb13482ab3df30ecd1e Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Wed, 30 Apr 2025 16:26:35 +0200 Subject: [PATCH 114/272] chore: Raise LayoutView component limit to 40 and remove component limit on containers --- discord/ui/container.py | 2 -- discord/ui/view.py | 60 ++++++++++++++++++++++++++--------------- 2 files changed, 39 insertions(+), 23 deletions(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index 0f362f898650..4aad65ca4c3f 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -136,8 +136,6 @@ def __init__( self._children: List[Item[V]] = self._init_children() if children is not MISSING: - if len(children) + len(self._children) > 10: - raise ValueError('maximum number of children exceeded') for child in children: self.add_item(child) diff --git a/discord/ui/view.py b/discord/ui/view.py index cbb28e0c8b07..91f96e508e52 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -209,6 +209,7 @@ def __init__(self, *, timeout: Optional[float] = 180.0) -> None: self.__timeout_expiry: Optional[float] = None self.__timeout_task: Optional[asyncio.Task[None]] = None self.__stopped: asyncio.Future[bool] = asyncio.get_running_loop().create_future() + self.__total_children: int = len(list(self.walk_children())) def _is_v2(self) -> bool: return False @@ -346,9 +347,14 @@ def add_item(self, item: Item[Any]) -> Self: raise ValueError('v2 items cannot be added to this view') item._view = self + added = 1 if getattr(item, '__discord_ui_update_view__', False): item._update_children_view(self) # type: ignore + added += len(list(item.walk_children())) # type: ignore + + if self._is_v2() and self.__total_children + added > 40: + raise ValueError('maximum number of children exceeded') self._children.append(item) return self @@ -369,6 +375,16 @@ def remove_item(self, item: Item[Any]) -> Self: self._children.remove(item) except ValueError: pass + else: + removed = 1 + if getattr(item, '__discord_ui_update_view__', False): + removed += len(list(item.walk_children())) # type: ignore + + if self.__total_children - removed < 0: + self.__total_children = 0 + else: + self.__total_children -= removed + return self def clear_items(self) -> Self: @@ -378,6 +394,7 @@ def clear_items(self) -> Self: chaining. """ self._children.clear() + self.__total_children = 0 return self def get_item_by_id(self, id: int, /) -> Optional[Item[Self]]: @@ -568,6 +585,23 @@ async def wait(self) -> bool: """ return await self.__stopped + def walk_children(self): + """An iterator that recursively walks through all the children of this view + and it's children, if applicable. + + Yields + ------ + :class:`Item` + An item in the view. + """ + + for child in self.children: + yield child + + if getattr(child, '__discord_ui_update_view__', False): + # if it has this attribute then it can contain children + yield from child.walk_children() # type: ignore + class View(BaseView): """Represents a UI view. @@ -723,6 +757,10 @@ class LayoutView(BaseView): def __init__(self, *, timeout: Optional[float] = 180.0) -> None: super().__init__(timeout=timeout) + self.__total_children: int = len(list(self.walk_children())) + + if self.__total_children > 40: + raise ValueError('maximum number of children exceeded') def __init_subclass__(cls) -> None: children: Dict[str, ItemLike] = {} @@ -739,9 +777,6 @@ def __init_subclass__(cls) -> None: elif hasattr(member, '__discord_ui_model_type__') and getattr(member, '__discord_ui_parent__', None): callback_children[name] = member - if len(children) > 10: - raise TypeError('LayoutView cannot have more than 10 top-level children') - children.update(callback_children) cls.__view_children_items__ = children @@ -761,7 +796,7 @@ def to_components(self): return components def add_item(self, item: Item[Any]) -> Self: - if len(self._children) >= 10: + if self.__total_children >= 40: raise ValueError('maximum number of children exceeded') super().add_item(item) return self @@ -798,23 +833,6 @@ def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) return view - def walk_children(self): - """An iterator that recursively walks through all the children of this view - and it's children, if applicable. - - Yields - ------ - :class:`Item` - An item in the view. - """ - - for child in self.children: - yield child - - if getattr(child, '__discord_ui_update_view__', False): - # if it has this attribute then it can contain children - yield from child.walk_children() # type: ignore - class ViewStore: def __init__(self, state: ConnectionState): From d41d7111a76ba67454ecc73ccdac85377acbd75f Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Wed, 30 Apr 2025 16:38:18 +0200 Subject: [PATCH 115/272] list -> tuple --- discord/ui/view.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index 91f96e508e52..e7a17c2d75e0 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -209,7 +209,7 @@ def __init__(self, *, timeout: Optional[float] = 180.0) -> None: self.__timeout_expiry: Optional[float] = None self.__timeout_task: Optional[asyncio.Task[None]] = None self.__stopped: asyncio.Future[bool] = asyncio.get_running_loop().create_future() - self.__total_children: int = len(list(self.walk_children())) + self.__total_children: int = len(tuple(self.walk_children())) def _is_v2(self) -> bool: return False @@ -351,7 +351,7 @@ def add_item(self, item: Item[Any]) -> Self: if getattr(item, '__discord_ui_update_view__', False): item._update_children_view(self) # type: ignore - added += len(list(item.walk_children())) # type: ignore + added += len(tuple(item.walk_children())) # type: ignore if self._is_v2() and self.__total_children + added > 40: raise ValueError('maximum number of children exceeded') @@ -378,7 +378,7 @@ def remove_item(self, item: Item[Any]) -> Self: else: removed = 1 if getattr(item, '__discord_ui_update_view__', False): - removed += len(list(item.walk_children())) # type: ignore + removed += len(tuple(item.walk_children())) # type: ignore if self.__total_children - removed < 0: self.__total_children = 0 From 50c40a20b377f02a412b660a02b4f07b5bcba350 Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Wed, 30 Apr 2025 22:14:48 +0200 Subject: [PATCH 116/272] fix: Change send type to None in Section.walk_children return type Co-authored-by: Michael H --- discord/ui/section.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/section.py b/discord/ui/section.py index 3f91514c7d8e..cefc4cc4085e 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -115,7 +115,7 @@ def _is_v2(self) -> bool: def is_dispatchable(self) -> bool: return self.accessory.is_dispatchable() - def walk_children(self) -> Generator[Item[V], Any, None]: + def walk_children(self) -> Generator[Item[V], None, None]: """An iterator that recursively walks through all the children of this section. and it's children, if applicable. From b0b332a2e0709649b91b7aa6dfc08622e7d67939 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Wed, 30 Apr 2025 22:16:13 +0200 Subject: [PATCH 117/272] fix: Add/Modify View/Container.walk_children return types --- discord/ui/container.py | 2 +- discord/ui/view.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index 4aad65ca4c3f..a367c96e51bb 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -295,7 +295,7 @@ def from_component(cls, component: ContainerComponent) -> Self: id=component.id, ) - def walk_children(self) -> Generator[Item[V], Any, None]: + def walk_children(self) -> Generator[Item[V], None, None]: """An iterator that recursively walks through all the children of this container and it's children, if applicable. diff --git a/discord/ui/view.py b/discord/ui/view.py index e7a17c2d75e0..2217474c589e 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -30,6 +30,7 @@ ClassVar, Coroutine, Dict, + Generator, Iterator, List, Optional, @@ -585,7 +586,7 @@ async def wait(self) -> bool: """ return await self.__stopped - def walk_children(self): + def walk_children(self) -> Generator[Item[Any], None, None]: """An iterator that recursively walks through all the children of this view and it's children, if applicable. From 9c745bb751269b8453f335b0894c35c5708f255e Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Wed, 30 Apr 2025 22:20:03 +0200 Subject: [PATCH 118/272] chore: run black --- discord/ui/container.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index a367c96e51bb..f87915e4e9d8 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -25,7 +25,21 @@ import copy import os -from typing import TYPE_CHECKING, Any, ClassVar, Coroutine, Dict, Generator, List, Literal, Optional, Tuple, Type, TypeVar, Union +from typing import ( + TYPE_CHECKING, + Any, + ClassVar, + Coroutine, + Dict, + Generator, + List, + Literal, + Optional, + Tuple, + Type, + TypeVar, + Union, +) from .item import Item, ItemCallbackType from .view import _component_to_item, LayoutView From 4044b2c97f6b1eaea32cf4c836eeb4cf1969910d Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Wed, 30 Apr 2025 22:20:23 +0200 Subject: [PATCH 119/272] chore: add *children param and validation for children --- discord/ui/action_row.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 946b6d3b9e14..e73d731de94a 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -134,9 +134,19 @@ async def row_button(self, interaction: discord.Interaction, button: discord.ui. __discord_ui_action_row__: ClassVar[bool] = True __discord_ui_update_view__: ClassVar[bool] = True - def __init__(self, *, id: Optional[int] = None) -> None: + def __init__( + self, + *children: Item[V], + id: Optional[int] = None, + ) -> None: super().__init__() - self._children: List[Item[Any]] = self._init_children() + self._weight: int = 0 + self._children: List[Item[V]] = self._init_children() + self._children.extend(children) + self._weight += sum(i.width for i in children) + + if self._weight > 5: + raise ValueError('maximum number of children exceeded') self.id = id @@ -162,6 +172,7 @@ def _init_children(self) -> List[Item[Any]]: item.callback = _ActionRowCallback(func, self, item) # type: ignore item._parent = getattr(func, '__discord_ui_parent__', self) setattr(self, func.__name__, item) + self._weight += item.width children.append(item) return children @@ -182,7 +193,7 @@ def is_dispatchable(self) -> bool: def _update_children_view(self, view: LayoutView) -> None: for child in self._children: - child._view = view + child._view = view # pyright: ignore[reportAttributeAccessIssue] def _is_v2(self) -> bool: # although it is not really a v2 component the only usecase here is for From 145af2f67f662717b60c8c35b8f1dd2fd22aa05f Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 1 May 2025 11:24:35 +0200 Subject: [PATCH 120/272] chore: update docstrings --- discord/ui/action_row.py | 2 ++ discord/ui/container.py | 5 ++--- discord/ui/file.py | 2 +- discord/ui/media_gallery.py | 2 +- discord/ui/section.py | 2 +- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index e73d731de94a..1eeb75da4aad 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -126,6 +126,8 @@ async def row_button(self, interaction: discord.Interaction, button: discord.ui. Parameters ---------- + *children: :class:`Item` + The initial children of this action row. id: Optional[:class:`int`] The ID of this component. This must be unique across the view. """ diff --git a/discord/ui/container.py b/discord/ui/container.py index f87915e4e9d8..adfbb549adb4 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -111,9 +111,8 @@ class MyView(ui.LayoutView): Parameters ---------- - children: List[:class:`Item`] - The initial children of this container. Can have up to 10 - items. + *children: List[:class:`Item`] + The initial children of this container. accent_colour: Optional[Union[:class:`.Colour`, :class:`int`]] The colour of the container. Defaults to ``None``. accent_color: Optional[Union[:class:`.Colour`, :class:`int`]] diff --git a/discord/ui/file.py b/discord/ui/file.py index 341860cc739d..ccc7a0fdcb6a 100644 --- a/discord/ui/file.py +++ b/discord/ui/file.py @@ -63,7 +63,7 @@ class MyView(ui.LayoutView): media: Union[:class:`str`, :class:`.UnfurledMediaItem`] This file's media. If this is a string it must point to a local file uploaded within the parent view of this item, and must - meet the ``attachment://file-name.extension`` structure. + meet the ``attachment://filename.extension`` structure. spoiler: :class:`bool` Whether to flag this file as a spoiler. Defaults to ``False``. row: Optional[:class:`int`] diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index badd495e0802..b7c92f0475fa 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -53,7 +53,7 @@ class MediaGallery(Item[V]): Parameters ---------- - items: List[:class:`.MediaGalleryItem`] + *items: :class:`.MediaGalleryItem` The initial items of this gallery. row: Optional[:class:`int`] The relative row this media gallery belongs to. By default diff --git a/discord/ui/section.py b/discord/ui/section.py index cefc4cc4085e..7a5fd2afaacb 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -50,7 +50,7 @@ class Section(Item[V]): Parameters ---------- - children: List[Union[:class:`str`, :class:`TextDisplay`]] + *children: Union[:class:`str`, :class:`TextDisplay`] The text displays of this section. Up to 3. accessory: :class:`Item` The section accessory. From 27db09adcfdb5a29371960f414567c6cb47cf267 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 1 May 2025 11:42:21 +0200 Subject: [PATCH 121/272] chore: overloads --- discord/ext/commands/context.py | 34 +++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/discord/ext/commands/context.py b/discord/ext/commands/context.py index 1e957feb4f16..3a7204e9b7bc 100644 --- a/discord/ext/commands/context.py +++ b/discord/ext/commands/context.py @@ -855,7 +855,33 @@ async def defer(self, *, ephemeral: bool = False) -> None: async def send( self, *, + file: File = ..., + delete_after: float = ..., + nonce: Union[str, int] = ..., + allowed_mentions: AllowedMentions = ..., + reference: Union[Message, MessageReference, PartialMessage] = ..., + mention_author: bool = ..., + view: LayoutView, + suppress_embeds: bool = ..., + ephemeral: bool = ..., + silent: bool = ..., + ) -> Message: + ... + + @overload + async def send( + self, + *, + files: Sequence[File] = ..., + delete_after: float = ..., + nonce: Union[str, int] = ..., + allowed_mentions: AllowedMentions = ..., + reference: Union[Message, MessageReference, PartialMessage] = ..., + mention_author: bool = ..., view: LayoutView, + suppress_embeds: bool = ..., + ephemeral: bool = ..., + silent: bool = ..., ) -> Message: ... @@ -873,7 +899,7 @@ async def send( allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: BaseView = ..., + view: View = ..., suppress_embeds: bool = ..., ephemeral: bool = ..., silent: bool = ..., @@ -895,7 +921,7 @@ async def send( allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: BaseView = ..., + view: View = ..., suppress_embeds: bool = ..., ephemeral: bool = ..., silent: bool = ..., @@ -917,7 +943,7 @@ async def send( allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: BaseView = ..., + view: View = ..., suppress_embeds: bool = ..., ephemeral: bool = ..., silent: bool = ..., @@ -939,7 +965,7 @@ async def send( allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: BaseView = ..., + view: View = ..., suppress_embeds: bool = ..., ephemeral: bool = ..., silent: bool = ..., From 03964172d33552ca7be6952e1197e7ac36ad0120 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 1 May 2025 16:14:57 +0200 Subject: [PATCH 122/272] rows --- discord/ui/action_row.py | 9 +++++++++ discord/ui/container.py | 2 +- discord/ui/file.py | 2 +- discord/ui/item.py | 2 +- discord/ui/media_gallery.py | 2 +- discord/ui/section.py | 2 +- discord/ui/separator.py | 2 +- discord/ui/text_display.py | 2 +- discord/ui/thumbnail.py | 2 +- 9 files changed, 17 insertions(+), 8 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 1eeb75da4aad..e54522ec613a 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -128,6 +128,13 @@ async def row_button(self, interaction: discord.Interaction, button: discord.ui. ---------- *children: :class:`Item` The initial children of this action row. + row: Optional[:class:`int`] + The relative row this action row belongs to. By default + items are arranged automatically into those 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 39 (i.e. zero indexed) id: Optional[:class:`int`] The ID of this component. This must be unique across the view. """ @@ -139,6 +146,7 @@ async def row_button(self, interaction: discord.Interaction, button: discord.ui. def __init__( self, *children: Item[V], + row: Optional[int] = None, id: Optional[int] = None, ) -> None: super().__init__() @@ -151,6 +159,7 @@ def __init__( raise ValueError('maximum number of children exceeded') self.id = id + self.row = row def __init_subclass__(cls) -> None: super().__init_subclass__() diff --git a/discord/ui/container.py b/discord/ui/container.py index adfbb549adb4..c5890722c46c 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -126,7 +126,7 @@ class MyView(ui.LayoutView): 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 9 (i.e. zero indexed) + ordering. The row number must be between 0 and 39 (i.e. zero indexed) id: Optional[:class:`int`] The ID of this component. This must be unique across the view. """ diff --git a/discord/ui/file.py b/discord/ui/file.py index ccc7a0fdcb6a..5d9014e72acb 100644 --- a/discord/ui/file.py +++ b/discord/ui/file.py @@ -72,7 +72,7 @@ class MyView(ui.LayoutView): 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 9 (i.e. zero indexed) + ordering. The row number must be between 0 and 39 (i.e. zero indexed) id: Optional[:class:`int`] The ID of this component. This must be unique across the view. """ diff --git a/discord/ui/item.py b/discord/ui/item.py index 87cfc368133f..c73d8e7621ba 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -80,7 +80,7 @@ def __init__(self): # only called upon edit and we're mainly interested during initial creation time. self._provided_custom_id: bool = False self._id: Optional[int] = None - self._max_row: int = 5 if not self._is_v2() else 10 + self._max_row: int = 5 if not self._is_v2() else 40 self._parent: Optional[Item] = None if self._is_v2(): diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index b7c92f0475fa..bbecc96494db 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -61,7 +61,7 @@ class MediaGallery(Item[V]): 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 9 (i.e. zero indexed) + ordering. The row number must be between 0 and 39 (i.e. zero indexed) id: Optional[:class:`int`] The ID of this component. This must be unique across the view. """ diff --git a/discord/ui/section.py b/discord/ui/section.py index 7a5fd2afaacb..2d4acdc3a804 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -60,7 +60,7 @@ class Section(Item[V]): 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 9 (i.e. zero indexed) + ordering. The row number must be between 0 and 39 (i.e. zero indexed) id: Optional[:class:`int`] The ID of this component. This must be unique across the view. """ diff --git a/discord/ui/separator.py b/discord/ui/separator.py index 48908df9d6eb..e7d75a998b95 100644 --- a/discord/ui/separator.py +++ b/discord/ui/separator.py @@ -59,7 +59,7 @@ class Separator(Item[V]): 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 9 (i.e. zero indexed) + ordering. The row number must be between 0 and 39 (i.e. zero indexed) id: Optional[:class:`int`] The ID of this component. This must be unique across the view. """ diff --git a/discord/ui/text_display.py b/discord/ui/text_display.py index beff74c6eac3..9ba7f294e4d0 100644 --- a/discord/ui/text_display.py +++ b/discord/ui/text_display.py @@ -56,7 +56,7 @@ class TextDisplay(Item[V]): 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 9 (i.e. zero indexed) + ordering. The row number must be between 0 and 39 (i.e. zero indexed) id: Optional[:class:`int`] The ID of this component. This must be unique across the view. """ diff --git a/discord/ui/thumbnail.py b/discord/ui/thumbnail.py index f639269918a4..67f8e4c7629f 100644 --- a/discord/ui/thumbnail.py +++ b/discord/ui/thumbnail.py @@ -61,7 +61,7 @@ class Thumbnail(Item[V]): 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 9 (i.e. zero indexed) + ordering. The row number must be between 0 and 39 (i.e. zero indexed) id: Optional[:class:`int`] The ID of this component. This must be unique across the view. """ From 7012cec96a35c925051b3525d1d56372376c3db2 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 6 May 2025 16:43:05 +0200 Subject: [PATCH 123/272] fix: LayoutView.__total_children being incorrectly set when adding/removing items from an item --- discord/ui/action_row.py | 10 ++++++++++ discord/ui/container.py | 17 +++++++++++++++++ discord/ui/section.py | 11 +++++++++++ discord/ui/view.py | 2 ++ 4 files changed, 40 insertions(+) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index e54522ec613a..a72d4db1e9ef 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -268,6 +268,10 @@ def add_item(self, item: Item[Any]) -> Self: item._view = self._view item._parent = self self._children.append(item) + + if self._view and getattr(self._view, '__discord_ui_layout_view__', False): + self._view.__total_children += 1 + return self def remove_item(self, item: Item[Any]) -> Self: @@ -286,6 +290,10 @@ def remove_item(self, item: Item[Any]) -> Self: self._children.remove(item) except ValueError: pass + else: + if self._view and getattr(self._view, '__discord_ui_layout_view__', False): + self._view.__total_children -= 1 + return self def get_item_by_id(self, id: int, /) -> Optional[Item[V]]: @@ -314,6 +322,8 @@ def clear_items(self) -> Self: This function returns the class instance to allow for fluent-style chaining. """ + if self._view and getattr(self._view, '__discord_ui_layout_view__', False): + self._view.__total_children -= len(self._children) self._children.clear() return self diff --git a/discord/ui/container.py b/discord/ui/container.py index c5890722c46c..d9eb4f35d093 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -360,9 +360,17 @@ def add_item(self, item: Item[Any]) -> Self: else: self.__dispatchable.append(item) + is_layout_view = self._view and getattr(self._view, '__discord_ui_layout_view__', False) + if getattr(item, '__discord_ui_update_view__', False): item._update_children_view(self.view) # type: ignore + if is_layout_view: + self._view.__total_children += len(tuple(item.walk_children())) # type: ignore + else: + if is_layout_view: + self._view.__total_children += 1 # type: ignore + item._view = self.view item._parent = self return self @@ -383,6 +391,12 @@ def remove_item(self, item: Item[Any]) -> Self: self._children.remove(item) except ValueError: pass + else: + if self._view and getattr(self._view, '__discord_ui_layout_view__', False): + if getattr(item, '__discord_ui_update_view__', False): + self._view.__total_children -= len(tuple(item.walk_children())) # type: ignore + else: + self._view.__total_children -= 1 return self def get_item_by_id(self, id: int, /) -> Optional[Item[V]]: @@ -411,5 +425,8 @@ def clear_items(self) -> Self: This function returns the class instance to allow for fluent-style chaining. """ + + if self._view and getattr(self._view, '__discord_ui_layout_view__', False): + self._view.__total_children -= len(tuple(self.walk_children())) self._children.clear() return self diff --git a/discord/ui/section.py b/discord/ui/section.py index 2d4acdc3a804..70c5a778c7a9 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -162,6 +162,10 @@ def add_item(self, item: Union[str, Item[Any]]) -> Self: item._view = self.view item._parent = self self._children.append(item) + + if self._view and getattr(self._view, '__discord_ui_layout_view__', False): + self._view.__total_children += 1 + return self def remove_item(self, item: Item[Any]) -> Self: @@ -180,6 +184,10 @@ def remove_item(self, item: Item[Any]) -> Self: self._children.remove(item) except ValueError: pass + else: + if self._view and getattr(self._view, '__discord_ui_layout_view__', False): + self._view.__total_children -= 1 + return self def get_item_by_id(self, id: int, /) -> Optional[Item[V]]: @@ -208,6 +216,9 @@ def clear_items(self) -> Self: This function returns the class instance to allow for fluent-style chaining. """ + if self._view and getattr(self._view, '__discord_ui_layout_view__', False): + self._view.__total_children -= len(self._children) + 1 # the + 1 is the accessory + self._children.clear() return self diff --git a/discord/ui/view.py b/discord/ui/view.py index 2217474c589e..d8c21354c560 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -756,6 +756,8 @@ class LayoutView(BaseView): If ``None`` then there is no timeout. """ + __discord_ui_layout_view__: ClassVar[bool] = True + def __init__(self, *, timeout: Optional[float] = 180.0) -> None: super().__init__(timeout=timeout) self.__total_children: int = len(list(self.walk_children())) From e29c10d18680882d2c8c155a6d3a6a58f98d169c Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 6 May 2025 16:46:50 +0200 Subject: [PATCH 124/272] fix: Webhook.send overloads missing ephemeral kwarg --- discord/webhook/async_.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/discord/webhook/async_.py b/discord/webhook/async_.py index df6055d9c520..104da78cab21 100644 --- a/discord/webhook/async_.py +++ b/discord/webhook/async_.py @@ -1611,6 +1611,7 @@ async def send( *, username: str = MISSING, avatar_url: Any = MISSING, + ephemeral: bool = MISSING, file: File = MISSING, files: Sequence[File] = MISSING, allowed_mentions: AllowedMentions = MISSING, @@ -1630,6 +1631,7 @@ async def send( *, username: str = MISSING, avatar_url: Any = MISSING, + ephemeral: bool = MISSING, file: File = MISSING, files: Sequence[File] = MISSING, allowed_mentions: AllowedMentions = MISSING, From 6122b32dae10ebeb6ad5989675836ea0b41d8631 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 6 May 2025 16:54:13 +0200 Subject: [PATCH 125/272] fix: Sorting LayoutView children defaulting to 0 instead of sys.maxsize --- discord/ui/view.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index d8c21354c560..0f9a552a3d9b 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -791,7 +791,8 @@ def to_components(self): # sorted by row, which in LayoutView indicates the position of the component in the # payload instead of in which ActionRow it should be placed on. - for child in sorted(self._children, key=lambda i: i._rendered_row or 0): + key = lambda i: i._rendered_row or i._row or sys.maxsize + for child in sorted(self._children, key=key): components.append( child.to_component_dict(), ) From 7b5f247502ee0782c5cc978a3fe5141641dc1221 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 6 May 2025 16:54:55 +0200 Subject: [PATCH 126/272] chore: Add call to super().__init_subclass__() --- discord/ui/view.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/discord/ui/view.py b/discord/ui/view.py index 0f9a552a3d9b..4de1d076647b 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -766,6 +766,8 @@ def __init__(self, *, timeout: Optional[float] = 180.0) -> None: raise ValueError('maximum number of children exceeded') def __init_subclass__(cls) -> None: + super().__init_subclass__() + children: Dict[str, ItemLike] = {} callback_children: Dict[str, ItemCallbackType[Any]] = {} From cf08c0e78f03da0f7006c887f519ef3d0f955d18 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Wed, 7 May 2025 15:48:03 +0200 Subject: [PATCH 127/272] chore: Remove ValueError on Container.add_item --- discord/ui/container.py | 6 ------ discord/ui/view.py | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index d9eb4f35d093..dab8f58ee215 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -340,13 +340,7 @@ def add_item(self, item: Item[Any]) -> Self: ------ TypeError An :class:`Item` was not passed. - ValueError - Maximum number of children has been exceeded (10). """ - - if len(self._children) >= 10: - raise ValueError('maximum number of children exceeded') - if not isinstance(item, Item): raise TypeError(f'expected Item not {item.__class__.__name__}') diff --git a/discord/ui/view.py b/discord/ui/view.py index 4de1d076647b..2b72b89482df 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -337,7 +337,7 @@ def add_item(self, item: Item[Any]) -> Self: TypeError An :class:`Item` was not passed. ValueError - Maximum number of children has been exceeded (25), the + Maximum number of children has been exceeded, the row the item is trying to be added to is full or the item you tried to add is not allowed in this View. """ @@ -803,7 +803,7 @@ def to_components(self): def add_item(self, item: Item[Any]) -> Self: if self.__total_children >= 40: - raise ValueError('maximum number of children exceeded') + raise ValueError('maximum number of children exceeded (40)') super().add_item(item) return self From 4ca483efdb6b6d22faed5b3c24850241e651cc49 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 8 May 2025 19:11:10 +0200 Subject: [PATCH 128/272] chore: fix is_persistent and default to sys.maxsize instead of 0 on sorting key --- discord/ui/action_row.py | 9 +++++++-- discord/ui/container.py | 8 +++++++- discord/ui/section.py | 6 +++++- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index a72d4db1e9ef..47903a4be1ec 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -23,6 +23,7 @@ """ from __future__ import annotations +import sys from typing import ( TYPE_CHECKING, Any, @@ -202,6 +203,9 @@ def _update_store_data(self, dispatch_info: Dict, dynamic_items: Dict) -> bool: def is_dispatchable(self) -> bool: return any(c.is_dispatchable() for c in self.children) + def is_persistent(self) -> bool: + return self.is_dispatchable() and all(c.is_persistent() for c in self.children) + def _update_children_view(self, view: LayoutView) -> None: for child in self._children: child._view = view # pyright: ignore[reportAttributeAccessIssue] @@ -330,8 +334,9 @@ def clear_items(self) -> Self: def to_component_dict(self) -> Dict[str, Any]: components = [] - for item in self._children: - components.append(item.to_component_dict()) + key = lambda i: i._rendered_row or i._row or sys.maxsize + for child in sorted(self._children, key=key): + components.append(child.to_component_dict()) base = { 'type': self.type.value, diff --git a/discord/ui/container.py b/discord/ui/container.py index dab8f58ee215..174a2ec027fc 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -25,6 +25,7 @@ import copy import os +import sys from typing import ( TYPE_CHECKING, Any, @@ -210,6 +211,9 @@ def _init_children(self) -> List[Item[Any]]: def is_dispatchable(self) -> bool: return bool(self.__dispatchable) + def is_persistent(self) -> bool: + return self.is_dispatchable() and all(c.is_persistent() for c in self.children) + def __init_subclass__(cls) -> None: super().__init_subclass__() @@ -263,7 +267,9 @@ def _is_v2(self) -> bool: def to_components(self) -> List[Dict[str, Any]]: components = [] - for child in sorted(self._children, key=lambda i: i._rendered_row or 0): + + key = lambda i: i._rendered_row or i._row or sys.maxsize + for child in sorted(self._children, key=key): components.append(child.to_component_dict()) return components diff --git a/discord/ui/section.py b/discord/ui/section.py index 70c5a778c7a9..28688152fc1e 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -23,6 +23,7 @@ """ from __future__ import annotations +import sys from typing import TYPE_CHECKING, Any, Dict, Generator, List, Literal, Optional, TypeVar, Union, ClassVar from .item import Item @@ -115,6 +116,9 @@ def _is_v2(self) -> bool: def is_dispatchable(self) -> bool: return self.accessory.is_dispatchable() + def is_persistent(self) -> bool: + return self.is_dispatchable() and self.accessory.is_persistent() + def walk_children(self) -> Generator[Item[V], None, None]: """An iterator that recursively walks through all the children of this section. and it's children, if applicable. @@ -239,7 +243,7 @@ def to_component_dict(self) -> Dict[str, Any]: c.to_component_dict() for c in sorted( self._children, - key=lambda i: i._rendered_row or 0, + key=lambda i: i._rendered_row or sys.maxsize, ) ], 'accessory': self.accessory.to_component_dict(), From 4103a976356a7570b311cb3d929343e05c2ce178 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Fri, 9 May 2025 22:19:43 +0200 Subject: [PATCH 129/272] things --- discord/ui/container.py | 2 +- discord/ui/view.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index 174a2ec027fc..72892d3a08eb 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -112,7 +112,7 @@ class MyView(ui.LayoutView): Parameters ---------- - *children: List[:class:`Item`] + *children: :class:`Item` The initial children of this container. accent_colour: Optional[Union[:class:`.Colour`, :class:`int`]] The colour of the container. Defaults to ``None``. diff --git a/discord/ui/view.py b/discord/ui/view.py index 2b72b89482df..7ecc9da1e482 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -776,7 +776,9 @@ def __init_subclass__(cls) -> None: for base in reversed(cls.__mro__): for name, member in base.__dict__.items(): if isinstance(member, Item): - member._rendered_row = member._row or row + if member._row is None: + member._row = row + member._rendered_row = member._row children[name] = member row += 1 elif hasattr(member, '__discord_ui_model_type__') and getattr(member, '__discord_ui_parent__', None): From 176d0a4182b18b8196331599e7e5fbe5e1427830 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Mon, 12 May 2025 15:33:57 +0200 Subject: [PATCH 130/272] fix attr error things --- discord/ui/action_row.py | 6 +++--- discord/ui/container.py | 13 ++++++------- discord/ui/section.py | 6 +++--- discord/ui/view.py | 22 +++++++++------------- 4 files changed, 21 insertions(+), 26 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 47903a4be1ec..5246444721b8 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -274,7 +274,7 @@ def add_item(self, item: Item[Any]) -> Self: self._children.append(item) if self._view and getattr(self._view, '__discord_ui_layout_view__', False): - self._view.__total_children += 1 + self._view._total_children += 1 return self @@ -296,7 +296,7 @@ def remove_item(self, item: Item[Any]) -> Self: pass else: if self._view and getattr(self._view, '__discord_ui_layout_view__', False): - self._view.__total_children -= 1 + self._view._total_children -= 1 return self @@ -327,7 +327,7 @@ def clear_items(self) -> Self: chaining. """ if self._view and getattr(self._view, '__discord_ui_layout_view__', False): - self._view.__total_children -= len(self._children) + self._view._total_children -= len(self._children) self._children.clear() return self diff --git a/discord/ui/container.py b/discord/ui/container.py index 72892d3a08eb..439f49a85453 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -366,10 +366,9 @@ def add_item(self, item: Item[Any]) -> Self: item._update_children_view(self.view) # type: ignore if is_layout_view: - self._view.__total_children += len(tuple(item.walk_children())) # type: ignore - else: - if is_layout_view: - self._view.__total_children += 1 # type: ignore + self._view._total_children += sum(1 for _ in item.walk_children()) # type: ignore + elif is_layout_view: + self._view._total_children += 1 # type: ignore item._view = self.view item._parent = self @@ -394,9 +393,9 @@ def remove_item(self, item: Item[Any]) -> Self: else: if self._view and getattr(self._view, '__discord_ui_layout_view__', False): if getattr(item, '__discord_ui_update_view__', False): - self._view.__total_children -= len(tuple(item.walk_children())) # type: ignore + self._view._total_children -= len(tuple(item.walk_children())) # type: ignore else: - self._view.__total_children -= 1 + self._view._total_children -= 1 return self def get_item_by_id(self, id: int, /) -> Optional[Item[V]]: @@ -427,6 +426,6 @@ def clear_items(self) -> Self: """ if self._view and getattr(self._view, '__discord_ui_layout_view__', False): - self._view.__total_children -= len(tuple(self.walk_children())) + self._view._total_children -= sum(1 for _ in self.walk_children()) self._children.clear() return self diff --git a/discord/ui/section.py b/discord/ui/section.py index 28688152fc1e..2653e7469b92 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -168,7 +168,7 @@ def add_item(self, item: Union[str, Item[Any]]) -> Self: self._children.append(item) if self._view and getattr(self._view, '__discord_ui_layout_view__', False): - self._view.__total_children += 1 + self._view._total_children += 1 return self @@ -190,7 +190,7 @@ def remove_item(self, item: Item[Any]) -> Self: pass else: if self._view and getattr(self._view, '__discord_ui_layout_view__', False): - self._view.__total_children -= 1 + self._view._total_children -= 1 return self @@ -221,7 +221,7 @@ def clear_items(self) -> Self: chaining. """ if self._view and getattr(self._view, '__discord_ui_layout_view__', False): - self._view.__total_children -= len(self._children) + 1 # the + 1 is the accessory + self._view._total_children -= len(self._children) # we don't count the accessory because it is required self._children.clear() return self diff --git a/discord/ui/view.py b/discord/ui/view.py index 7ecc9da1e482..dfa4a2388f10 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -198,7 +198,6 @@ def __call__(self, interaction: Interaction) -> Coroutine[Any, Any, Any]: class BaseView: __discord_ui_view__: ClassVar[bool] = False __discord_ui_modal__: ClassVar[bool] = False - __discord_ui_container__: ClassVar[bool] = False __view_children_items__: ClassVar[Dict[str, ItemLike]] = {} def __init__(self, *, timeout: Optional[float] = 180.0) -> None: @@ -210,7 +209,7 @@ def __init__(self, *, timeout: Optional[float] = 180.0) -> None: self.__timeout_expiry: Optional[float] = None self.__timeout_task: Optional[asyncio.Task[None]] = None self.__stopped: asyncio.Future[bool] = asyncio.get_running_loop().create_future() - self.__total_children: int = len(tuple(self.walk_children())) + self._total_children: int = sum(1 for _ in self.walk_children()) def _is_v2(self) -> bool: return False @@ -354,7 +353,7 @@ def add_item(self, item: Item[Any]) -> Self: item._update_children_view(self) # type: ignore added += len(tuple(item.walk_children())) # type: ignore - if self._is_v2() and self.__total_children + added > 40: + if self._is_v2() and self._total_children + added > 40: raise ValueError('maximum number of children exceeded') self._children.append(item) @@ -381,10 +380,10 @@ def remove_item(self, item: Item[Any]) -> Self: if getattr(item, '__discord_ui_update_view__', False): removed += len(tuple(item.walk_children())) # type: ignore - if self.__total_children - removed < 0: - self.__total_children = 0 + if self._total_children - removed < 0: + self._total_children = 0 else: - self.__total_children -= removed + self._total_children -= removed return self @@ -395,7 +394,7 @@ def clear_items(self) -> Self: chaining. """ self._children.clear() - self.__total_children = 0 + self._total_children = 0 return self def get_item_by_id(self, id: int, /) -> Optional[Item[Self]]: @@ -760,9 +759,8 @@ class LayoutView(BaseView): def __init__(self, *, timeout: Optional[float] = 180.0) -> None: super().__init__(timeout=timeout) - self.__total_children: int = len(list(self.walk_children())) - if self.__total_children > 40: + if self._total_children > 40: raise ValueError('maximum number of children exceeded') def __init_subclass__(cls) -> None: @@ -776,9 +774,7 @@ def __init_subclass__(cls) -> None: for base in reversed(cls.__mro__): for name, member in base.__dict__.items(): if isinstance(member, Item): - if member._row is None: - member._row = row - member._rendered_row = member._row + member._rendered_row = member._row or row children[name] = member row += 1 elif hasattr(member, '__discord_ui_model_type__') and getattr(member, '__discord_ui_parent__', None): @@ -804,7 +800,7 @@ def to_components(self): return components def add_item(self, item: Item[Any]) -> Self: - if self.__total_children >= 40: + if self._total_children >= 40: raise ValueError('maximum number of children exceeded (40)') super().add_item(item) return self From 8f39bf5731544183a3211e5382ec5e17261555dd Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Wed, 14 May 2025 16:31:04 +0200 Subject: [PATCH 131/272] fix: rows being set weirdly because of sorted --- discord/ui/action_row.py | 5 +++-- discord/ui/container.py | 5 +++-- discord/ui/section.py | 15 ++++++++------- discord/ui/view.py | 8 ++++---- 4 files changed, 18 insertions(+), 15 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 5246444721b8..cd7b4c75e5fd 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -24,6 +24,7 @@ from __future__ import annotations import sys +from itertools import groupby from typing import ( TYPE_CHECKING, Any, @@ -335,8 +336,8 @@ def to_component_dict(self) -> Dict[str, Any]: components = [] key = lambda i: i._rendered_row or i._row or sys.maxsize - for child in sorted(self._children, key=key): - components.append(child.to_component_dict()) + for _, cmps in groupby(self._children, key=key): + components.extend(c.to_component_dict() for c in cmps) base = { 'type': self.type.value, diff --git a/discord/ui/container.py b/discord/ui/container.py index 439f49a85453..e9c7494d8a1e 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -26,6 +26,7 @@ import copy import os import sys +from itertools import groupby from typing import ( TYPE_CHECKING, Any, @@ -269,8 +270,8 @@ def to_components(self) -> List[Dict[str, Any]]: components = [] key = lambda i: i._rendered_row or i._row or sys.maxsize - for child in sorted(self._children, key=key): - components.append(child.to_component_dict()) + for _, comps in groupby(self._children, key=key): + components.extend(c.to_component_dict() for c in comps) return components def to_component_dict(self) -> Dict[str, Any]: diff --git a/discord/ui/section.py b/discord/ui/section.py index 2653e7469b92..c38040346dc9 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -24,6 +24,7 @@ from __future__ import annotations import sys +from itertools import groupby from typing import TYPE_CHECKING, Any, Dict, Generator, List, Literal, Optional, TypeVar, Union, ClassVar from .item import Item @@ -236,16 +237,16 @@ def from_component(cls, component: SectionComponent) -> Self: id=component.id, ) + def to_components(self) -> List[Dict[str, Any]]: + components = [] + for _, comps in groupby(self._children, key=lambda i: i._rendered_row or i._row or sys.maxsize): + components.extend(c.to_component_dict() for c in comps) + return components + def to_component_dict(self) -> Dict[str, Any]: data = { 'type': self.type.value, - 'components': [ - c.to_component_dict() - for c in sorted( - self._children, - key=lambda i: i._rendered_row or sys.maxsize, - ) - ], + 'components': self.to_components(), 'accessory': self.accessory.to_component_dict(), } if self.id is not None: diff --git a/discord/ui/view.py b/discord/ui/view.py index dfa4a2388f10..65d54c7dae79 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -761,7 +761,7 @@ def __init__(self, *, timeout: Optional[float] = 180.0) -> None: super().__init__(timeout=timeout) if self._total_children > 40: - raise ValueError('maximum number of children exceeded') + raise ValueError('maximum number of children exceeded (40)') def __init_subclass__(cls) -> None: super().__init_subclass__() @@ -792,9 +792,9 @@ def to_components(self): # sorted by row, which in LayoutView indicates the position of the component in the # payload instead of in which ActionRow it should be placed on. key = lambda i: i._rendered_row or i._row or sys.maxsize - for child in sorted(self._children, key=key): - components.append( - child.to_component_dict(), + for _, cmps in groupby(self._children, key=key): + components.extend( + c.to_component_dict() for c in cmps ) return components From 8fc329d4bfd884a283589e10a09e89174e34156b Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Wed, 14 May 2025 16:32:43 +0200 Subject: [PATCH 132/272] fix: missing applied_tags param on overloads --- discord/channel.py | 1 + 1 file changed, 1 insertion(+) diff --git a/discord/channel.py b/discord/channel.py index 168eee1f4ad6..9a1218f31499 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -2852,6 +2852,7 @@ async def create_thread( files: Sequence[File] = ..., allowed_mentions: AllowedMentions = ..., mention_author: bool = ..., + applied_tags: Sequence[ForumTag] = ..., view: LayoutView, suppress_embeds: bool = ..., reason: Optional[str] = ..., From 0b25cf7a990c3baecde246f72d915a3015b679a4 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Wed, 14 May 2025 16:35:21 +0200 Subject: [PATCH 133/272] chore: dynamic items --- discord/ui/dynamic.py | 9 +++++---- discord/ui/view.py | 3 ++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/discord/ui/dynamic.py b/discord/ui/dynamic.py index b8aa78fdbe30..667848920ce0 100644 --- a/discord/ui/dynamic.py +++ b/discord/ui/dynamic.py @@ -45,7 +45,7 @@ V = TypeVar('V', bound='BaseView', covariant=True) -class DynamicItem(Generic[BaseT], Item['BaseView']): +class DynamicItem(Generic[BaseT, V], Item[V]): """Represents an item with a dynamic ``custom_id`` that can be used to store state within that ``custom_id``. @@ -57,9 +57,10 @@ class DynamicItem(Generic[BaseT], Item['BaseView']): and should not be used long term. Their only purpose is to act as a "template" for the actual dispatched item. - When this item is generated, :attr:`view` is set to a regular :class:`View` instance - from the original message given from the interaction. This means that custom view - subclasses cannot be accessed from this item. + When this item is generated, :attr:`view` is set to a regular :class:`View` instance, + but to a :class:`LayoutView` if the component was sent with one, this is obtained from + the original message given from the interaction. This means that custom view subclasses + cannot be accessed from this item. .. versionadded:: 2.4 diff --git a/discord/ui/view.py b/discord/ui/view.py index 65d54c7dae79..6f31a65fb464 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -948,7 +948,8 @@ async def schedule_dynamic_item_call( if interaction.message is None: return - view = View.from_message(interaction.message, timeout=None) + view_cls = View if not interaction.message.flags.components_v2 else LayoutView + view = view_cls.from_message(interaction.message, timeout=None) try: base_item_index, base_item = next( From 1281a2e5fa5c5f710d89209b9a7781695e02a095 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Wed, 14 May 2025 16:37:06 +0200 Subject: [PATCH 134/272] chore: black --- discord/ui/view.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index 6f31a65fb464..795818841197 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -793,9 +793,7 @@ def to_components(self): # payload instead of in which ActionRow it should be placed on. key = lambda i: i._rendered_row or i._row or sys.maxsize for _, cmps in groupby(self._children, key=key): - components.extend( - c.to_component_dict() for c in cmps - ) + components.extend(c.to_component_dict() for c in cmps) return components From 736fbfcb7d5b9b7819726e22df354cbdbf27dd54 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Wed, 14 May 2025 17:04:49 +0200 Subject: [PATCH 135/272] fix: typings on examples of dynamic --- examples/views/dynamic_counter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/views/dynamic_counter.py b/examples/views/dynamic_counter.py index cfb02ee5ddda..d007e0173dd4 100644 --- a/examples/views/dynamic_counter.py +++ b/examples/views/dynamic_counter.py @@ -17,7 +17,7 @@ # Note that custom_ids can only be up to 100 characters long. class DynamicCounter( - discord.ui.DynamicItem[discord.ui.Button], + discord.ui.DynamicItem[discord.ui.Button, discord.ui.View], template=r'counter:(?P[0-9]+):user:(?P[0-9]+)', ): def __init__(self, user_id: int, count: int = 0) -> None: From a19055b308a4ef58401c44c23f390077ce064ed0 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 15 May 2025 15:35:54 +0200 Subject: [PATCH 136/272] __dispatchable having children that were removed from the Container --- discord/ui/container.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index e9c7494d8a1e..1998351790d4 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -356,7 +356,7 @@ def add_item(self, item: Item[Any]) -> Self: if item.is_dispatchable(): if getattr(item, '__discord_ui_section__', False): self.__dispatchable.append(item.accessory) # type: ignore - elif hasattr(item, '_children'): + elif getattr(item, '__discord_ui_action_row__', False): self.__dispatchable.extend([i for i in item._children if i.is_dispatchable()]) # type: ignore else: self.__dispatchable.append(item) @@ -392,6 +392,22 @@ def remove_item(self, item: Item[Any]) -> Self: except ValueError: pass else: + if item.is_dispatchable(): + # none of this should error, but wrap in a try/except block + # anyways. + try: + if getattr(item, '__discord_ui_section__', False): + self.__dispatchable.remove(item.accessory) # type: ignore + elif getattr(item, '__discord_ui_action_row__', False): + for c in item._children: # type: ignore + if not c.is_dispatchable(): + continue + self.__dispatchable.remove(c) + else: + self.__dispatchable.remove(item) + except ValueError: + pass + if self._view and getattr(self._view, '__discord_ui_layout_view__', False): if getattr(item, '__discord_ui_update_view__', False): self._view._total_children -= len(tuple(item.walk_children())) # type: ignore @@ -429,4 +445,5 @@ def clear_items(self) -> Self: if self._view and getattr(self._view, '__discord_ui_layout_view__', False): self._view._total_children -= sum(1 for _ in self.walk_children()) self._children.clear() + self.__dispatchable.clear() return self From eb38195e8044ea6c11647d0e8110a088f086fd1f Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 15 May 2025 15:41:02 +0200 Subject: [PATCH 137/272] fix: Container.__dispatchable not having new dispatchable nested items added after a dispatchable item was added in add_item --- discord/ui/action_row.py | 3 +++ discord/ui/container.py | 32 ++++++++++++++++++-------------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index cd7b4c75e5fd..764ce1dc097f 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -277,6 +277,9 @@ def add_item(self, item: Item[Any]) -> Self: if self._view and getattr(self._view, '__discord_ui_layout_view__', False): self._view._total_children += 1 + if item.is_dispatchable() and self._parent and getattr(self._parent, '__discord_ui_container__', False): + self._parent._add_dispatchable(item) # type: ignore + return self def remove_item(self, item: Item[Any]) -> Self: diff --git a/discord/ui/container.py b/discord/ui/container.py index 1998351790d4..633af8360335 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -160,6 +160,15 @@ def __init__( self.row = row self.id = id + def _add_dispatchable(self, item: Item[Any]) -> None: + self.__dispatchable.append(item) + + def _remove_dispatchable(self, item: Item[Any]) -> None: + try: + self.__dispatchable.remove(item) + except ValueError: + pass + def _init_children(self) -> List[Item[Any]]: children = [] parents = {} @@ -393,20 +402,15 @@ def remove_item(self, item: Item[Any]) -> Self: pass else: if item.is_dispatchable(): - # none of this should error, but wrap in a try/except block - # anyways. - try: - if getattr(item, '__discord_ui_section__', False): - self.__dispatchable.remove(item.accessory) # type: ignore - elif getattr(item, '__discord_ui_action_row__', False): - for c in item._children: # type: ignore - if not c.is_dispatchable(): - continue - self.__dispatchable.remove(c) - else: - self.__dispatchable.remove(item) - except ValueError: - pass + if getattr(item, '__discord_ui_section__', False): + self._remove_dispatchable(item.accessory) # type: ignore + elif getattr(item, '__discord_ui_action_row__', False): + for c in item._children: # type: ignore + if not c.is_dispatchable(): + continue + self._remove_dispatchable(c) + else: + self._remove_dispatchable(item) if self._view and getattr(self._view, '__discord_ui_layout_view__', False): if getattr(item, '__discord_ui_update_view__', False): From 091705c2836e46e6aedc59a10afe674bc0ee8a21 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 15 May 2025 15:45:22 +0200 Subject: [PATCH 138/272] chore: remove _ViewWeights leftover code --- discord/ui/view.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index 795818841197..1e988d55b304 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -179,9 +179,6 @@ def remove_item(self, item: Item) -> None: def clear(self) -> None: self.weights = [0, 0, 0, 0, 0] - def v2_weights(self) -> bool: - return len(self.weights) > 5 - class _ViewCallback: __slots__ = ('view', 'callback', 'item') From eb2996d91ee526d35313f2c6719d94c74f08e041 Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Fri, 16 May 2025 11:32:10 +0200 Subject: [PATCH 139/272] Make Container._init_children more similar to BaseView._init_children --- discord/ui/container.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index 633af8360335..28ae23039d75 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -175,23 +175,15 @@ def _init_children(self) -> List[Item[Any]]: for name, raw in self.__container_children_items__.items(): if isinstance(raw, Item): - if getattr(raw, '__discord_ui_action_row__', False): - item = copy.deepcopy(raw) - # we need to deepcopy this object and set it later to prevent - # errors reported on the bikeshedding post - item._parent = self - + item = copy.deepcopy(raw) + item._parent = self + if getattr(item, '__discord_ui_action_row__', False): if item.is_dispatchable(): self.__dispatchable.extend(item._children) # type: ignore - if getattr(raw, '__discord_ui_section__', False): - item = copy.copy(raw) + if getattr(item, '__discord_ui_section__', False): if item.accessory.is_dispatchable(): # type: ignore - item.accessory = copy.deepcopy(item.accessory) # type: ignore if item.accessory._provided_custom_id is False: # type: ignore item.accessory.custom_id = os.urandom(16).hex() # type: ignore - else: - item = copy.copy(raw) - if getattr(item, '__discord_ui_section__', False) and item.accessory.is_dispatchable(): # type: ignore self.__dispatchable.append(item.accessory) # type: ignore From 98b632283261b4686470e748d07112b85f7f631a Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Fri, 16 May 2025 11:33:15 +0200 Subject: [PATCH 140/272] if --- discord/ui/container.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index 28ae23039d75..0e721e6232c5 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -184,8 +184,7 @@ def _init_children(self) -> List[Item[Any]]: if item.accessory.is_dispatchable(): # type: ignore if item.accessory._provided_custom_id is False: # type: ignore item.accessory.custom_id = os.urandom(16).hex() # type: ignore - if getattr(item, '__discord_ui_section__', False) and item.accessory.is_dispatchable(): # type: ignore - self.__dispatchable.append(item.accessory) # type: ignore + self.__dispatchable.append(item.accessory) # type: ignore setattr(self, name, item) children.append(item) From 4005399f936bbf0b63f7c38526006d4d109bdd31 Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Fri, 16 May 2025 11:34:39 +0200 Subject: [PATCH 141/272] chore: remove setting default row --- discord/ui/view.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index 1e988d55b304..964eb82e1373 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -766,14 +766,11 @@ def __init_subclass__(cls) -> None: children: Dict[str, ItemLike] = {} callback_children: Dict[str, ItemCallbackType[Any]] = {} - row = 0 - for base in reversed(cls.__mro__): for name, member in base.__dict__.items(): if isinstance(member, Item): - member._rendered_row = member._row or row + member._rendered_row = member._row children[name] = member - row += 1 elif hasattr(member, '__discord_ui_model_type__') and getattr(member, '__discord_ui_parent__', None): callback_children[name] = member From 2f029c3976368e43f1300c587d99dbf7fb1babeb Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Fri, 16 May 2025 15:51:43 +0200 Subject: [PATCH 142/272] fix: Container rows --- discord/ui/container.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index 0e721e6232c5..fce21dd4beab 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -26,7 +26,6 @@ import copy import os import sys -from itertools import groupby from typing import ( TYPE_CHECKING, Any, @@ -107,7 +106,7 @@ async def a_button(self, interaction: discord.Interaction, button: discord.ui.Bu # or use it directly on LayoutView class MyView(ui.LayoutView): - container = ui.Container([ui.TextDisplay('I am a text display on a container!')]) + container = ui.Container(ui.TextDisplay('I am a text display on a container!')) # or you can use your subclass: # container = MyContainer() @@ -269,9 +268,9 @@ def _is_v2(self) -> bool: def to_components(self) -> List[Dict[str, Any]]: components = [] - key = lambda i: i._rendered_row or i._row or sys.maxsize - for _, comps in groupby(self._children, key=key): - components.extend(c.to_component_dict() for c in comps) + key = lambda i: (i._rendered_row or i._row or sys.maxsize) + 1 + for i in sorted(self._children, key=key): + components.append(i.to_component_dict()) return components def to_component_dict(self) -> Dict[str, Any]: From acd17d8713cf6e070d7d756bc79ef01a14e428a1 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Fri, 16 May 2025 22:43:56 +0200 Subject: [PATCH 143/272] chore: Update LayoutView.to_components() --- discord/ui/view.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index 964eb82e1373..1f2e9848d98b 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -785,9 +785,9 @@ def to_components(self): # sorted by row, which in LayoutView indicates the position of the component in the # payload instead of in which ActionRow it should be placed on. - key = lambda i: i._rendered_row or i._row or sys.maxsize - for _, cmps in groupby(self._children, key=key): - components.extend(c.to_component_dict() for c in cmps) + key = lambda i: (i._rendered_row or i._row or sys.maxsize) + 1 + for i in sorted(self._children, key=key): + components.append(i.to_component_dict()) return components From 1b676df69b9a52fad0c9f87b2bf23d67c62a1056 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 18 May 2025 12:11:01 +0200 Subject: [PATCH 144/272] chore: Improve documentation on MediaGallery(Item) and ui.File --- discord/components.py | 8 +-- discord/ui/file.py | 2 +- discord/ui/media_gallery.py | 97 +++++++++++++++++++++++++++++++------ 3 files changed, 87 insertions(+), 20 deletions(-) diff --git a/discord/components.py b/discord/components.py index 22c3d714cb54..4f69a80dc503 100644 --- a/discord/components.py +++ b/discord/components.py @@ -905,7 +905,9 @@ class UnfurledMediaItem(AssetMixin): Parameters ---------- url: :class:`str` - The URL of this media item. + The URL of this media item. This can be an arbitrary url or a reference to a local + file uploaded as an attachment within the message, which can be accessed with the + ``attachment://`` format. Attributes ---------- @@ -990,8 +992,8 @@ class MediaGalleryItem: ---------- media: Union[:class:`str`, :class:`UnfurledMediaItem`] The media item data. This can be a string representing a local - file uploaded as an attachment in the message, that can be accessed - using the ``attachment://file-name.extension`` format. + file uploaded as an attachment in the message, which can be accessed + using the ``attachment://`` format, or an arbitrary url. description: Optional[:class:`str`] The description to show within this item. Up to 256 characters. Defaults to ``None``. diff --git a/discord/ui/file.py b/discord/ui/file.py index 5d9014e72acb..09d557a892ed 100644 --- a/discord/ui/file.py +++ b/discord/ui/file.py @@ -63,7 +63,7 @@ class MyView(ui.LayoutView): media: Union[:class:`str`, :class:`.UnfurledMediaItem`] This file's media. If this is a string it must point to a local file uploaded within the parent view of this item, and must - meet the ``attachment://filename.extension`` structure. + meet the ``attachment://`` format. spoiler: :class:`bool` Whether to flag this file as a spoiler. Defaults to ``False``. row: Optional[:class:`int`] diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index bbecc96494db..e7346bf690e5 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -23,13 +23,14 @@ """ from __future__ import annotations -from typing import TYPE_CHECKING, List, Literal, Optional, TypeVar +from typing import TYPE_CHECKING, List, Literal, Optional, TypeVar, Union from .item import Item from ..enums import ComponentType from ..components import ( MediaGalleryItem, MediaGalleryComponent, + UnfurledMediaItem, ) if TYPE_CHECKING: @@ -100,12 +101,49 @@ def to_component_dict(self): def _is_v2(self) -> bool: return True - def add_item(self, item: MediaGalleryItem) -> Self: + def add_item( + self, + *, + media: Union[str, UnfurledMediaItem], + description: Optional[str] = None, + spoiler: bool = False, + ) -> Self: """Adds an item to this gallery. This function returns the class instance to allow for fluent-style chaining. + Parameters + ---------- + media: Union[:class:`str`, :class:`.UnfurledMediaItem`] + The media item data. This can be a string representing a local + file uploaded as an attachment in the message, which can be accessed + using the ``attachment://`` format, or an arbitrary url. + description: Optional[:class:`str`] + The description to show within this item. Up to 256 characters. Defaults + to ``None``. + spoiler: :class:`bool` + Whether this item should be flagged as a spoiler. Defaults to ``False``. + + Raises + ------ + ValueError + Maximum number of items has been exceeded (10). + """ + + if len(self._underlying.items) >= 10: + raise ValueError('maximum number of items has been exceeded') + + item = MediaGalleryItem(media, description=description, spoiler=spoiler) + self._underlying.items.append(item) + return self + + def append_item(self, item: MediaGalleryItem) -> Self: + """Appends an item to this gallery. + + This function returns the class instance to allow for fluent-style + chaining. + Parameters ---------- item: :class:`.MediaGalleryItem` @@ -128,39 +166,66 @@ def add_item(self, item: MediaGalleryItem) -> Self: self._underlying.items.append(item) return self - def remove_item(self, item: MediaGalleryItem) -> Self: - """Removes an item from the gallery. + def insert_item_at( + self, + index: int, + *, + media: Union[str, UnfurledMediaItem], + description: Optional[str] = None, + spoiler: bool = False, + ) -> Self: + """Inserts an item before a specified index to the media gallery. This function returns the class instance to allow for fluent-style chaining. Parameters ---------- - item: :class:`.MediaGalleryItem` - The item to remove from the gallery. + index: :class:`int` + The index of where to insert the field. + media: Union[:class:`str`, :class:`.UnfurledMediaItem`] + The media item data. This can be a string representing a local + file uploaded as an attachment in the message, which can be accessed + using the ``attachment://`` format, or an arbitrary url. + description: Optional[:class:`str`] + The description to show within this item. Up to 256 characters. Defaults + to ``None``. + spoiler: :class:`bool` + Whether this item should be flagged as a spoiler. Defaults to ``False``. + + Raises + ------ + ValueError + Maximum number of items has been exceeded (10). """ - try: - self._underlying.items.remove(item) - except ValueError: - pass + if len(self._underlying.items) >= 10: + raise ValueError('maximum number of items has been exceeded') + + item = MediaGalleryItem( + media, + description=description, + spoiler=spoiler, + ) + self._underlying.items.insert(index, item) return self - def insert_item_at(self, index: int, item: MediaGalleryItem) -> Self: - """Inserts an item before a specified index to the gallery. + def remove_item(self, item: MediaGalleryItem) -> Self: + """Removes an item from the gallery. This function returns the class instance to allow for fluent-style chaining. Parameters ---------- - index: :class:`int` - The index of where to insert the item. item: :class:`.MediaGalleryItem` - The item to insert. + The item to remove from the gallery. """ - self._underlying.items.insert(index, item) + try: + self._underlying.items.remove(item) + except ValueError: + pass return self def clear_items(self) -> Self: From 6e302a37ac3261d0230ee7e87958f476b4918652 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Mon, 19 May 2025 20:37:17 +0200 Subject: [PATCH 145/272] fix: is_persistent returning wrong values --- discord/ui/action_row.py | 2 +- discord/ui/section.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 764ce1dc097f..167d1664f1da 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -205,7 +205,7 @@ def is_dispatchable(self) -> bool: return any(c.is_dispatchable() for c in self.children) def is_persistent(self) -> bool: - return self.is_dispatchable() and all(c.is_persistent() for c in self.children) + return all(c.is_persistent() for c in self.children) def _update_children_view(self, view: LayoutView) -> None: for child in self._children: diff --git a/discord/ui/section.py b/discord/ui/section.py index c38040346dc9..dd04431e6c65 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -118,7 +118,7 @@ def is_dispatchable(self) -> bool: return self.accessory.is_dispatchable() def is_persistent(self) -> bool: - return self.is_dispatchable() and self.accessory.is_persistent() + return self.accessory.is_persistent() def walk_children(self) -> Generator[Item[V], None, None]: """An iterator that recursively walks through all the children of this section. From 8e03c3a740af31ad0158f48ebc25b366ae9a97dc Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Mon, 19 May 2025 20:47:02 +0200 Subject: [PATCH 146/272] fix: is_persistent in container.py --- discord/ui/container.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index fce21dd4beab..1837e3d71830 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -212,7 +212,7 @@ def is_dispatchable(self) -> bool: return bool(self.__dispatchable) def is_persistent(self) -> bool: - return self.is_dispatchable() and all(c.is_persistent() for c in self.children) + return all(c.is_persistent() for c in self.children) def __init_subclass__(cls) -> None: super().__init_subclass__() From e3294223b6faad87723b537cf145459dc4656bcd Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 20 May 2025 19:33:36 +0200 Subject: [PATCH 147/272] feat: Add (Layout)View.from_dict methods --- discord/ui/view.py | 202 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 200 insertions(+), 2 deletions(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index 1f2e9848d98b..cd9c81958977 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -49,11 +49,15 @@ import time import os import copy + from .item import Item, ItemCallbackType from .dynamic import DynamicItem from ..components import ( Component, ActionRow as ActionRowComponent, + MediaGalleryItem, + SelectDefaultValue, + UnfurledMediaItem, _component_factory, Button as ButtonComponent, SelectMenu as SelectComponent, @@ -63,8 +67,11 @@ FileComponent, SeparatorComponent, ThumbnailComponent, + SelectOption, ) -from ..utils import get as _utils_get +from ..utils import MISSING, get as _utils_get, _get_as_snowflake +from ..enums import SeparatorSize, TextStyle, try_enum, ButtonStyle +from ..emoji import PartialEmoji # fmt: off __all__ = ( @@ -80,7 +87,7 @@ from ..interactions import Interaction from ..message import Message - from ..types.components import ComponentBase as ComponentBasePayload + from ..types.components import ComponentBase as ComponentBasePayload, Component as ComponentPayload from ..types.interactions import ModalSubmitComponentInteractionData as ModalSubmitComponentInteractionDataPayload from ..state import ConnectionState from .modal import Modal @@ -100,6 +107,10 @@ def _walk_all_components(components: List[Component]) -> Iterator[Component]: def _component_to_item(component: Component) -> Item: + if isinstance(component, ActionRowComponent): + from .action_row import ActionRow + + return ActionRow.from_component(component) if isinstance(component, ButtonComponent): from .button import Button @@ -136,6 +147,141 @@ def _component_to_item(component: Component) -> Item: return Item.from_component(component) +def _component_data_to_item(data: ComponentPayload) -> Item: + if data['type'] == 1: + from .action_row import ActionRow + + return ActionRow( + *(_component_data_to_item(c) for c in data['components']), + id=data.get('id'), + ) + elif data['type'] == 2: + from .button import Button + + emoji = data.get('emoji') + + return Button( + style=try_enum(ButtonStyle, data['style']), + custom_id=data.get('custom_id'), + url=data.get('url'), + disabled=data.get('disabled', False), + emoji=PartialEmoji.from_dict(emoji) if emoji else None, + label=data.get('label'), + sku_id=_get_as_snowflake(data, 'sku_id'), + ) + elif data['type'] == 3: + from .select import Select + + return Select( + custom_id=data['custom_id'], + placeholder=data.get('placeholder'), + min_values=data.get('min_values', 1), + max_values=data.get('max_values', 1), + disabled=data.get('disabled', False), + id=data.get('id'), + options=[ + SelectOption.from_dict(o) + for o in data.get('options', []) + ], + ) + elif data['type'] == 4: + from .text_input import TextInput + + return TextInput( + label=data['label'], + style=try_enum(TextStyle, data['style']), + custom_id=data['custom_id'], + placeholder=data.get('placeholder'), + default=data.get('value'), + required=data.get('required', True), + min_length=data.get('min_length'), + max_length=data.get('max_length'), + id=data.get('id'), + ) + elif data['type'] in (5, 6, 7, 8): + from .select import ( + UserSelect, + RoleSelect, + MentionableSelect, + ChannelSelect, + ) + + cls_map: Dict[int, Type[Union[UserSelect, RoleSelect, MentionableSelect, ChannelSelect]]] = { + 5: UserSelect, + 6: RoleSelect, + 7: MentionableSelect, + 8: ChannelSelect, + } + + return cls_map[data['type']]( + custom_id=data['custom_id'], # type: ignore # will always be present in this point + placeholder=data.get('placeholder'), + min_values=data.get('min_values', 1), + max_values=data.get('max_values', 1), + disabled=data.get('disabled', False), + default_values=[ + SelectDefaultValue.from_dict(v) + for v in data.get('default_values', []) + ], + id=data.get('id'), + ) + elif data['type'] == 9: + from .section import Section + + return Section( + *(_component_data_to_item(c) for c in data['components']), + accessory=_component_data_to_item(data['accessory']), + id=data.get('id'), + ) + elif data['type'] == 10: + from .text_display import TextDisplay + + return TextDisplay(data['content'], id=data.get('id')) + elif data['type'] == 11: + from .thumbnail import Thumbnail + + return Thumbnail( + UnfurledMediaItem._from_data(data['media'], None), + description=data.get('description'), + spoiler=data.get('spoiler', False), + id=data.get('id'), + ) + elif data['type'] == 12: + from .media_gallery import MediaGallery + + return MediaGallery( + *(MediaGalleryItem._from_data(m, None) for m in data['items']), + id=data.get('id'), + ) + elif data['type'] == 13: + from .file import File + + return File( + UnfurledMediaItem._from_data(data['file'], None), + spoiler=data.get('spoiler', False), + id=data.get('id'), + ) + elif data['type'] == 14: + from .separator import Separator + + return Separator( + visible=data.get('divider', True), + spacing=try_enum(SeparatorSize, data.get('spacing', 1)), + id=data.get('id'), + ) + elif data['type'] == 17: + from .container import Container + + return Container( + *(_component_data_to_item(c) for c in data['components']), + accent_colour=data.get('accent_color'), + spoiler=data.get('spoiler', False), + id=data.get('type'), + ) + else: + raise ValueError(f'invalid item with type {data["type"]} provided') + + class _ViewWeights: # fmt: off __slots__ = ( @@ -599,6 +745,28 @@ def walk_children(self) -> Generator[Item[Any], None, None]: # if it has this attribute then it can contain children yield from child.walk_children() # type: ignore + @classmethod + def _to_minimal_cls(cls) -> Type[Union[View, LayoutView]]: + if issubclass(cls, View): + return View + elif issubclass(cls, LayoutView): + return LayoutView + raise RuntimeError + + @classmethod + def from_dict(cls, data: List[ComponentPayload], *, timeout: Optional[float] = 180.0) -> Any: + cls = cls._to_minimal_cls() + self = cls(timeout=timeout) + + for raw in data: + item = _component_data_to_item(raw) + + if item._is_v2() and not self._is_v2(): + continue + + self.add_item(item) + return self + class View(BaseView): """Represents a UI view. @@ -616,6 +784,21 @@ class View(BaseView): __discord_ui_view__: ClassVar[bool] = True + if TYPE_CHECKING: + @classmethod + def from_dict(cls, data: List[ComponentPayload], *, timeout: Optional[float] = 180.0) -> View: + """Converts a :class:`list` of :class:`dict` s to a :class:`View` provided it is in the + format that Discord expects it to be in. + + You can find out about this format in the :ddocs:`official Discord documentation `. + + Parameters + ---------- + data: List[:class:`dict`] + The array of dictionaries to convert into a View. + """ + ... + def __init_subclass__(cls) -> None: super().__init_subclass__() @@ -754,6 +937,21 @@ class LayoutView(BaseView): __discord_ui_layout_view__: ClassVar[bool] = True + if TYPE_CHECKING: + @classmethod + def from_dict(cls, data: List[ComponentPayload], *, timeout: Optional[float] = 180.0) -> LayoutView: + """Converts a :class:`list` of :class:`dict` s to a :class:`LayoutView` provided it is in the + format that Discord expects it to be in. + + You can find out about this format in the :ddocs:`official Discord documentation `. + + Parameters + ---------- + data: List[:class:`dict`] + The array of dictionaries to convert into a LayoutView. + """ + ... + def __init__(self, *, timeout: Optional[float] = 180.0) -> None: super().__init__(timeout=timeout) From f63f9f638251e843a68a6abc80295a951bb16602 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 20 May 2025 19:33:52 +0200 Subject: [PATCH 148/272] chore: Consistency on types --- discord/types/components.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/discord/types/components.py b/discord/types/components.py index bb241c9ac6ff..a458a1d96e5a 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -34,7 +34,7 @@ ButtonStyle = Literal[1, 2, 3, 4, 5, 6] TextStyle = Literal[1, 2] DefaultValueType = Literal["user", "role", "channel"] -DividerSize = Literal[1, 2] +SeparatorSize = Literal[1, 2] MediaItemLoadingState = Literal[0, 1, 2, 3] @@ -128,7 +128,7 @@ class SelectMenu(SelectComponent): class SectionComponent(ComponentBase): type: Literal[9] components: List[Union[TextComponent, ButtonComponent]] - accessory: ComponentBase + accessory: Component class TextComponent(ComponentBase): @@ -174,7 +174,7 @@ class FileComponent(ComponentBase): class SeparatorComponent(ComponentBase): type: Literal[14] divider: NotRequired[bool] - spacing: NotRequired[DividerSize] + spacing: NotRequired[SeparatorSize] class ContainerComponent(ComponentBase): From 22daf24830b7a31e1a444fbfc22f45bc56718a92 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 20 May 2025 19:54:06 +0200 Subject: [PATCH 149/272] fix: View.from_dict raising unexpected errors --- discord/components.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/components.py b/discord/components.py index 4f69a80dc503..e9812c5180f7 100644 --- a/discord/components.py +++ b/discord/components.py @@ -965,7 +965,7 @@ def _from_data(cls, data: UnfurledMediaItemPayload, state: Optional[ConnectionSt return self def _update(self, data: UnfurledMediaItemPayload, state: Optional[ConnectionState]) -> None: - self.proxy_url = data['proxy_url'] + self.proxy_url = data.get('proxy_url') self.height = data.get('height') self.width = data.get('width') self.content_type = data.get('content_type') From b4b7a7493a0d5733103869190ed45fdcf36b3677 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 20 May 2025 19:54:45 +0200 Subject: [PATCH 150/272] yet more fixes --- discord/components.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/discord/components.py b/discord/components.py index e9812c5180f7..bb3cbd6252e7 100644 --- a/discord/components.py +++ b/discord/components.py @@ -971,7 +971,10 @@ def _update(self, data: UnfurledMediaItemPayload, state: Optional[ConnectionStat self.content_type = data.get('content_type') self._flags = data.get('flags', 0) self.placeholder = data.get('placeholder') - self.loading_state = try_enum(MediaItemLoadingState, data['loading_state']) + + loading_state = data.get('loading_state') + if loading_state is not None: + self.loading_state = try_enum(MediaItemLoadingState, loading_state) self._state = state def __repr__(self) -> str: From 5489806a624e5a536446455a08844111e1be9fb1 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 20 May 2025 20:36:22 +0200 Subject: [PATCH 151/272] fix: row not being respected when being 0 --- discord/ui/action_row.py | 8 +++++++- discord/ui/container.py | 8 +++++++- discord/ui/section.py | 10 +++++++++- discord/ui/view.py | 9 ++++++++- 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 167d1664f1da..4f27de4f1c4e 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -338,7 +338,13 @@ def clear_items(self) -> Self: def to_component_dict(self) -> Dict[str, Any]: components = [] - key = lambda i: i._rendered_row or i._row or sys.maxsize + def key(item: Item) -> int: + if item._rendered_row is not None: + return item._rendered_row + if item._row is not None: + return item._row + return sys.maxsize + for _, cmps in groupby(self._children, key=key): components.extend(c.to_component_dict() for c in cmps) diff --git a/discord/ui/container.py b/discord/ui/container.py index 1837e3d71830..c7410423793d 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -268,7 +268,13 @@ def _is_v2(self) -> bool: def to_components(self) -> List[Dict[str, Any]]: components = [] - key = lambda i: (i._rendered_row or i._row or sys.maxsize) + 1 + def key(item: Item) -> int: + if item._rendered_row is not None: + return item._rendered_row + if item._row is not None: + return item._row + return sys.maxsize + for i in sorted(self._children, key=key): components.append(i.to_component_dict()) return components diff --git a/discord/ui/section.py b/discord/ui/section.py index dd04431e6c65..fcd2002e9a9b 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -239,7 +239,15 @@ def from_component(cls, component: SectionComponent) -> Self: def to_components(self) -> List[Dict[str, Any]]: components = [] - for _, comps in groupby(self._children, key=lambda i: i._rendered_row or i._row or sys.maxsize): + + def key(item: Item) -> int: + if item._rendered_row is not None: + return item._rendered_row + if item._row is not None: + return item._row + return sys.maxsize + + for _, comps in groupby(self._children, key=key): components.extend(c.to_component_dict() for c in comps) return components diff --git a/discord/ui/view.py b/discord/ui/view.py index cd9c81958977..e877457d72fb 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -983,7 +983,14 @@ def to_components(self): # sorted by row, which in LayoutView indicates the position of the component in the # payload instead of in which ActionRow it should be placed on. - key = lambda i: (i._rendered_row or i._row or sys.maxsize) + 1 + + def key(item: Item) -> int: + if item._rendered_row is not None: + return item._rendered_row + if item._row is not None: + return item._row + return sys.maxsize + for i in sorted(self._children, key=key): components.append(i.to_component_dict()) From c9e0f35453101d4259850c94033fe28a3ffeb011 Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 20 May 2025 20:42:19 +0200 Subject: [PATCH 152/272] fix: linting --- discord/ui/view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index e877457d72fb..e978ef388505 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -69,7 +69,7 @@ ThumbnailComponent, SelectOption, ) -from ..utils import MISSING, get as _utils_get, _get_as_snowflake +from ..utils import get as _utils_get, _get_as_snowflake from ..enums import SeparatorSize, TextStyle, try_enum, ButtonStyle from ..emoji import PartialEmoji From 03af02b4b55e96f90947e18f34587249a51431ab Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 20 May 2025 21:15:17 +0200 Subject: [PATCH 153/272] chore: Run black and fix linting errors --- discord/components.py | 3 +-- discord/ui/view.py | 12 ++++-------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/discord/components.py b/discord/components.py index bb3cbd6252e7..4767a1a3ebbb 100644 --- a/discord/components.py +++ b/discord/components.py @@ -54,7 +54,6 @@ from typing_extensions import Self from .types.components import ( - ComponentBase as ComponentBasePayload, Component as ComponentPayload, ButtonComponent as ButtonComponentPayload, SelectMenu as SelectMenuPayload, @@ -160,7 +159,7 @@ def _raw_construct(cls, **kwargs) -> Self: setattr(self, slot, value) return self - def to_dict(self) -> ComponentBasePayload: + def to_dict(self) -> ComponentPayload: raise NotImplementedError diff --git a/discord/ui/view.py b/discord/ui/view.py index e978ef388505..1c0783e7c128 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -179,10 +179,7 @@ def _component_data_to_item(data: ComponentPayload) -> Item: max_values=data.get('max_values', 1), disabled=data.get('disabled', False), id=data.get('id'), - options=[ - SelectOption.from_dict(o) - for o in data.get('options', []) - ], + options=[SelectOption.from_dict(o) for o in data.get('options', [])], ) elif data['type'] == 4: from .text_input import TextInput @@ -219,10 +216,7 @@ def _component_data_to_item(data: ComponentPayload) -> Item: min_values=data.get('min_values', 1), max_values=data.get('max_values', 1), disabled=data.get('disabled', False), - default_values=[ - SelectDefaultValue.from_dict(v) - for v in data.get('default_values', []) - ], + default_values=[SelectDefaultValue.from_dict(v) for v in data.get('default_values', [])], id=data.get('id'), ) elif data['type'] == 9: @@ -785,6 +779,7 @@ class View(BaseView): __discord_ui_view__: ClassVar[bool] = True if TYPE_CHECKING: + @classmethod def from_dict(cls, data: List[ComponentPayload], *, timeout: Optional[float] = 180.0) -> View: """Converts a :class:`list` of :class:`dict` s to a :class:`View` provided it is in the @@ -938,6 +933,7 @@ class LayoutView(BaseView): __discord_ui_layout_view__: ClassVar[bool] = True if TYPE_CHECKING: + @classmethod def from_dict(cls, data: List[ComponentPayload], *, timeout: Optional[float] = 180.0) -> LayoutView: """Converts a :class:`list` of :class:`dict` s to a :class:`LayoutView` provided it is in the From 3d37331cbaac332112ff5e1a654e0b607465a895 Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 20 May 2025 23:20:44 +0200 Subject: [PATCH 154/272] chore: Co-authored-by: Soheab <33902984+Soheab@users.noreply.github.com> --- discord/ui/container.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index c7410423793d..8f6d425d1982 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -81,9 +81,6 @@ class Container(Item[V]): This can be inherited. - .. note:: - - Containers can contain up to 10 top-level components. .. versionadded:: 2.6 From fe3b5963793e76cce94a0706e85738c2ac68e287 Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 20 May 2025 23:21:40 +0200 Subject: [PATCH 155/272] fix: unexpected behaviour on accent_colours Co-authored-by: Soheab <33902984+Soheab@users.noreply.github.com> --- discord/ui/container.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index 8f6d425d1982..ed1ba80b001d 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -151,7 +151,7 @@ def __init__( self.add_item(child) self.spoiler: bool = spoiler - self._colour = accent_colour or accent_color + self._colour = accent_colour if accent_colour is not None else accent_color self.row = row self.id = id From cf21c5bf80ba238fc768cb923b4ef5a854a5783f Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 20 May 2025 23:23:56 +0200 Subject: [PATCH 156/272] chore: revert double quotes to single quotes --- discord/types/components.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/types/components.py b/discord/types/components.py index a458a1d96e5a..332701ef500c 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -33,7 +33,7 @@ ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 17] ButtonStyle = Literal[1, 2, 3, 4, 5, 6] TextStyle = Literal[1, 2] -DefaultValueType = Literal["user", "role", "channel"] +DefaultValueType = Literal['user', 'role', 'channel'] SeparatorSize = Literal[1, 2] MediaItemLoadingState = Literal[0, 1, 2, 3] From 0a8d9cbf02499ce05346e2fdd39e73674ec69a7c Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 20 May 2025 23:29:25 +0200 Subject: [PATCH 157/272] chore: check types on Container.accent_colour setter --- discord/ui/container.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/discord/ui/container.py b/discord/ui/container.py index ed1ba80b001d..cfce5ef2f1e3 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -247,6 +247,9 @@ def accent_colour(self) -> Optional[Union[Colour, int]]: @accent_colour.setter def accent_colour(self, value: Optional[Union[Colour, int]]) -> None: + if not isinstance(value, (int, Colour)): + raise TypeError(f'expected an int, or Colour, not {value.__class__.__name__!r}') + self._colour = value accent_color = accent_colour From f0c0e40ba981a06375f7e66c310e997c82845a67 Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Wed, 21 May 2025 13:03:20 +0200 Subject: [PATCH 158/272] fix: Colo(u)r being on TYPE_CHECKING block --- discord/ui/container.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index cfce5ef2f1e3..77dee7fa47bb 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -47,11 +47,11 @@ from .dynamic import DynamicItem from ..enums import ComponentType from ..utils import MISSING, get as _utils_get +from ..colour import Colour, Color if TYPE_CHECKING: from typing_extensions import Self - from ..colour import Colour, Color from ..components import Container as ContainerComponent from ..interactions import Interaction From d65437b4daafcc70d5b8e4632e0e699a30d1c294 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 27 May 2025 18:19:17 +0200 Subject: [PATCH 159/272] chore: QoL changes on BaseView.from_message and BaseView.from_dict and ActionRow.to_component_dict() --- discord/ui/action_row.py | 5 +- discord/ui/view.py | 171 +++++++++++++++++---------------------- 2 files changed, 74 insertions(+), 102 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 4f27de4f1c4e..dfb93878d48a 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -24,7 +24,6 @@ from __future__ import annotations import sys -from itertools import groupby from typing import ( TYPE_CHECKING, Any, @@ -345,8 +344,8 @@ def key(item: Item) -> int: return item._row return sys.maxsize - for _, cmps in groupby(self._children, key=key): - components.extend(c.to_component_dict() for c in cmps) + for component in sorted(self.children, key=key): + components.append(component.to_component_dict()) base = { 'type': self.type.value, diff --git a/discord/ui/view.py b/discord/ui/view.py index 1c0783e7c128..99e5f89e2f74 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -446,7 +446,16 @@ def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) The :attr:`.Message.components` of a message are read-only and separate types from those in the ``discord.ui`` namespace. In order to modify and edit message components they must be - converted into a :class:`View` first. + converted into a :class:`View` or :class:`LayoutView` first. + + If the message has any v2 component, then you must use + :class:`LayoutView` in order for them to be converted into + their respective items. + + This method should be called on the respective class (or subclass), so + if you want to convert v2 items, you should call :meth:`LayoutView.from_message`, + or the same method from any subclass of it; and not :meth:`View.from_message`, or the + same method from any subclass of it. Parameters ----------- @@ -454,8 +463,40 @@ def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) The message with components to convert into a view. timeout: Optional[:class:`float`] The timeout of the converted view. + + Returns + ------- + Union[:class:`View`, :class:`LayoutView`] + The converted view. This will always return one of :class:`View` or + :class:`LayoutView`, and not one of its subclasses. """ - pass + cls = cls._to_minimal_cls() + view = cls(timeout=timeout) + row = 0 + + for component in message.components: + if not view._is_v2() and isinstance(component, ActionRowComponent): + for child in component.children: + item = _component_to_item(child) + item.row = row + # this error should never be raised, because ActionRows can only + # contain items that View accepts, but check anyways + if item._is_v2(): + raise RuntimeError(f'{item.__class__.__name__} cannot be added to {view.__class__.__name__}') + view.add_item(item) + row += 1 + continue + + item = _component_to_item(component) + item.row = row + + if item._is_v2() and not view._is_v2(): + raise RuntimeError(f'{item.__class__.__name__} cannot be added to {view.__class__.__name__}') + + view.add_item(item) + row += 1 + + return view def add_item(self, item: Item[Any]) -> Self: """Adds an item to the view. @@ -749,6 +790,29 @@ def _to_minimal_cls(cls) -> Type[Union[View, LayoutView]]: @classmethod def from_dict(cls, data: List[ComponentPayload], *, timeout: Optional[float] = 180.0) -> Any: + r"""Converts a :class:`list` of :class:`dict`\s to a :class:`View` or :class:`LayoutView`, + provided as in the format that Discord expects it to be in. + + You can find out about this format in the :ddocs:`official Discord documentation `. + + This method shuold be called on the respective class (or subclass), so if you + want to convert v2 items, you should call :meth:`LayoutView.from_dict`, or the same + method from any subclass of it; and not :meth:`View.from_message`, or the same + method from any subclass of it. + + Parameters + ---------- + data: List[:class:`dict`] + The array of dictionaries to convert into a LayoutView + timeout: Optional[:class:`float`] + The timeout of the converted view. + + Returns + ------- + Union[:class:`View`, :class:`LayoutView`] + The converted view. This will always return one of :class:`View` or + :class:`LayoutView`, and not one of its subclasses. + """ cls = cls._to_minimal_cls() self = cls(timeout=timeout) @@ -782,16 +846,10 @@ class View(BaseView): @classmethod def from_dict(cls, data: List[ComponentPayload], *, timeout: Optional[float] = 180.0) -> View: - """Converts a :class:`list` of :class:`dict` s to a :class:`View` provided it is in the - format that Discord expects it to be in. - - You can find out about this format in the :ddocs:`official Discord documentation `. + ... - Parameters - ---------- - data: List[:class:`dict`] - The array of dictionaries to convert into a View. - """ + @classmethod + def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> View: ... def __init_subclass__(cls) -> None: @@ -838,53 +896,6 @@ def key(item: Item) -> int: return components - @classmethod - def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> View: - """Converts a message's components into a :class:`View`. - - The :attr:`.Message.components` of a message are read-only - and separate types from those in the ``discord.ui`` namespace. - In order to modify and edit message components they must be - converted into a :class:`View` first. - - .. warning:: - - This **will not** take into account every v2 component, if you - want to edit them, use :meth:`LayoutView.from_message` instead. - - Parameters - ----------- - message: :class:`discord.Message` - The message with components to convert into a view. - timeout: Optional[:class:`float`] - The timeout of the converted view. - - Returns - -------- - :class:`View` - The converted view. This always returns a :class:`View` and not - one of its subclasses. - """ - view = View(timeout=timeout) - row = 0 - for component in message.components: - if isinstance(component, ActionRowComponent): - for child in component.children: - item = _component_to_item(child) - item.row = row - if item._is_v2(): - raise RuntimeError('v2 components cannot be added to this View') - view.add_item(item) - row += 1 - else: - item = _component_to_item(component) - item.row = row - if item._is_v2(): - raise RuntimeError('v2 components cannot be added to this View') - view.add_item(item) - - return view - def add_item(self, item: Item[Any]) -> Self: if len(self._children) >= 25: raise ValueError('maximum number of children exceeded') @@ -936,16 +947,10 @@ class LayoutView(BaseView): @classmethod def from_dict(cls, data: List[ComponentPayload], *, timeout: Optional[float] = 180.0) -> LayoutView: - """Converts a :class:`list` of :class:`dict` s to a :class:`LayoutView` provided it is in the - format that Discord expects it to be in. - - You can find out about this format in the :ddocs:`official Discord documentation `. + ... - Parameters - ---------- - data: List[:class:`dict`] - The array of dictionaries to convert into a LayoutView. - """ + @classmethod + def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> LayoutView: ... def __init__(self, *, timeout: Optional[float] = 180.0) -> None: @@ -998,38 +1003,6 @@ def add_item(self, item: Item[Any]) -> Self: super().add_item(item) return self - @classmethod - def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> LayoutView: - """Converts a message's components into a :class:`LayoutView`. - - The :attr:`.Message.components` of a message are read-only - and separate types from those in the ``discord.ui`` namespace. - In order to modify and edit message components they must be - converted into a :class:`LayoutView` first. - - Unlike :meth:`View.from_message` this converts v2 components. - - Parameters - ----------- - message: :class:`discord.Message` - The message with components to convert into a view. - timeout: Optional[:class:`float`] - The timeout of the converted view. - - Returns - -------- - :class:`LayoutView` - The converted view. This always returns a :class:`LayoutView` and not - one of its subclasses. - """ - view = LayoutView(timeout=timeout) - for component in message.components: - item = _component_to_item(component) - item.row = 0 - view.add_item(item) - - return view - class ViewStore: def __init__(self, state: ConnectionState): From 42bd21a7008ebce2de644b8941ece0c4468eeb2a Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 27 May 2025 18:21:04 +0200 Subject: [PATCH 160/272] chore(breaking): Rename SeparatorSize to SeparatorSpacing --- discord/components.py | 6 +++--- discord/enums.py | 4 ++-- discord/ui/separator.py | 12 ++++++------ discord/ui/view.py | 4 ++-- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/discord/components.py b/discord/components.py index 4767a1a3ebbb..e5d7bb9befb2 100644 --- a/discord/components.py +++ b/discord/components.py @@ -42,7 +42,7 @@ TextStyle, ChannelType, SelectDefaultValueType, - SeparatorSize, + SeparatorSpacing, MediaItemLoadingState, ) from .flags import AttachmentFlags @@ -1158,7 +1158,7 @@ class SeparatorComponent(Component): Attributes ---------- - spacing: :class:`SeparatorSize` + spacing: :class:`SeparatorSpacing` The spacing size of the separator. visible: :class:`bool` Whether this separator is visible and shows a divider. @@ -1178,7 +1178,7 @@ def __init__( self, data: SeparatorComponentPayload, ) -> None: - self.spacing: SeparatorSize = try_enum(SeparatorSize, data.get('spacing', 1)) + self.spacing: SeparatorSpacing = try_enum(SeparatorSpacing, data.get('spacing', 1)) self.visible: bool = data.get('divider', True) self.id: Optional[int] = data.get('id') diff --git a/discord/enums.py b/discord/enums.py index 49684935f7c7..226e98071091 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -77,7 +77,7 @@ 'VoiceChannelEffectAnimationType', 'SubscriptionStatus', 'MessageReferenceType', - 'SeparatorSize', + 'SeparatorSpacing', 'MediaItemLoadingState', ) @@ -872,7 +872,7 @@ class SubscriptionStatus(Enum): inactive = 2 -class SeparatorSize(Enum): +class SeparatorSpacing(Enum): small = 1 large = 2 diff --git a/discord/ui/separator.py b/discord/ui/separator.py index e7d75a998b95..76c1a5275d03 100644 --- a/discord/ui/separator.py +++ b/discord/ui/separator.py @@ -27,7 +27,7 @@ from .item import Item from ..components import SeparatorComponent -from ..enums import SeparatorSize, ComponentType +from ..enums import SeparatorSpacing, ComponentType if TYPE_CHECKING: from typing_extensions import Self @@ -51,7 +51,7 @@ class Separator(Item[V]): visible: :class:`bool` Whether this separator is visible. On the client side this is whether a divider line should be shown or not. - spacing: :class:`.SeparatorSize` + spacing: :class:`.SeparatorSpacing` The spacing of this separator. row: Optional[:class:`int`] The relative row this separator belongs to. By default @@ -68,7 +68,7 @@ def __init__( self, *, visible: bool = True, - spacing: SeparatorSize = SeparatorSize.small, + spacing: SeparatorSpacing = SeparatorSpacing.small, row: Optional[int] = None, id: Optional[int] = None, ) -> None: @@ -99,12 +99,12 @@ def visible(self, value: bool) -> None: self._underlying.visible = value @property - def spacing(self) -> SeparatorSize: - """:class:`.SeparatorSize`: The spacing of this separator.""" + def spacing(self) -> SeparatorSpacing: + """:class:`.SeparatorSpacing`: The spacing of this separator.""" return self._underlying.spacing @spacing.setter - def spacing(self, value: SeparatorSize) -> None: + def spacing(self, value: SeparatorSpacing) -> None: self._underlying.spacing = value @property diff --git a/discord/ui/view.py b/discord/ui/view.py index 99e5f89e2f74..4a7afa50c1bf 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -70,7 +70,7 @@ SelectOption, ) from ..utils import get as _utils_get, _get_as_snowflake -from ..enums import SeparatorSize, TextStyle, try_enum, ButtonStyle +from ..enums import SeparatorSpacing, TextStyle, try_enum, ButtonStyle from ..emoji import PartialEmoji # fmt: off @@ -260,7 +260,7 @@ def _component_data_to_item(data: ComponentPayload) -> Item: return Separator( visible=data.get('divider', True), - spacing=try_enum(SeparatorSize, data.get('spacing', 1)), + spacing=try_enum(SeparatorSpacing, data.get('spacing', 1)), id=data.get('id'), ) elif data['type'] == 17: From 3dfc609a0d4fa5846b5b771425dbf4c9f0ff419b Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 27 May 2025 19:09:24 +0200 Subject: [PATCH 161/272] fix: SpacingSpace not being found on docs --- docs/interactions/api.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/interactions/api.rst b/docs/interactions/api.rst index b75d33044b71..e13c47fde6c3 100644 --- a/docs/interactions/api.rst +++ b/docs/interactions/api.rst @@ -566,7 +566,7 @@ Enumerations The permission is for a user. -.. class:: SeparatorSize +.. class:: SeparatorSpacing The separator's size type. From d4e738abb270a2890cded949c2ee77e985ce2cb9 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Wed, 28 May 2025 18:40:45 +0200 Subject: [PATCH 162/272] qol things and changes --- discord/ui/action_row.py | 4 ++-- discord/ui/container.py | 6 +++--- discord/ui/dynamic.py | 8 ++------ discord/ui/media_gallery.py | 4 ++-- discord/ui/view.py | 2 +- 5 files changed, 10 insertions(+), 14 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index dfb93878d48a..5cc41de26e32 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -87,10 +87,10 @@ def __call__(self, interaction: Interaction) -> Coroutine[Any, Any, Any]: class ActionRow(Item[V]): - """Represents a UI action row. + r"""Represents a UI action row. This is a top-level layout component that can only be used on :class:`LayoutView` - and can contain :class:`Button` 's and :class:`Select` 's in it. + and can contain :class:`Button`\s and :class:`Select`\s in it. This can be inherited. diff --git a/discord/ui/container.py b/discord/ui/container.py index 77dee7fa47bb..8c550816f4ec 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -73,11 +73,11 @@ def __call__(self, interaction: Interaction) -> Coroutine[Any, Any, Any]: class Container(Item[V]): - """Represents a UI container. + r"""Represents a UI container. This is a top-level layout component that can only be used on :class:`LayoutView` - and can contain :class:`ActionRow` 's, :class:`TextDisplay` 's, :class:`Section` 's, - :class:`MediaGallery` 's, and :class:`File` 's in it. + and can contain :class:`ActionRow`\s, :class:`TextDisplay`\s, :class:`Section`\s, + :class:`MediaGallery`\s, and :class:`File`\s in it. This can be inherited. diff --git a/discord/ui/dynamic.py b/discord/ui/dynamic.py index 667848920ce0..3c7ea0e48eb1 100644 --- a/discord/ui/dynamic.py +++ b/discord/ui/dynamic.py @@ -38,14 +38,10 @@ from ..interactions import Interaction from ..components import Component from ..enums import ComponentType - from .view import BaseView + from .view import View, LayoutView - V = TypeVar('V', bound='BaseView', covariant=True, default=BaseView) -else: - V = TypeVar('V', bound='BaseView', covariant=True) - -class DynamicItem(Generic[BaseT, V], Item[V]): +class DynamicItem(Generic[BaseT], Item[Union[View, LayoutView]]): """Represents an item with a dynamic ``custom_id`` that can be used to store state within that ``custom_id``. diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index e7346bf690e5..8cfbfa1e333c 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -44,9 +44,9 @@ class MediaGallery(Item[V]): - """Represents a UI media gallery. + r"""Represents a UI media gallery. - Can contain up to 10 :class:`.MediaGalleryItem` 's. + Can contain up to 10 :class:`.MediaGalleryItem`\s. This is a top-level layout component that can only be used on :class:`LayoutView`. diff --git a/discord/ui/view.py b/discord/ui/view.py index 4a7afa50c1bf..533339b845bb 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -575,7 +575,7 @@ def clear_items(self) -> Self: self._total_children = 0 return self - def get_item_by_id(self, id: int, /) -> Optional[Item[Self]]: + def get_item(self, id: int, /) -> Optional[Item[Self]]: """Gets an item with :attr:`Item.id` set as ``id``, or ``None`` if not found. From ff55d37166ced8b8352f6a0aaac28e3a419f5ae1 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Wed, 28 May 2025 19:01:56 +0200 Subject: [PATCH 163/272] changes to every item, mostly on repr --- discord/ui/action_row.py | 11 +++++++++-- discord/ui/button.py | 4 ++++ discord/ui/container.py | 24 ++++++++++++++++-------- discord/ui/dynamic.py | 3 +++ discord/ui/file.py | 7 +++++++ discord/ui/item.py | 9 ++++++++- discord/ui/media_gallery.py | 9 +++++++++ discord/ui/section.py | 8 ++++++++ discord/ui/select.py | 4 ++++ discord/ui/separator.py | 7 +++++++ discord/ui/text_input.py | 1 + discord/ui/thumbnail.py | 8 ++++++++ 12 files changed, 84 insertions(+), 11 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 5cc41de26e32..a27e31ca919e 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -143,6 +143,10 @@ async def row_button(self, interaction: discord.Interaction, button: discord.ui. __action_row_children_items__: ClassVar[List[ItemCallbackType[Any]]] = [] __discord_ui_action_row__: ClassVar[bool] = True __discord_ui_update_view__: ClassVar[bool] = True + __item_repr_attributes__ = ( + 'row', + 'id', + ) def __init__( self, @@ -176,6 +180,9 @@ def __init_subclass__(cls) -> None: cls.__action_row_children_items__ = list(children.values()) + def __repr__(self) -> str: + return f'{super().__repr__()[:-1]} children={len(self._children)}>' + def _init_children(self) -> List[Item[Any]]: children = [] @@ -303,7 +310,7 @@ def remove_item(self, item: Item[Any]) -> Self: return self - def get_item_by_id(self, id: int, /) -> Optional[Item[V]]: + def get_item(self, id: int, /) -> Optional[Item[V]]: """Gets an item with :attr:`Item.id` set as ``id``, or ``None`` if not found. @@ -321,7 +328,7 @@ def get_item_by_id(self, id: int, /) -> Optional[Item[V]]: Optional[:class:`Item`] The item found, or ``None``. """ - return _utils_get(self._children, id=id) + return _utils_get(self.walk_children(), id=id) def clear_items(self) -> Self: """Removes all items from the row. diff --git a/discord/ui/button.py b/discord/ui/button.py index 46230d480d54..9d68d411b926 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -97,6 +97,7 @@ class Button(Item[V]): 'emoji', 'row', 'sku_id', + 'id', ) def __init__( @@ -269,6 +270,9 @@ def is_persistent(self) -> bool: return self.url is not None return super().is_persistent() + def _can_be_dynamic(self) -> bool: + return True + def _refresh_component(self, button: ButtonComponent) -> None: self._underlying = button diff --git a/discord/ui/container.py b/discord/ui/container.py index 8c550816f4ec..93dedfbe16a6 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -132,6 +132,12 @@ class MyView(ui.LayoutView): __container_children_items__: ClassVar[Dict[str, Union[ItemCallbackType[Any], Item[Any]]]] = {} __discord_ui_update_view__: ClassVar[bool] = True __discord_ui_container__: ClassVar[bool] = True + __item_repr_attributes__ = ( + 'accent_colour', + 'spoiler', + 'row', + 'id', + ) def __init__( self, @@ -156,6 +162,9 @@ def __init__( self.row = row self.id = id + def __repr__(self) -> str: + return f'<{super().__repr__()[:-1]} children={len(self._children)}>' + def _add_dispatchable(self, item: Item[Any]) -> None: self.__dispatchable.append(item) @@ -173,14 +182,13 @@ def _init_children(self) -> List[Item[Any]]: if isinstance(raw, Item): item = copy.deepcopy(raw) item._parent = self - if getattr(item, '__discord_ui_action_row__', False): + if getattr(item, '__discord_ui_action_row__', False) and item.is_dispatchable(): if item.is_dispatchable(): self.__dispatchable.extend(item._children) # type: ignore - if getattr(item, '__discord_ui_section__', False): - if item.accessory.is_dispatchable(): # type: ignore - if item.accessory._provided_custom_id is False: # type: ignore - item.accessory.custom_id = os.urandom(16).hex() # type: ignore - self.__dispatchable.append(item.accessory) # type: ignore + if getattr(item, '__discord_ui_section__', False) and item.accessory.is_dispatchable(): # type: ignore + if item.accessory._provided_custom_id is False: # type: ignore + item.accessory.custom_id = os.urandom(16).hex() # type: ignore + self.__dispatchable.append(item.accessory) # type: ignore setattr(self, name, item) children.append(item) @@ -415,7 +423,7 @@ def remove_item(self, item: Item[Any]) -> Self: self._view._total_children -= 1 return self - def get_item_by_id(self, id: int, /) -> Optional[Item[V]]: + def get_item(self, id: int, /) -> Optional[Item[V]]: """Gets an item with :attr:`Item.id` set as ``id``, or ``None`` if not found. @@ -433,7 +441,7 @@ def get_item_by_id(self, id: int, /) -> Optional[Item[V]]: Optional[:class:`Item`] The item found, or ``None``. """ - return _utils_get(self._children, id=id) + return _utils_get(self.walk_children(), id=id) def clear_items(self) -> Self: """Removes all the items from the container. diff --git a/discord/ui/dynamic.py b/discord/ui/dynamic.py index 3c7ea0e48eb1..6a5503364cb6 100644 --- a/discord/ui/dynamic.py +++ b/discord/ui/dynamic.py @@ -107,6 +107,9 @@ def __init__( if not self.item.is_dispatchable(): raise TypeError('item must be dispatchable, e.g. not a URL button') + if not self.item._can_be_dynamic(): + raise TypeError(f'{self.item.__class__.__name__} cannot be set as a dynamic item') + if not self.template.match(self.custom_id): raise ValueError(f'item custom_id {self.custom_id!r} must match the template {self.template.pattern!r}') diff --git a/discord/ui/file.py b/discord/ui/file.py index 09d557a892ed..630258cf4815 100644 --- a/discord/ui/file.py +++ b/discord/ui/file.py @@ -77,6 +77,13 @@ class MyView(ui.LayoutView): The ID of this component. This must be unique across the view. """ + __item_repr_attributes__ = ( + 'media', + 'spoiler', + 'row', + 'id', + ) + def __init__( self, media: Union[str, UnfurledMediaItem], diff --git a/discord/ui/item.py b/discord/ui/item.py index c73d8e7621ba..4e1c7d172879 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -66,7 +66,7 @@ class Item(Generic[V]): .. versionadded:: 2.0 """ - __item_repr_attributes__: Tuple[str, ...] = ('row',) + __item_repr_attributes__: Tuple[str, ...] = ('row', 'id') def __init__(self): self._view: Optional[V] = None @@ -162,6 +162,13 @@ async def _run_checks(self, interaction: Interaction[ClientT]) -> bool: return can_run + def _can_be_dynamic(self) -> bool: + # if an item can be dynamic then it must override this, this is mainly used + # by DynamicItem's so a user cannot set, for example, a Container with a dispatchable + # button as a dynamic item, and cause errors where Container can't be dispatched + # or lost interactions + return False + async def callback(self, interaction: Interaction[ClientT]) -> Any: """|coro| diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index 8cfbfa1e333c..166d99b22c9f 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -67,6 +67,12 @@ class MediaGallery(Item[V]): The ID of this component. This must be unique across the view. """ + __item_repr_attributes__ = ( + 'items', + 'row', + 'id', + ) + def __init__( self, *items: MediaGalleryItem, @@ -83,6 +89,9 @@ def __init__( self.row = row self.id = id + def __repr__(self) -> str: + return f'<{super().__repr__()[:-1]} items={len(self._underlying.items)}>' + @property def items(self) -> List[MediaGalleryItem]: """List[:class:`.MediaGalleryItem`]: Returns a read-only list of this gallery's items.""" diff --git a/discord/ui/section.py b/discord/ui/section.py index fcd2002e9a9b..da81fe38d170 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -67,6 +67,11 @@ class Section(Item[V]): The ID of this component. This must be unique across the view. """ + __item_repr_attributes__ = ( + 'accessory', + 'row', + 'id', + ) __discord_ui_section__: ClassVar[bool] = True __discord_ui_update_view__: ClassVar[bool] = True @@ -94,6 +99,9 @@ def __init__( self.row = row self.id = id + def __repr__(self) -> str: + return f'<{super().__repr__()[:-1]} children={len(self._children)}' + @property def type(self) -> Literal[ComponentType.section]: return ComponentType.section diff --git a/discord/ui/select.py b/discord/ui/select.py index 40b8a26f38dc..7695f759e281 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -217,6 +217,7 @@ class BaseSelect(Item[V]): 'min_values', 'max_values', 'disabled', + 'id', ) __component_attributes__: Tuple[str, ...] = ( 'custom_id', @@ -363,6 +364,9 @@ def from_component(cls, component: SelectMenu) -> BaseSelect[V]: kwrgs = {key: getattr(component, key) for key in constructor.__component_attributes__} return constructor(**kwrgs) + def _can_be_dynamic(self) -> bool: + return True + class Select(BaseSelect[V]): """Represents a UI select menu with a list of custom options. This is represented diff --git a/discord/ui/separator.py b/discord/ui/separator.py index 76c1a5275d03..f90fbaa4b2f8 100644 --- a/discord/ui/separator.py +++ b/discord/ui/separator.py @@ -64,6 +64,13 @@ class Separator(Item[V]): The ID of this component. This must be unique across the view. """ + __item_repr_attributes__ = ( + 'visible', + 'spacing', + 'row', + 'id', + ) + def __init__( self, *, diff --git a/discord/ui/text_input.py b/discord/ui/text_input.py index 86f7373ee11e..218d7c4d090a 100644 --- a/discord/ui/text_input.py +++ b/discord/ui/text_input.py @@ -102,6 +102,7 @@ class TextInput(Item[V]): 'label', 'placeholder', 'required', + 'id', ) def __init__( diff --git a/discord/ui/thumbnail.py b/discord/ui/thumbnail.py index 67f8e4c7629f..e0fbd3a645b4 100644 --- a/discord/ui/thumbnail.py +++ b/discord/ui/thumbnail.py @@ -66,6 +66,14 @@ class Thumbnail(Item[V]): The ID of this component. This must be unique across the view. """ + __item_repr_attributes__ = ( + 'media', + 'description', + 'spoiler', + 'row', + 'id', + ) + def __init__( self, media: Union[str, UnfurledMediaItem], From 12abc1a83a43b6e9b59a25ff98f1536e183d7df1 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Wed, 28 May 2025 19:06:06 +0200 Subject: [PATCH 164/272] chore: fix DynamicItem errors --- discord/ui/dynamic.py | 2 ++ examples/views/dynamic_counter.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/discord/ui/dynamic.py b/discord/ui/dynamic.py index 6a5503364cb6..e022ae12ea9e 100644 --- a/discord/ui/dynamic.py +++ b/discord/ui/dynamic.py @@ -39,6 +39,8 @@ from ..components import Component from ..enums import ComponentType from .view import View, LayoutView +else: + View = LayoutView = Any class DynamicItem(Generic[BaseT], Item[Union[View, LayoutView]]): diff --git a/examples/views/dynamic_counter.py b/examples/views/dynamic_counter.py index d007e0173dd4..cfb02ee5ddda 100644 --- a/examples/views/dynamic_counter.py +++ b/examples/views/dynamic_counter.py @@ -17,7 +17,7 @@ # Note that custom_ids can only be up to 100 characters long. class DynamicCounter( - discord.ui.DynamicItem[discord.ui.Button, discord.ui.View], + discord.ui.DynamicItem[discord.ui.Button], template=r'counter:(?P[0-9]+):user:(?P[0-9]+)', ): def __init__(self, user_id: int, count: int = 0) -> None: From 5bde2f0d29417e940ab2c120a84919fd0b255d08 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Wed, 28 May 2025 19:06:30 +0200 Subject: [PATCH 165/272] chore: rename Section.get_item_by_id to Section.get_item --- discord/ui/section.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/section.py b/discord/ui/section.py index da81fe38d170..55072ff7659a 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -203,7 +203,7 @@ def remove_item(self, item: Item[Any]) -> Self: return self - def get_item_by_id(self, id: int, /) -> Optional[Item[V]]: + def get_item(self, id: int, /) -> Optional[Item[V]]: """Gets an item with :attr:`Item.id` set as ``id``, or ``None`` if not found. From d124702290547eff082535c327b8cc1fb2f9a99e Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Wed, 28 May 2025 19:14:22 +0200 Subject: [PATCH 166/272] itertools.groupby bye --- discord/ui/section.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/discord/ui/section.py b/discord/ui/section.py index 55072ff7659a..adda44b4072d 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -24,7 +24,6 @@ from __future__ import annotations import sys -from itertools import groupby from typing import TYPE_CHECKING, Any, Dict, Generator, List, Literal, Optional, TypeVar, Union, ClassVar from .item import Item @@ -255,8 +254,8 @@ def key(item: Item) -> int: return item._row return sys.maxsize - for _, comps in groupby(self._children, key=key): - components.extend(c.to_component_dict() for c in comps) + for component in sorted(self._children, key=key): + components.append(component.to_component_dict()) return components def to_component_dict(self) -> Dict[str, Any]: From 78ed233f07f60fdfb0bd543a06310538705c6f39 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Fri, 30 May 2025 15:45:07 +0200 Subject: [PATCH 167/272] shuold --- discord/ui/view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index 533339b845bb..10519cf5ad44 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -795,7 +795,7 @@ def from_dict(cls, data: List[ComponentPayload], *, timeout: Optional[float] = 1 You can find out about this format in the :ddocs:`official Discord documentation `. - This method shuold be called on the respective class (or subclass), so if you + This method should be called on the respective class (or subclass), so if you want to convert v2 items, you should call :meth:`LayoutView.from_dict`, or the same method from any subclass of it; and not :meth:`View.from_message`, or the same method from any subclass of it. From 49125e8406fdafdd636b389d76a305e5c6ea84d8 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Fri, 30 May 2025 23:01:16 +0200 Subject: [PATCH 168/272] chore: update ViewStore.dispatch_view Since a time ago, the /interactions/{token}/callback route got a return type (finally), and this code updates the whole interaction_id dependency the current system had to always use the resource id (in case it is a message) Discord provides. discord.py always defaults to receiving a response on interaction callbacks, so it should not fail anymore. Maybe needs some fixing, the testing I've done seems to be working fine, so I guess I'll leave it like that currently --- discord/interactions.py | 34 ++++++++++++++-------------------- discord/message.py | 6 +----- discord/state.py | 10 ++-------- discord/ui/view.py | 27 +-------------------------- 4 files changed, 18 insertions(+), 59 deletions(-) diff --git a/discord/interactions.py b/discord/interactions.py index 7b0b9c493787..1346c3fe983e 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -1071,18 +1071,24 @@ async def send_message( proxy_auth=http.proxy_auth, params=params, ) + self._response_type = InteractionResponseType.channel_message + ret = InteractionCallbackResponse( + data=response, + parent=self._parent, + state=self._parent._state, + type=self._response_type, + ) if view is not MISSING and not view.is_finished(): if ephemeral and view.timeout is None: view.timeout = 15 * 60.0 - # If the interaction type isn't an application command then there's no way - # to obtain this interaction_id again, so just default to None - entity_id = parent.id if parent.type is InteractionType.application_command else None + # this assertion should never fail because the resource of a send_message + # response will always be an InteractionMessage + assert isinstance(ret.resource, InteractionMessage) + entity_id = ret.resource.id if parent.type is InteractionType.application_command else None self._parent._state.store_view(view, entity_id) - self._response_type = InteractionResponseType.channel_message - if delete_after is not None: async def inner_call(delay: float = delete_after): @@ -1094,12 +1100,7 @@ async def inner_call(delay: float = delete_after): asyncio.create_task(inner_call()) - return InteractionCallbackResponse( - data=response, - parent=self._parent, - state=self._parent._state, - type=self._response_type, - ) + return ret @overload async def edit_message( @@ -1208,14 +1209,7 @@ async def edit_message( parent = self._parent msg = parent.message state = parent._state - if msg is not None: - message_id = msg.id - # If this was invoked via an application command then we can use its original interaction ID - # Since this is used as a cache key for view updates - original_interaction_id = msg.interaction_metadata.id if msg.interaction_metadata is not None else None - else: - message_id = None - original_interaction_id = None + message_id = msg and msg.id if parent.type not in (InteractionType.component, InteractionType.modal_submit): return @@ -1253,7 +1247,7 @@ async def edit_message( ) if view and not view.is_finished(): - state.store_view(view, message_id, interaction_id=original_interaction_id) + state.store_view(view, message_id) self._response_type = InteractionResponseType.message_update diff --git a/discord/message.py b/discord/message.py index 0057b06f8737..fd781aeee1db 100644 --- a/discord/message.py +++ b/discord/message.py @@ -1444,11 +1444,7 @@ async def edit( message = Message(state=self._state, channel=self.channel, data=data) if view and not view.is_finished(): - interaction: Optional[MessageInteraction] = getattr(self, 'interaction', None) - if interaction is not None: - self._state.store_view(view, self.id, interaction_id=interaction.id) - else: - self._state.store_view(view, self.id) + self._state.store_view(view, self.id) if delete_after is not None: await self.delete(delay=delete_after) diff --git a/discord/state.py b/discord/state.py index 37bd138a7dd9..667c932bfc63 100644 --- a/discord/state.py +++ b/discord/state.py @@ -412,9 +412,7 @@ def store_sticker(self, guild: Guild, data: GuildStickerPayload) -> GuildSticker self._stickers[sticker_id] = sticker = GuildSticker(state=self, data=data) return sticker - def store_view(self, view: BaseView, message_id: Optional[int] = None, interaction_id: Optional[int] = None) -> None: - if interaction_id is not None: - self._view_store.remove_interaction_mapping(interaction_id) + def store_view(self, view: BaseView, message_id: Optional[int] = None) -> None: self._view_store.add_view(view, message_id) def prevent_view_updates_for(self, message_id: int) -> Optional[BaseView]: @@ -735,11 +733,7 @@ def parse_message_update(self, data: gw.MessageUpdateEvent) -> None: self.dispatch('raw_message_edit', raw) if 'components' in data: - try: - entity_id = int(data['interaction']['id']) # pyright: ignore[reportTypedDictNotRequiredAccess] - except (KeyError, ValueError): - entity_id = raw.message_id - + entity_id = raw.message_id if self._view_store.is_message_tracked(entity_id): self._view_store.update_from_message(entity_id, data['components']) diff --git a/discord/ui/view.py b/discord/ui/view.py index 10519cf5ad44..d21ea56612ce 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -1162,15 +1162,12 @@ def dispatch_dynamic_items(self, component_type: int, custom_id: str, interactio def dispatch_view(self, component_type: int, custom_id: str, interaction: Interaction) -> None: self.dispatch_dynamic_items(component_type, custom_id, interaction) - interaction_id: Optional[int] = None message_id: Optional[int] = None # Realistically, in a component based interaction the Interaction.message will never be None # However, this guard is just in case Discord screws up somehow msg = interaction.message if msg is not None: message_id = msg.id - if msg.interaction_metadata: - interaction_id = msg.interaction_metadata.id key = (component_type, custom_id) @@ -1179,27 +1176,10 @@ def dispatch_view(self, component_type: int, custom_id: str, interaction: Intera if message_id is not None: item = self._views.get(message_id, {}).get(key) - if item is None and interaction_id is not None: - try: - items = self._views.pop(interaction_id) - except KeyError: - item = None - else: - item = items.get(key) - # If we actually got the items, then these keys should probably be moved - # to the proper message_id instead of the interaction_id as they are now. - # An interaction_id is only used as a temporary stop gap for - # InteractionResponse.send_message so multiple view instances do not - # override each other. - # NOTE: Fix this mess if /callback endpoint ever gets proper return types - self._views.setdefault(message_id, {}).update(items) - if item is None: - # Fallback to None message_id searches in case a persistent view - # was added without an associated message_id item = self._views.get(None, {}).get(key) - # If 3 lookups failed at this point then just discard it + # If 2 lookups failed at this point then just discard it if item is None: return @@ -1219,11 +1199,6 @@ def dispatch_modal( modal._dispatch_submit(interaction, components) - def remove_interaction_mapping(self, interaction_id: int) -> None: - # This is called before re-adding the view - self._views.pop(interaction_id, None) - self._synced_message_views.pop(interaction_id, None) - def is_message_tracked(self, message_id: int) -> bool: return message_id in self._synced_message_views From 8f03384a884fa11846ed4ead0eab4008ef1d1ca3 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Fri, 30 May 2025 23:02:57 +0200 Subject: [PATCH 169/272] fix on the previous commit lol --- discord/interactions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/interactions.py b/discord/interactions.py index 1346c3fe983e..665ee92fc4e8 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -593,7 +593,7 @@ async def edit_original_response( state = _InteractionMessageState(self, self._state) message = InteractionMessage(state=state, channel=self.channel, data=data) # type: ignore if view and not view.is_finished(): - self._state.store_view(view, message.id, interaction_id=self.id) + self._state.store_view(view, message.id) return message async def delete_original_response(self) -> None: From b8432722d8d415abd2257ab85c5fed3dcab75ab0 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 31 May 2025 09:36:41 +0200 Subject: [PATCH 170/272] Revert "chore: update ViewStore.dispatch_view" This reverts commit 49125e8406fdafdd636b389d76a305e5c6ea84d8. --- discord/interactions.py | 34 ++++++++++++++++++++-------------- discord/message.py | 6 +++++- discord/state.py | 10 ++++++++-- discord/ui/view.py | 27 ++++++++++++++++++++++++++- 4 files changed, 59 insertions(+), 18 deletions(-) diff --git a/discord/interactions.py b/discord/interactions.py index 665ee92fc4e8..c580a5e6507c 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -1071,24 +1071,18 @@ async def send_message( proxy_auth=http.proxy_auth, params=params, ) - self._response_type = InteractionResponseType.channel_message - ret = InteractionCallbackResponse( - data=response, - parent=self._parent, - state=self._parent._state, - type=self._response_type, - ) if view is not MISSING and not view.is_finished(): if ephemeral and view.timeout is None: view.timeout = 15 * 60.0 - # this assertion should never fail because the resource of a send_message - # response will always be an InteractionMessage - assert isinstance(ret.resource, InteractionMessage) - entity_id = ret.resource.id if parent.type is InteractionType.application_command else None + # If the interaction type isn't an application command then there's no way + # to obtain this interaction_id again, so just default to None + entity_id = parent.id if parent.type is InteractionType.application_command else None self._parent._state.store_view(view, entity_id) + self._response_type = InteractionResponseType.channel_message + if delete_after is not None: async def inner_call(delay: float = delete_after): @@ -1100,7 +1094,12 @@ async def inner_call(delay: float = delete_after): asyncio.create_task(inner_call()) - return ret + return InteractionCallbackResponse( + data=response, + parent=self._parent, + state=self._parent._state, + type=self._response_type, + ) @overload async def edit_message( @@ -1209,7 +1208,14 @@ async def edit_message( parent = self._parent msg = parent.message state = parent._state - message_id = msg and msg.id + if msg is not None: + message_id = msg.id + # If this was invoked via an application command then we can use its original interaction ID + # Since this is used as a cache key for view updates + original_interaction_id = msg.interaction_metadata.id if msg.interaction_metadata is not None else None + else: + message_id = None + original_interaction_id = None if parent.type not in (InteractionType.component, InteractionType.modal_submit): return @@ -1247,7 +1253,7 @@ async def edit_message( ) if view and not view.is_finished(): - state.store_view(view, message_id) + state.store_view(view, message_id, interaction_id=original_interaction_id) self._response_type = InteractionResponseType.message_update diff --git a/discord/message.py b/discord/message.py index fd781aeee1db..0057b06f8737 100644 --- a/discord/message.py +++ b/discord/message.py @@ -1444,7 +1444,11 @@ async def edit( message = Message(state=self._state, channel=self.channel, data=data) if view and not view.is_finished(): - self._state.store_view(view, self.id) + interaction: Optional[MessageInteraction] = getattr(self, 'interaction', None) + if interaction is not None: + self._state.store_view(view, self.id, interaction_id=interaction.id) + else: + self._state.store_view(view, self.id) if delete_after is not None: await self.delete(delay=delete_after) diff --git a/discord/state.py b/discord/state.py index 667c932bfc63..37bd138a7dd9 100644 --- a/discord/state.py +++ b/discord/state.py @@ -412,7 +412,9 @@ def store_sticker(self, guild: Guild, data: GuildStickerPayload) -> GuildSticker self._stickers[sticker_id] = sticker = GuildSticker(state=self, data=data) return sticker - def store_view(self, view: BaseView, message_id: Optional[int] = None) -> None: + def store_view(self, view: BaseView, message_id: Optional[int] = None, interaction_id: Optional[int] = None) -> None: + if interaction_id is not None: + self._view_store.remove_interaction_mapping(interaction_id) self._view_store.add_view(view, message_id) def prevent_view_updates_for(self, message_id: int) -> Optional[BaseView]: @@ -733,7 +735,11 @@ def parse_message_update(self, data: gw.MessageUpdateEvent) -> None: self.dispatch('raw_message_edit', raw) if 'components' in data: - entity_id = raw.message_id + try: + entity_id = int(data['interaction']['id']) # pyright: ignore[reportTypedDictNotRequiredAccess] + except (KeyError, ValueError): + entity_id = raw.message_id + if self._view_store.is_message_tracked(entity_id): self._view_store.update_from_message(entity_id, data['components']) diff --git a/discord/ui/view.py b/discord/ui/view.py index d21ea56612ce..10519cf5ad44 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -1162,12 +1162,15 @@ def dispatch_dynamic_items(self, component_type: int, custom_id: str, interactio def dispatch_view(self, component_type: int, custom_id: str, interaction: Interaction) -> None: self.dispatch_dynamic_items(component_type, custom_id, interaction) + interaction_id: Optional[int] = None message_id: Optional[int] = None # Realistically, in a component based interaction the Interaction.message will never be None # However, this guard is just in case Discord screws up somehow msg = interaction.message if msg is not None: message_id = msg.id + if msg.interaction_metadata: + interaction_id = msg.interaction_metadata.id key = (component_type, custom_id) @@ -1176,10 +1179,27 @@ def dispatch_view(self, component_type: int, custom_id: str, interaction: Intera if message_id is not None: item = self._views.get(message_id, {}).get(key) + if item is None and interaction_id is not None: + try: + items = self._views.pop(interaction_id) + except KeyError: + item = None + else: + item = items.get(key) + # If we actually got the items, then these keys should probably be moved + # to the proper message_id instead of the interaction_id as they are now. + # An interaction_id is only used as a temporary stop gap for + # InteractionResponse.send_message so multiple view instances do not + # override each other. + # NOTE: Fix this mess if /callback endpoint ever gets proper return types + self._views.setdefault(message_id, {}).update(items) + if item is None: + # Fallback to None message_id searches in case a persistent view + # was added without an associated message_id item = self._views.get(None, {}).get(key) - # If 2 lookups failed at this point then just discard it + # If 3 lookups failed at this point then just discard it if item is None: return @@ -1199,6 +1219,11 @@ def dispatch_modal( modal._dispatch_submit(interaction, components) + def remove_interaction_mapping(self, interaction_id: int) -> None: + # This is called before re-adding the view + self._views.pop(interaction_id, None) + self._synced_message_views.pop(interaction_id, None) + def is_message_tracked(self, message_id: int) -> bool: return message_id in self._synced_message_views From 59321d356d94ec6c13d63aa2bb15745b1894ade4 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 31 May 2025 09:38:05 +0200 Subject: [PATCH 171/272] revert " fix on the previous commit\ --- discord/interactions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/interactions.py b/discord/interactions.py index c580a5e6507c..7b0b9c493787 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -593,7 +593,7 @@ async def edit_original_response( state = _InteractionMessageState(self, self._state) message = InteractionMessage(state=state, channel=self.channel, data=data) # type: ignore if view and not view.is_finished(): - self._state.store_view(view, message.id) + self._state.store_view(view, message.id, interaction_id=self.id) return message async def delete_original_response(self) -> None: From e1d8a8419b6feec59a24ab66f773c1128be54ebb Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 1 Jun 2025 12:59:34 +0200 Subject: [PATCH 172/272] chore: change get_item behaviour --- discord/ui/section.py | 2 +- discord/ui/view.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/ui/section.py b/discord/ui/section.py index adda44b4072d..708ef68c5f57 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -220,7 +220,7 @@ def get_item(self, id: int, /) -> Optional[Item[V]]: Optional[:class:`Item`] The item found, or ``None``. """ - return _utils_get(self._children, id=id) + return _utils_get(self.walk_children(), id=id) def clear_items(self) -> Self: """Removes all the items from the section. diff --git a/discord/ui/view.py b/discord/ui/view.py index 10519cf5ad44..273c5603e7f0 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -595,7 +595,7 @@ def get_item(self, id: int, /) -> Optional[Item[Self]]: Optional[:class:`Item`] The item found, or ``None``. """ - return _utils_get(self._children, id=id) + return _utils_get(self.walk_children(), id=id) async def interaction_check(self, interaction: Interaction, /) -> bool: """|coro| From ca625124312eb13ff6dd9d43c4a2050743a3c6ee Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 7 Jun 2025 12:27:20 +0200 Subject: [PATCH 173/272] fix: description key always present when being None --- discord/components.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/discord/components.py b/discord/components.py index e5d7bb9befb2..514b0819b058 100644 --- a/discord/components.py +++ b/discord/components.py @@ -1045,12 +1045,16 @@ def _from_gallery( return [cls._from_data(item, state) for item in items] def to_dict(self) -> MediaGalleryItemPayload: - return { + payload: MediaGalleryItemPayload = { 'media': self.media.to_dict(), # type: ignore - 'description': self.description, 'spoiler': self.spoiler, } + if self.description is not None: + payload['description'] = self.description + + return payload + class MediaGalleryComponent(Component): """Represents a Media Gallery component from the Discord Bot UI Kit. From ad361364eb26f50916ec0fff577de38783a30476 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 7 Jun 2025 12:27:45 +0200 Subject: [PATCH 174/272] chore: update types --- discord/types/components.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/types/components.py b/discord/types/components.py index 332701ef500c..3a99caab330e 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -156,7 +156,7 @@ class ThumbnailComponent(ComponentBase): class MediaGalleryItem(TypedDict): media: UnfurledMediaItem - description: NotRequired[Optional[str]] + description: NotRequired[str] spoiler: NotRequired[bool] From 4f2040593da72e9e4d3126d767b0208641286d1f Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 7 Jun 2025 13:40:37 +0200 Subject: [PATCH 175/272] chore: Add Container.type property raised NotImplementedError Co-authored-by: owocado <24418520+owocado@users.noreply.github.com> --- discord/components.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/discord/components.py b/discord/components.py index 514b0819b058..6dbe2e710786 100644 --- a/discord/components.py +++ b/discord/components.py @@ -1261,6 +1261,10 @@ def accent_colour(self) -> Optional[Colour]: accent_color = accent_colour + @property + def type(self) -> Literal[ComponentType.container]: + return ComponentType.container + def to_dict(self) -> ContainerComponentPayload: payload: ContainerComponentPayload = { 'type': self.type.value, # type: ignore From f1c397dfb49c7fcd2dfb0556214ce1586a5c89c4 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 7 Jun 2025 13:42:25 +0200 Subject: [PATCH 176/272] typing --- discord/components.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/components.py b/discord/components.py index 6dbe2e710786..407e460b05b8 100644 --- a/discord/components.py +++ b/discord/components.py @@ -1267,9 +1267,9 @@ def type(self) -> Literal[ComponentType.container]: def to_dict(self) -> ContainerComponentPayload: payload: ContainerComponentPayload = { - 'type': self.type.value, # type: ignore + 'type': self.type.value, 'spoiler': self.spoiler, - 'components': [c.to_dict() for c in self.children], + 'components': [c.to_dict() for c in self.children], # pyright: ignore[reportAssignmentType] } if self.id is not None: payload['id'] = self.id From 0d86c55d454333516d4d989f4310fbab2ec5e645 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 7 Jun 2025 13:43:58 +0200 Subject: [PATCH 177/272] chore: use a truthy check instead of a is not None --- discord/components.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/components.py b/discord/components.py index 407e460b05b8..99de2c48c0a7 100644 --- a/discord/components.py +++ b/discord/components.py @@ -1050,7 +1050,7 @@ def to_dict(self) -> MediaGalleryItemPayload: 'spoiler': self.spoiler, } - if self.description is not None: + if self.description: payload['description'] = self.description return payload From d9f0be78ccc86eebb4f5c3ca822344c1e88033ef Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Wed, 11 Jun 2025 15:55:50 +0200 Subject: [PATCH 178/272] add attachment_id and name & size --- discord/components.py | 16 +++++++++++++++- discord/types/components.py | 3 +++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/discord/components.py b/discord/components.py index 99de2c48c0a7..050a97b86dff 100644 --- a/discord/components.py +++ b/discord/components.py @@ -47,7 +47,7 @@ ) from .flags import AttachmentFlags from .colour import Colour -from .utils import get_slots, MISSING +from .utils import get_slots, MISSING, _get_as_snowflake from .partial_emoji import PartialEmoji, _EmojiTag if TYPE_CHECKING: @@ -926,6 +926,9 @@ class UnfurledMediaItem(AssetMixin): The media item's placeholder. loading_state: Optional[:class:`MediaItemLoadingState`] The loading state of this media item. + attachment_id: Optional[:class:`int`] + The attachment id this media item points to, only available if the url points to a local file + uploaded within the component message. """ __slots__ = ( @@ -937,6 +940,7 @@ class UnfurledMediaItem(AssetMixin): '_flags', 'placeholder', 'loading_state', + 'attachment_id', '_state', ) @@ -950,6 +954,7 @@ def __init__(self, url: str) -> None: self._flags: int = 0 self.placeholder: Optional[str] = None self.loading_state: Optional[MediaItemLoadingState] = None + self.attachment_id: Optional[int] = None self._state: Optional[ConnectionState] = None @property @@ -974,6 +979,7 @@ def _update(self, data: UnfurledMediaItemPayload, state: Optional[ConnectionStat loading_state = data.get('loading_state') if loading_state is not None: self.loading_state = try_enum(MediaItemLoadingState, loading_state) + self.attachment_id = _get_as_snowflake(data, 'attachment_id') self._state = state def __repr__(self) -> str: @@ -1118,12 +1124,18 @@ class FileComponent(Component): Whether this file is flagged as a spoiler. id: Optional[:class:`int`] The ID of this component. + name: Optional[:class:`str`] + The displayed file name, only available when received from the API. + size: Optional[:class:`int`] + The file size in MiB, only available when received from the API. """ __slots__ = ( 'media', 'spoiler', 'id', + 'name', + 'size', ) __repr_info__ = __slots__ @@ -1132,6 +1144,8 @@ def __init__(self, data: FileComponentPayload, state: Optional[ConnectionState]) self.media: UnfurledMediaItem = UnfurledMediaItem._from_data(data['file'], state) self.spoiler: bool = data.get('spoiler', False) self.id: Optional[int] = data.get('id') + self.name: Optional[str] = data.get('name') + self.size: Optional[int] = data.get('size') @property def type(self) -> Literal[ComponentType.file]: diff --git a/discord/types/components.py b/discord/types/components.py index 3a99caab330e..a93abd12755b 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -144,6 +144,7 @@ class UnfurledMediaItem(TypedDict): content_type: NotRequired[str] placeholder: str loading_state: MediaItemLoadingState + attachment_id: NotRequired[int] flags: NotRequired[int] @@ -169,6 +170,8 @@ class FileComponent(ComponentBase): type: Literal[13] file: UnfurledMediaItem spoiler: NotRequired[bool] + name: NotRequired[str] + size: NotRequired[int] class SeparatorComponent(ComponentBase): From 3582bf62beb485e304fee9149680143b94447030 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 12 Jun 2025 15:50:23 +0200 Subject: [PATCH 179/272] fix: dynamic item calls failing --- discord/ui/action_row.py | 2 +- discord/ui/button.py | 1 - discord/ui/container.py | 9 ++++- discord/ui/section.py | 13 ++++--- discord/ui/select.py | 1 - discord/ui/view.py | 84 +++++++++++++++++++++++----------------- 6 files changed, 64 insertions(+), 46 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index a27e31ca919e..7e6a6a37c268 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -601,5 +601,5 @@ def from_component(cls, component: ActionRowComponent) -> ActionRow: self = cls() for cmp in component.children: - self.add_item(_component_to_item(cmp)) + self.add_item(_component_to_item(cmp, self)) return self diff --git a/discord/ui/button.py b/discord/ui/button.py index 9d68d411b926..6950dadee509 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -152,7 +152,6 @@ def __init__( sku_id=sku_id, id=id, ) - self._parent: Optional[ActionRow] = None self.row = row self.id = id diff --git a/discord/ui/container.py b/discord/ui/container.py index 93dedfbe16a6..838661ff3035 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -321,12 +321,17 @@ def _update_store_data( @classmethod def from_component(cls, component: ContainerComponent) -> Self: - return cls( - *[_component_to_item(c) for c in component.children], + self = cls( accent_colour=component.accent_colour, spoiler=component.spoiler, id=component.id, ) + self._children = [ + _component_to_item( + cmp, self + ) for cmp in component.children + ] + return self def walk_children(self) -> Generator[Item[V], None, None]: """An iterator that recursively walks through all the children of this container diff --git a/discord/ui/section.py b/discord/ui/section.py index 708ef68c5f57..320dbf4da49f 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -236,13 +236,14 @@ def clear_items(self) -> Self: @classmethod def from_component(cls, component: SectionComponent) -> Self: - from .view import _component_to_item # >circular import< + from .view import _component_to_item - return cls( - *[_component_to_item(c) for c in component.components], - accessory=_component_to_item(component.accessory), - id=component.id, - ) + self = cls.__new__(cls) + self.accessory = _component_to_item(component.accessory, self) + self.id = component.id + self._children = [_component_to_item(c, self) for c in component.components] + + return self def to_components(self) -> List[Dict[str, Any]]: components = [] diff --git a/discord/ui/select.py b/discord/ui/select.py index 7695f759e281..31f16bd88e2e 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -264,7 +264,6 @@ def __init__( self.row = row self.id = id - self._parent: Optional[ActionRow] = None self._values: List[PossibleValue] = [] @property diff --git a/discord/ui/view.py b/discord/ui/view.py index 273c5603e7f0..d3eb4508653a 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -68,6 +68,7 @@ SeparatorComponent, ThumbnailComponent, SelectOption, + Container as ContainerComponent, ) from ..utils import get as _utils_get, _get_as_snowflake from ..enums import SeparatorSpacing, TextStyle, try_enum, ButtonStyle @@ -106,52 +107,59 @@ def _walk_all_components(components: List[Component]) -> Iterator[Component]: yield item -def _component_to_item(component: Component) -> Item: +def _component_to_item(component: Component, parent: Optional[Item] = None) -> Item: if isinstance(component, ActionRowComponent): from .action_row import ActionRow - return ActionRow.from_component(component) - if isinstance(component, ButtonComponent): + item = ActionRow.from_component(component) + elif isinstance(component, ButtonComponent): from .button import Button - return Button.from_component(component) - if isinstance(component, SelectComponent): + item = Button.from_component(component) + elif isinstance(component, SelectComponent): from .select import BaseSelect - return BaseSelect.from_component(component) - if isinstance(component, SectionComponent): + item = BaseSelect.from_component(component) + elif isinstance(component, SectionComponent): from .section import Section - return Section.from_component(component) - if isinstance(component, TextDisplayComponent): + item = Section.from_component(component) + elif isinstance(component, TextDisplayComponent): from .text_display import TextDisplay - return TextDisplay.from_component(component) - if isinstance(component, MediaGalleryComponent): + item = TextDisplay.from_component(component) + elif isinstance(component, MediaGalleryComponent): from .media_gallery import MediaGallery - return MediaGallery.from_component(component) - if isinstance(component, FileComponent): + item = MediaGallery.from_component(component) + elif isinstance(component, FileComponent): from .file import File - return File.from_component(component) - if isinstance(component, SeparatorComponent): + item = File.from_component(component) + elif isinstance(component, SeparatorComponent): from .separator import Separator - return Separator.from_component(component) - if isinstance(component, ThumbnailComponent): + item = Separator.from_component(component) + elif isinstance(component, ThumbnailComponent): from .thumbnail import Thumbnail - return Thumbnail.from_component(component) + item = Thumbnail.from_component(component) + elif isinstance(component, ContainerComponent): + from .container import Container + + item = Container.from_component(component) + else: + item = Item.from_component(component) - return Item.from_component(component) + item._parent = parent + return item -def _component_data_to_item(data: ComponentPayload) -> Item: +def _component_data_to_item(data: ComponentPayload, parent: Optional[Item] = None) -> Item: if data['type'] == 1: from .action_row import ActionRow - return ActionRow( + item = ActionRow( *(_component_data_to_item(c) for c in data['components']), id=data.get('id'), ) @@ -160,7 +168,7 @@ def _component_data_to_item(data: ComponentPayload) -> Item: emoji = data.get('emoji') - return Button( + item = Button( style=try_enum(ButtonStyle, data['style']), custom_id=data.get('custom_id'), url=data.get('url'), @@ -172,7 +180,7 @@ def _component_data_to_item(data: ComponentPayload) -> Item: elif data['type'] == 3: from .select import Select - return Select( + item = Select( custom_id=data['custom_id'], placeholder=data.get('placeholder'), min_values=data.get('min_values', 1), @@ -184,7 +192,7 @@ def _component_data_to_item(data: ComponentPayload) -> Item: elif data['type'] == 4: from .text_input import TextInput - return TextInput( + item = TextInput( label=data['label'], style=try_enum(TextStyle, data['style']), custom_id=data['custom_id'], @@ -210,7 +218,7 @@ def _component_data_to_item(data: ComponentPayload) -> Item: 8: ChannelSelect, } - return cls_map[data['type']]( + item = cls_map[data['type']]( custom_id=data['custom_id'], # type: ignore # will always be present in this point placeholder=data.get('placeholder'), min_values=data.get('min_values', 1), @@ -222,7 +230,7 @@ def _component_data_to_item(data: ComponentPayload) -> Item: elif data['type'] == 9: from .section import Section - return Section( + item = Section( *(_component_data_to_item(c) for c in data['components']), accessory=_component_data_to_item(data['accessory']), id=data.get('id'), @@ -230,11 +238,11 @@ def _component_data_to_item(data: ComponentPayload) -> Item: elif data['type'] == 10: from .text_display import TextDisplay - return TextDisplay(data['content'], id=data.get('id')) + item = TextDisplay(data['content'], id=data.get('id')) elif data['type'] == 11: from .thumbnail import Thumbnail - return Thumbnail( + item = Thumbnail( UnfurledMediaItem._from_data(data['media'], None), description=data.get('description'), spoiler=data.get('spoiler', False), @@ -243,14 +251,14 @@ def _component_data_to_item(data: ComponentPayload) -> Item: elif data['type'] == 12: from .media_gallery import MediaGallery - return MediaGallery( + item = MediaGallery( *(MediaGalleryItem._from_data(m, None) for m in data['items']), id=data.get('id'), ) elif data['type'] == 13: from .file import File - return File( + item = File( UnfurledMediaItem._from_data(data['file'], None), spoiler=data.get('spoiler', False), id=data.get('id'), @@ -258,7 +266,7 @@ def _component_data_to_item(data: ComponentPayload) -> Item: elif data['type'] == 14: from .separator import Separator - return Separator( + item = Separator( visible=data.get('divider', True), spacing=try_enum(SeparatorSpacing, data.get('spacing', 1)), id=data.get('id'), @@ -266,7 +274,7 @@ def _component_data_to_item(data: ComponentPayload) -> Item: elif data['type'] == 17: from .container import Container - return Container( + item = Container( *(_component_data_to_item(c) for c in data['components']), accent_colour=data.get('accent_color'), spoiler=data.get('spoiler', False), @@ -275,6 +283,9 @@ def _component_data_to_item(data: ComponentPayload) -> Item: else: raise ValueError(f'invalid item with type {data["type"]} provided') + item._parent = parent + return item + class _ViewWeights: # fmt: off @@ -1120,7 +1131,7 @@ async def schedule_dynamic_item_call( try: base_item_index, base_item = next( (index, child) - for index, child in enumerate(view._children) + for index, child in enumerate(view.walk_children()) if child.type.value == component_type and getattr(child, 'custom_id', None) == custom_id ) except StopIteration: @@ -1132,8 +1143,11 @@ async def schedule_dynamic_item_call( _log.exception('Ignoring exception in dynamic item creation for %r', factory) return - # Swap the item in the view with our new dynamic item - view._children[base_item_index] = item # type: ignore + # Swap the item in the view or parent with our new dynamic item + if base_item._parent: + base_item._parent._children[base_item_index] = item # type: ignore + else: + view._children[base_item_index] = item # type: ignore item._view = view item._rendered_row = base_item._rendered_row item._refresh_state(interaction, interaction.data) # type: ignore From 04d1ca077c9d858d38ee62200cd42ba2e20b20c4 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 12 Jun 2025 16:10:12 +0200 Subject: [PATCH 180/272] fix: dynamic items strange cases --- discord/ui/view.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index d3eb4508653a..f5664b98bd28 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -1144,8 +1144,21 @@ async def schedule_dynamic_item_call( return # Swap the item in the view or parent with our new dynamic item + # if the item has a parent, then it is a nested item if base_item._parent: - base_item._parent._children[base_item_index] = item # type: ignore + # try and find the item reference on the parent children + try: + child_index = base_item._parent._children.index(base_item) # type: ignore + except ValueError: + # there can be cases in which the button is an accessory + # of a section, so the index will fail + if getattr(base_item._parent, '__discord_ui_section__', False): + accessory = base_item._parent.accessory # type: ignore + if accessory.type.value == component_type and getattr(accessory, 'custom_id', None) == custom_id: + base_item._parent.accessory = item # type: ignore + else: + base_item._parent._children[child_index] = item # type: ignore + # if it does not have a parent then it is at top level else: view._children[base_item_index] = item # type: ignore item._view = view From 7e071ff3c9cf7ed53eb3ee0151f57171008bb3ab Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 12 Jun 2025 16:12:48 +0200 Subject: [PATCH 181/272] chore: remove unused imports & run black --- discord/ui/button.py | 1 - discord/ui/container.py | 6 +----- discord/ui/select.py | 1 - 3 files changed, 1 insertion(+), 7 deletions(-) diff --git a/discord/ui/button.py b/discord/ui/button.py index 6950dadee509..93ec4fa4b280 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -43,7 +43,6 @@ from typing_extensions import Self from .view import BaseView - from .action_row import ActionRow from ..emoji import Emoji from ..types.components import ButtonComponent as ButtonComponentPayload diff --git a/discord/ui/container.py b/discord/ui/container.py index 838661ff3035..6d773b483919 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -326,11 +326,7 @@ def from_component(cls, component: ContainerComponent) -> Self: spoiler=component.spoiler, id=component.id, ) - self._children = [ - _component_to_item( - cmp, self - ) for cmp in component.children - ] + self._children = [_component_to_item(cmp, self) for cmp in component.children] return self def walk_children(self) -> Generator[Item[V], None, None]: diff --git a/discord/ui/select.py b/discord/ui/select.py index 31f16bd88e2e..21aeb66f0ff6 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -73,7 +73,6 @@ from typing_extensions import TypeAlias, TypeGuard from .view import BaseView - from .action_row import ActionRow from ..types.components import SelectMenu as SelectMenuPayload from ..types.interactions import SelectMessageComponentInteractionData from ..app_commands import AppCommandChannel, AppCommandThread From 4bd97e1c71ce172e8a9b893c8757c86f153cb217 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Fri, 13 Jun 2025 23:24:51 +0200 Subject: [PATCH 182/272] chore: fix messy code on ViewStore.schedule_dynamic_item_call --- discord/ui/view.py | 37 +++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index f5664b98bd28..b46ca871af41 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -1129,9 +1129,9 @@ async def schedule_dynamic_item_call( view = view_cls.from_message(interaction.message, timeout=None) try: - base_item_index, base_item = next( - (index, child) - for index, child in enumerate(view.walk_children()) + base_item = next( + child + for child in view.walk_children() if child.type.value == component_type and getattr(child, 'custom_id', None) == custom_id ) except StopIteration: @@ -1144,23 +1144,24 @@ async def schedule_dynamic_item_call( return # Swap the item in the view or parent with our new dynamic item - # if the item has a parent, then it is a nested item - if base_item._parent: - # try and find the item reference on the parent children - try: - child_index = base_item._parent._children.index(base_item) # type: ignore - except ValueError: - # there can be cases in which the button is an accessory - # of a section, so the index will fail - if getattr(base_item._parent, '__discord_ui_section__', False): - accessory = base_item._parent.accessory # type: ignore - if accessory.type.value == component_type and getattr(accessory, 'custom_id', None) == custom_id: - base_item._parent.accessory = item # type: ignore + # Prioritize the item parent: + parent = base_item._parent or view + + try: + child_index = parent._children.index(base_item) # type: ignore + except ValueError: + # handle cases in which the item is a section accesory + if getattr(base_item._parent, '__discord_ui_section__', False): + if ( + base_item._parent.accessory.type.value == component_type # type: ignore + and getattr(base_item._parent.accessory, 'custom_id', None) == custom_id # type: ignore + ): + base_item._parent.accessory = item # type: ignore else: - base_item._parent._children[child_index] = item # type: ignore - # if it does not have a parent then it is at top level + return else: - view._children[base_item_index] = item # type: ignore + parent._children[child_index] = item # type: ignore + item._view = view item._rendered_row = base_item._rendered_row item._refresh_state(interaction, interaction.data) # type: ignore From 8c807e1e20b3067d12a2182b3b61514a04de6e1a Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 14 Jun 2025 00:37:51 +0200 Subject: [PATCH 183/272] chore: update some things for storing items --- discord/ui/action_row.py | 6 ------ discord/ui/container.py | 6 ------ discord/ui/item.py | 5 ----- discord/ui/section.py | 10 ---------- discord/ui/view.py | 32 +++----------------------------- 5 files changed, 3 insertions(+), 56 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 7e6a6a37c268..ec80596fde58 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -207,12 +207,6 @@ def _update_store_data(self, dispatch_info: Dict, dynamic_items: Dict) -> bool: is_fully_dynamic = False return is_fully_dynamic - def is_dispatchable(self) -> bool: - return any(c.is_dispatchable() for c in self.children) - - def is_persistent(self) -> bool: - return all(c.is_persistent() for c in self.children) - def _update_children_view(self, view: LayoutView) -> None: for child in self._children: child._view = view # pyright: ignore[reportAttributeAccessIssue] diff --git a/discord/ui/container.py b/discord/ui/container.py index 6d773b483919..a628f3d3e2dc 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -213,12 +213,6 @@ def _init_children(self) -> List[Item[Any]]: return children - def is_dispatchable(self) -> bool: - return bool(self.__dispatchable) - - def is_persistent(self) -> bool: - return all(c.is_persistent() for c in self.children) - def __init_subclass__(cls) -> None: super().__init_subclass__() diff --git a/discord/ui/item.py b/discord/ui/item.py index 4e1c7d172879..ae7a566b1976 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -83,11 +83,6 @@ def __init__(self): self._max_row: int = 5 if not self._is_v2() else 40 self._parent: Optional[Item] = None - if self._is_v2(): - # this is done so v2 components can be stored on ViewStore._views - # and does not break v1 components custom_id property - self.custom_id: str = os.urandom(16).hex() - def to_component_dict(self) -> Dict[str, Any]: raise NotImplementedError diff --git a/discord/ui/section.py b/discord/ui/section.py index 320dbf4da49f..cfdcd2181e80 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -117,16 +117,6 @@ def width(self): def _is_v2(self) -> bool: return True - # Accessory can be a button, and thus it can have a callback so, maybe - # allow for section to be dispatchable and make the callback func - # be accessory component callback, only called if accessory is - # dispatchable? - def is_dispatchable(self) -> bool: - return self.accessory.is_dispatchable() - - def is_persistent(self) -> bool: - return self.accessory.is_persistent() - def walk_children(self) -> Generator[Item[V], None, None]: """An iterator that recursively walks through all the children of this section. and it's children, if applicable. diff --git a/discord/ui/view.py b/discord/ui/view.py index b46ca871af41..efea81b81c8a 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -1057,39 +1057,13 @@ def add_view(self, view: BaseView, message_id: Optional[int] = None) -> None: dispatch_info = self._views.setdefault(message_id, {}) is_fully_dynamic = True - for item in view._children: + for item in view.walk_children(): if isinstance(item, DynamicItem): pattern = item.__discord_ui_compiled_template__ self._dynamic_items[pattern] = item.__class__ elif item.is_dispatchable(): - if getattr(item, '__discord_ui_container__', False): - is_fully_dynamic = ( - item._update_store_data( # type: ignore - dispatch_info, - self._dynamic_items, - ) - or is_fully_dynamic - ) - elif getattr(item, '__discord_ui_action_row__', False): - is_fully_dynamic = ( - item._update_store_data( # type: ignore - dispatch_info, - self._dynamic_items, - ) - or is_fully_dynamic - ) - elif getattr(item, '__discord_ui_section__', False): - accessory: Item = item.accessory # type: ignore - accessory._view = view - - if isinstance(accessory, DynamicItem): - pattern = accessory.__discord_ui_compiled_template__ - self._dynamic_items[pattern] = accessory.__class__ - else: - dispatch_info[(accessory.type.value, accessory.custom_id)] = accessory - else: - dispatch_info[(item.type.value, item.custom_id)] = item - is_fully_dynamic = False + dispatch_info[(item.type.value, item.custom_id)] = item + is_fully_dynamic = False view._cache_key = message_id if message_id is not None and not is_fully_dynamic: From 7345812ba4b9cea0c3089141dff70f3bf1a7bc50 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 14 Jun 2025 00:38:41 +0200 Subject: [PATCH 184/272] fix: typings --- discord/ui/view.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index efea81b81c8a..b5da6cc1a18c 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -1062,7 +1062,7 @@ def add_view(self, view: BaseView, message_id: Optional[int] = None) -> None: pattern = item.__discord_ui_compiled_template__ self._dynamic_items[pattern] = item.__class__ elif item.is_dispatchable(): - dispatch_info[(item.type.value, item.custom_id)] = item + dispatch_info[(item.type.value, item.custom_id)] = item # type: ignore is_fully_dynamic = False view._cache_key = message_id @@ -1081,7 +1081,7 @@ def remove_view(self, view: View) -> None: pattern = item.__discord_ui_compiled_template__ self._dynamic_items.pop(pattern, None) elif item.is_dispatchable(): - dispatch_info.pop((item.type.value, item.custom_id), None) + dispatch_info.pop((item.type.value, item.custom_id), None) # type: ignore if len(dispatch_info) == 0: self._views.pop(view._cache_key, None) From 14bf8c1432c79c0e481aafc479bd530b1d5ef51f Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 14 Jun 2025 00:53:30 +0200 Subject: [PATCH 185/272] fix: Section not having all attributes due to cls.__new__(cls) --- discord/ui/section.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/discord/ui/section.py b/discord/ui/section.py index cfdcd2181e80..830efb88a179 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -228,7 +228,8 @@ def clear_items(self) -> Self: def from_component(cls, component: SectionComponent) -> Self: from .view import _component_to_item - self = cls.__new__(cls) + # using MISSING as accessory so we can create the new one with the parent set + self = cls(id=component.id, accessory=MISSING) self.accessory = _component_to_item(component.accessory, self) self.id = component.id self._children = [_component_to_item(c, self) for c in component.components] From c4225ecd2f1feee8bbab8412b0074b06a471db91 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 14 Jun 2025 00:53:41 +0200 Subject: [PATCH 186/272] chore: remove unused functions and attributes --- discord/ui/action_row.py | 15 ------------- discord/ui/container.py | 48 ---------------------------------------- 2 files changed, 63 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index ec80596fde58..3c54b0bbac01 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -195,18 +195,6 @@ def _init_children(self) -> List[Item[Any]]: children.append(item) return children - def _update_store_data(self, dispatch_info: Dict, dynamic_items: Dict) -> bool: - is_fully_dynamic = True - - for item in self._children: - if isinstance(item, DynamicItem): - pattern = item.__discord_ui_compiled_template__ - dynamic_items[pattern] = item.__class__ - elif item.is_dispatchable(): - dispatch_info[(item.type.value, item.custom_id)] = item - is_fully_dynamic = False - return is_fully_dynamic - def _update_children_view(self, view: LayoutView) -> None: for child in self._children: child._view = view # pyright: ignore[reportAttributeAccessIssue] @@ -277,9 +265,6 @@ def add_item(self, item: Item[Any]) -> Self: if self._view and getattr(self._view, '__discord_ui_layout_view__', False): self._view._total_children += 1 - if item.is_dispatchable() and self._parent and getattr(self._parent, '__discord_ui_container__', False): - self._parent._add_dispatchable(item) # type: ignore - return self def remove_item(self, item: Item[Any]) -> Self: diff --git a/discord/ui/container.py b/discord/ui/container.py index a628f3d3e2dc..8e9e6a7a669f 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -149,7 +149,6 @@ def __init__( id: Optional[int] = None, ) -> None: super().__init__() - self.__dispatchable: List[Item[V]] = [] self._children: List[Item[V]] = self._init_children() if children is not MISSING: @@ -165,15 +164,6 @@ def __init__( def __repr__(self) -> str: return f'<{super().__repr__()[:-1]} children={len(self._children)}>' - def _add_dispatchable(self, item: Item[Any]) -> None: - self.__dispatchable.append(item) - - def _remove_dispatchable(self, item: Item[Any]) -> None: - try: - self.__dispatchable.remove(item) - except ValueError: - pass - def _init_children(self) -> List[Item[Any]]: children = [] parents = {} @@ -188,7 +178,6 @@ def _init_children(self) -> List[Item[Any]]: if getattr(item, '__discord_ui_section__', False) and item.accessory.is_dispatchable(): # type: ignore if item.accessory._provided_custom_id is False: # type: ignore item.accessory.custom_id = os.urandom(16).hex() # type: ignore - self.__dispatchable.append(item.accessory) # type: ignore setattr(self, name, item) children.append(item) @@ -209,7 +198,6 @@ def _init_children(self) -> List[Item[Any]]: parents.get(parent, parent)._children.append(item) # we donnot append it to the children list because technically these buttons and # selects are not from the container but the action row itself. - self.__dispatchable.append(item) return children @@ -298,21 +286,6 @@ def to_component_dict(self) -> Dict[str, Any]: base['id'] = self.id return base - def _update_store_data( - self, - dispatch_info: Dict[Tuple[int, str], Item[Any]], - dynamic_items: Dict[Any, Type[DynamicItem]], - ) -> bool: - is_fully_dynamic = True - for item in self.__dispatchable: - if isinstance(item, DynamicItem): - pattern = item.__discord_ui_compiled_template__ - dynamic_items[pattern] = item.__class__ - elif item.is_dispatchable(): - dispatch_info[(item.type.value, item.custom_id)] = item - is_fully_dynamic = False - return is_fully_dynamic - @classmethod def from_component(cls, component: ContainerComponent) -> Self: self = cls( @@ -360,15 +333,6 @@ def add_item(self, item: Item[Any]) -> Self: raise TypeError(f'expected Item not {item.__class__.__name__}') self._children.append(item) - - if item.is_dispatchable(): - if getattr(item, '__discord_ui_section__', False): - self.__dispatchable.append(item.accessory) # type: ignore - elif getattr(item, '__discord_ui_action_row__', False): - self.__dispatchable.extend([i for i in item._children if i.is_dispatchable()]) # type: ignore - else: - self.__dispatchable.append(item) - is_layout_view = self._view and getattr(self._view, '__discord_ui_layout_view__', False) if getattr(item, '__discord_ui_update_view__', False): @@ -400,17 +364,6 @@ def remove_item(self, item: Item[Any]) -> Self: except ValueError: pass else: - if item.is_dispatchable(): - if getattr(item, '__discord_ui_section__', False): - self._remove_dispatchable(item.accessory) # type: ignore - elif getattr(item, '__discord_ui_action_row__', False): - for c in item._children: # type: ignore - if not c.is_dispatchable(): - continue - self._remove_dispatchable(c) - else: - self._remove_dispatchable(item) - if self._view and getattr(self._view, '__discord_ui_layout_view__', False): if getattr(item, '__discord_ui_update_view__', False): self._view._total_children -= len(tuple(item.walk_children())) # type: ignore @@ -448,5 +401,4 @@ def clear_items(self) -> Self: if self._view and getattr(self._view, '__discord_ui_layout_view__', False): self._view._total_children -= sum(1 for _ in self.walk_children()) self._children.clear() - self.__dispatchable.clear() return self From 30405292813f63e9f4a5c7a61269d9895184209c Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 14 Jun 2025 00:54:56 +0200 Subject: [PATCH 187/272] chore: remove unused imports and typings --- discord/ui/action_row.py | 1 - discord/ui/container.py | 3 --- discord/ui/dynamic.py | 4 ++-- discord/ui/item.py | 1 - discord/ui/view.py | 2 +- 5 files changed, 3 insertions(+), 8 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 3c54b0bbac01..e91cc4e86745 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -44,7 +44,6 @@ from .item import Item, ItemCallbackType from .button import Button, button as _button -from .dynamic import DynamicItem from .select import select as _select, Select, UserSelect, RoleSelect, ChannelSelect, MentionableSelect from ..components import ActionRow as ActionRowComponent from ..enums import ButtonStyle, ComponentType, ChannelType diff --git a/discord/ui/container.py b/discord/ui/container.py index 8e9e6a7a669f..e0f013413c46 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -36,15 +36,12 @@ List, Literal, Optional, - Tuple, - Type, TypeVar, Union, ) from .item import Item, ItemCallbackType from .view import _component_to_item, LayoutView -from .dynamic import DynamicItem from ..enums import ComponentType from ..utils import MISSING, get as _utils_get from ..colour import Colour, Color diff --git a/discord/ui/dynamic.py b/discord/ui/dynamic.py index e022ae12ea9e..52cc5d06842b 100644 --- a/discord/ui/dynamic.py +++ b/discord/ui/dynamic.py @@ -146,7 +146,7 @@ def is_persistent(self) -> bool: @property def custom_id(self) -> str: """:class:`str`: The ID of the dynamic item that gets received during an interaction.""" - return self.item.custom_id + return self.item.custom_id # type: ignore @custom_id.setter def custom_id(self, value: str) -> None: @@ -156,7 +156,7 @@ def custom_id(self, value: str) -> None: if not self.template.match(value): raise ValueError(f'custom_id must match the template {self.template.pattern!r}') - self.item.custom_id = value + self.item.custom_id = value # type: ignore self._provided_custom_id = True @property diff --git a/discord/ui/item.py b/discord/ui/item.py index ae7a566b1976..3315c3667556 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -24,7 +24,6 @@ from __future__ import annotations -import os from typing import Any, Callable, Coroutine, Dict, Generic, Optional, TYPE_CHECKING, Tuple, Type, TypeVar from ..interactions import Interaction diff --git a/discord/ui/view.py b/discord/ui/view.py index b5da6cc1a18c..1499b709b33a 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -706,7 +706,7 @@ def _dispatch_item(self, item: Item, interaction: Interaction): def _refresh(self, components: List[Component]) -> None: # fmt: off old_state: Dict[str, Item[Any]] = { - item.custom_id: item + item.custom_id: item # type: ignore for item in self._children if item.is_dispatchable() } From 3465426ff61d7496da40d619fbeb4c5417b2d32c Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 14 Jun 2025 00:57:18 +0200 Subject: [PATCH 188/272] chore: revert removing comments --- discord/ui/dynamic.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/ui/dynamic.py b/discord/ui/dynamic.py index 52cc5d06842b..bc96e230b039 100644 --- a/discord/ui/dynamic.py +++ b/discord/ui/dynamic.py @@ -146,7 +146,7 @@ def is_persistent(self) -> bool: @property def custom_id(self) -> str: """:class:`str`: The ID of the dynamic item that gets received during an interaction.""" - return self.item.custom_id # type: ignore + return self.item.custom_id # type: ignore # This attribute exists for dispatchable items @custom_id.setter def custom_id(self, value: str) -> None: @@ -156,7 +156,7 @@ def custom_id(self, value: str) -> None: if not self.template.match(value): raise ValueError(f'custom_id must match the template {self.template.pattern!r}') - self.item.custom_id = value # type: ignore + self.item.custom_id = value # type: ignore # This attribute exists for dispatchable itesm self._provided_custom_id = True @property From 8a79252e9d396e8c79701344e2e52ad505de24fb Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 14 Jun 2025 00:57:52 +0200 Subject: [PATCH 189/272] typo --- discord/ui/dynamic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/dynamic.py b/discord/ui/dynamic.py index bc96e230b039..1c0efae69791 100644 --- a/discord/ui/dynamic.py +++ b/discord/ui/dynamic.py @@ -156,7 +156,7 @@ def custom_id(self, value: str) -> None: if not self.template.match(value): raise ValueError(f'custom_id must match the template {self.template.pattern!r}') - self.item.custom_id = value # type: ignore # This attribute exists for dispatchable itesm + self.item.custom_id = value # type: ignore # This attribute exists for dispatchable items self._provided_custom_id = True @property From 3697a9b96ca3e1f512242fbce06164a674e1a244 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 14 Jun 2025 12:15:30 +0200 Subject: [PATCH 190/272] chore: prevent storing non-interactable views and various fixes --- discord/abc.py | 2 +- discord/channel.py | 2 +- discord/interactions.py | 4 ++-- discord/message.py | 6 +++--- discord/ui/view.py | 23 ++++++++++++----------- discord/webhook/async_.py | 4 ++-- 6 files changed, 21 insertions(+), 20 deletions(-) diff --git a/discord/abc.py b/discord/abc.py index 5ea20b558a70..b8a7c90c5ba7 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -1678,7 +1678,7 @@ async def send( data = await state.http.send_message(channel.id, params=params) ret = state.create_message(channel=channel, data=data) - if view and not view.is_finished(): + if view and not view.is_finished() and view.is_dispatchable(): state.store_view(view, ret.id) if poll: diff --git a/discord/channel.py b/discord/channel.py index 9a1218f31499..58a9943d47be 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -3027,7 +3027,7 @@ async def create_thread( data = await state.http.start_thread_in_forum(self.id, params=params, reason=reason) thread = Thread(guild=self.guild, state=self._state, data=data) message = Message(state=self._state, channel=thread, data=data['message']) - if view and not view.is_finished(): + if view and not view.is_finished() and view.is_dispatchable(): self._state.store_view(view, message.id) return ThreadWithMessage(thread=thread, message=message) diff --git a/discord/interactions.py b/discord/interactions.py index 7b0b9c493787..05e627e84ff9 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -592,7 +592,7 @@ async def edit_original_response( # The message channel types should always match state = _InteractionMessageState(self, self._state) message = InteractionMessage(state=state, channel=self.channel, data=data) # type: ignore - if view and not view.is_finished(): + if view and not view.is_finished() and view.is_dispatchable(): self._state.store_view(view, message.id, interaction_id=self.id) return message @@ -1252,7 +1252,7 @@ async def edit_message( params=params, ) - if view and not view.is_finished(): + if view and not view.is_finished() and view.is_dispatchable(): state.store_view(view, message_id, interaction_id=original_interaction_id) self._response_type = InteractionResponseType.message_update diff --git a/discord/message.py b/discord/message.py index 0057b06f8737..6a27be910eff 100644 --- a/discord/message.py +++ b/discord/message.py @@ -1443,8 +1443,8 @@ async def edit( data = await self._state.http.edit_message(self.channel.id, self.id, params=params) message = Message(state=self._state, channel=self.channel, data=data) - if view and not view.is_finished(): - interaction: Optional[MessageInteraction] = getattr(self, 'interaction', None) + if view and not view.is_finished() and view.is_dispatchable(): + interaction: Optional[MessageInteractionMetadata] = getattr(self, 'interaction_metadata', None) if interaction is not None: self._state.store_view(view, self.id, interaction_id=interaction.id) else: @@ -3033,7 +3033,7 @@ async def edit( data = await self._state.http.edit_message(self.channel.id, self.id, params=params) message = Message(state=self._state, channel=self.channel, data=data) - if view and not view.is_finished(): + if view and not view.is_finished() and view.is_dispatchable(): self._state.store_view(view, self.id) if delete_after is not None: diff --git a/discord/ui/view.py b/discord/ui/view.py index 1499b709b33a..5f7899da2e4b 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -70,7 +70,7 @@ SelectOption, Container as ContainerComponent, ) -from ..utils import get as _utils_get, _get_as_snowflake +from ..utils import get as _utils_get, _get_as_snowflake, find as _utils_find from ..enums import SeparatorSpacing, TextStyle, try_enum, ButtonStyle from ..emoji import PartialEmoji @@ -412,8 +412,9 @@ async def __timeout_task_impl(self) -> None: await asyncio.sleep(self.__timeout_expiry - now) def is_dispatchable(self) -> bool: - # this is used by webhooks to check whether a view requires a state attached - # or not, this simply is, whether a view has a component other than a url button + # checks whether any interactable items (buttons or selects) are present + # in this view, and check whether this requires a state attached in case + # of webhooks and if the view should be stored in the view store return any(item.is_dispatchable() for item in self.children) def has_components_v2(self) -> bool: @@ -1102,13 +1103,13 @@ async def schedule_dynamic_item_call( view_cls = View if not interaction.message.flags.components_v2 else LayoutView view = view_cls.from_message(interaction.message, timeout=None) - try: - base_item = next( - child - for child in view.walk_children() - if child.type.value == component_type and getattr(child, 'custom_id', None) == custom_id - ) - except StopIteration: + base_item = _utils_find( + lambda i: i.type.value == component_type and getattr(i, 'custom_id', None) == custom_id, + view.walk_children(), + ) + + # if the item is not found then return + if not base_item: return try: @@ -1124,7 +1125,7 @@ async def schedule_dynamic_item_call( try: child_index = parent._children.index(base_item) # type: ignore except ValueError: - # handle cases in which the item is a section accesory + # handle cases in which the item is a section accessory if getattr(base_item._parent, '__discord_ui_section__', False): if ( base_item._parent.accessory.type.value == component_type # type: ignore diff --git a/discord/webhook/async_.py b/discord/webhook/async_.py index 104da78cab21..322e60465fc9 100644 --- a/discord/webhook/async_.py +++ b/discord/webhook/async_.py @@ -1921,7 +1921,7 @@ async def send( if wait: msg = self._create_message(data, thread=thread) - if view is not MISSING and not view.is_finished(): + if view is not MISSING and not view.is_finished() and view.is_dispatchable(): message_id = None if msg is None else msg.id self._state.store_view(view, message_id) @@ -2124,7 +2124,7 @@ async def edit_message( ) message = self._create_message(data, thread=thread) - if view and not view.is_finished(): + if view and not view.is_finished() and view.is_dispatchable(): self._state.store_view(view, message_id) return message From 9fc0d3fc5609b5b2e5521efac4b9a8a2b12bcc9a Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 14 Jun 2025 22:18:53 +0200 Subject: [PATCH 191/272] fix: ActionRow missing id --- discord/ui/action_row.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index e91cc4e86745..9fbec833a83c 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -577,7 +577,7 @@ def decorator(func: ItemCallbackType[BaseSelectT]) -> ItemCallbackType[BaseSelec def from_component(cls, component: ActionRowComponent) -> ActionRow: from .view import _component_to_item - self = cls() + self = cls(id=component.id) for cmp in component.children: self.add_item(_component_to_item(cmp, self)) return self From 5d5f4b6d1455fc55b2f892be943e6b9aaa6acd13 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Wed, 18 Jun 2025 22:40:45 +0200 Subject: [PATCH 192/272] fix: total_children not being updated when using add_item --- discord/ui/view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index 5f7899da2e4b..54dc1beb5541 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -545,7 +545,7 @@ def add_item(self, item: Item[Any]) -> Self: if self._is_v2() and self._total_children + added > 40: raise ValueError('maximum number of children exceeded') - + self._total_children += added self._children.append(item) return self From c03afaaa5ceaa76dd6837d1457ff4f12961ae756 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Wed, 18 Jun 2025 22:43:16 +0200 Subject: [PATCH 193/272] fix: views saying that arent dispatchable when they are --- discord/ui/view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index 54dc1beb5541..f98ef31bded2 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -415,7 +415,7 @@ def is_dispatchable(self) -> bool: # checks whether any interactable items (buttons or selects) are present # in this view, and check whether this requires a state attached in case # of webhooks and if the view should be stored in the view store - return any(item.is_dispatchable() for item in self.children) + return any(item.is_dispatchable() for item in self.walk_children()) def has_components_v2(self) -> bool: return any(c._is_v2() for c in self.children) From 0342becc756d3e28305e26a5707b54515e06b1ee Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 21 Jun 2025 09:40:54 +0200 Subject: [PATCH 194/272] update container.accent_colour to not raise errors on None Co-authored-by: Jay3332 <40323796+jay3332@users.noreply.github.com> --- discord/ui/container.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index e0f013413c46..3767c6eab3a0 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -234,7 +234,7 @@ def accent_colour(self) -> Optional[Union[Colour, int]]: @accent_colour.setter def accent_colour(self, value: Optional[Union[Colour, int]]) -> None: - if not isinstance(value, (int, Colour)): + if value is not None and not isinstance(value, (int, Colour)): raise TypeError(f'expected an int, or Colour, not {value.__class__.__name__!r}') self._colour = value From 06f4052e0fa260a18a7758c7d4c45470f697655e Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 29 Jun 2025 18:06:02 +0200 Subject: [PATCH 195/272] chore: Add notes on LayoutView behaviour when editing messages --- discord/interactions.py | 6 ++++++ discord/message.py | 12 ++++++++++++ 2 files changed, 18 insertions(+) diff --git a/discord/interactions.py b/discord/interactions.py index 1a1eff416a27..4015c6f19292 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -1494,6 +1494,12 @@ async def edit( The updated view to update this message with. If ``None`` is passed then the view is removed. + .. note:: + + If you want to update the message to have a :class:`~discord.ui.LayoutView`, you must + explicitly set to ``None`` or empty array, as required, the ``content``, ``embed``, + ``embeds``, and ``attachments`` parameters. + .. versionchanged:: 2.6 This now accepts :class:`~discord.ui.LayoutView` instances. delete_after: Optional[:class:`float`] diff --git a/discord/message.py b/discord/message.py index aef8fa4da1ab..c268f682fe33 100644 --- a/discord/message.py +++ b/discord/message.py @@ -1405,6 +1405,12 @@ async def edit( The updated view to update this message with. If ``None`` is passed then the view is removed. + .. note:: + + If you want to update the message to have a :class:`~discord.ui.LayoutView`, you must + explicitly set to ``None`` or empty array, as required, the ``content``, ``embed``, + ``embeds``, and ``attachments`` parameters. + .. versionchanged:: 2.6 This now accepts :class:`~discord.ui.LayoutView` instances. @@ -2991,6 +2997,12 @@ async def edit( The updated view to update this message with. If ``None`` is passed then the view is removed. + .. note:: + + If you want to update the message to have a :class:`~discord.ui.LayoutView`, you must + explicitly set to ``None`` or empty array, as required, the ``content``, ``embed``, + ``embeds``, and ``attachments`` parameters. + Raises ------- HTTPException From 67d67d3495ebb8be8845cf71fbe250aca71a3795 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Mon, 30 Jun 2025 12:41:32 +0200 Subject: [PATCH 196/272] chore: Clarify note on message edit related methods --- discord/interactions.py | 23 +++++++++++++++++++---- discord/message.py | 5 ++++- discord/webhook/async_.py | 6 ++++++ 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/discord/interactions.py b/discord/interactions.py index 4015c6f19292..6cf8d15f3b75 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -540,9 +540,18 @@ async def edit_original_response( allowed_mentions: :class:`AllowedMentions` Controls the mentions being processed in this message. See :meth:`.abc.Messageable.send` for more information. - view: Optional[:class:`~discord.ui.View`] + view: Optional[Union[:class:`~discord.ui.View`, :class:`~discord.ui.LayoutView`]] The updated view to update this message with. If ``None`` is passed then the view is removed. + + .. note:: + + To update the message to add a :class:`~discord.ui.LayoutView`, you + must explicitly set the ``content``, ``embed``, ``embeds``, and + ``attachments`` parameters to either ``None`` or an empty array, as appropriate. + + .. versionchanged:: 2.6 + This now accepts :class:`~discord.ui.LayoutView` instances. poll: :class:`Poll` The poll to create when editing the message. @@ -1178,6 +1187,12 @@ async def edit_message( The updated view to update this message with. If ``None`` is passed then the view is removed. + .. note:: + + To update the message to add a :class:`~discord.ui.LayoutView`, you + must explicitly set the ``content``, ``embed``, ``embeds``, and + ``attachments`` parameters to either ``None`` or an empty array, as appropriate. + .. versionchanged:: 2.6 This now accepts :class:`~discord.ui.LayoutView` instances. allowed_mentions: Optional[:class:`~discord.AllowedMentions`] @@ -1496,9 +1511,9 @@ async def edit( .. note:: - If you want to update the message to have a :class:`~discord.ui.LayoutView`, you must - explicitly set to ``None`` or empty array, as required, the ``content``, ``embed``, - ``embeds``, and ``attachments`` parameters. + To update the message to add a :class:`~discord.ui.LayoutView`, you + must explicitly set the ``content``, ``embed``, ``embeds``, and + ``attachments`` parameters to either ``None`` or an empty array, as appropriate. .. versionchanged:: 2.6 This now accepts :class:`~discord.ui.LayoutView` instances. diff --git a/discord/message.py b/discord/message.py index c268f682fe33..d9fee394f843 100644 --- a/discord/message.py +++ b/discord/message.py @@ -2993,7 +2993,7 @@ async def edit( are used instead. .. versionadded:: 1.4 - view: Optional[:class:`~discord.ui.View`] + view: Optional[Union[:class:`~discord.ui.View`, :class:`~discord.ui.LayoutView`]] The updated view to update this message with. If ``None`` is passed then the view is removed. @@ -3003,6 +3003,9 @@ async def edit( explicitly set to ``None`` or empty array, as required, the ``content``, ``embed``, ``embeds``, and ``attachments`` parameters. + .. versionchanged:: 2.6 + This now accepts :class:`~discord.ui.LayoutView` instances. + Raises ------- HTTPException diff --git a/discord/webhook/async_.py b/discord/webhook/async_.py index 322e60465fc9..a539918b9a04 100644 --- a/discord/webhook/async_.py +++ b/discord/webhook/async_.py @@ -2059,6 +2059,12 @@ async def edit_message( the view is removed. The webhook must have state attached, similar to :meth:`send`. + .. note:: + + To update the message to add a :class:`~discord.ui.LayoutView`, you + must explicitly set the ``content``, ``embed``, ``embeds``, and + ``attachments`` parameters to either ``None`` or an empty array, as appropriate. + .. versionadded:: 2.0 .. versionchanged:: 2.6 This now accepts :class:`~discord.ui.LayoutView` instances. From b699e7e5e569646b6b4208ef6b60bf432ee7cf4b Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Mon, 30 Jun 2025 12:44:04 +0200 Subject: [PATCH 197/272] versionadded's and reorders --- docs/interactions/api.rst | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/docs/interactions/api.rst b/docs/interactions/api.rst index e13c47fde6c3..40741768dc60 100644 --- a/docs/interactions/api.rst +++ b/docs/interactions/api.rst @@ -408,30 +408,44 @@ Enumerations Represents a component which holds different components in a section. + .. versionadded:: 2.6 + .. attribute:: text_display Represents a text display component. + .. versionadded:: 2.6 + .. attribute:: thumbnail Represents a thumbnail component. + .. versionadded:: 2.6 + .. attribute:: media_gallery Represents a media gallery component. + + .. versionadded:: 2.6 .. attribute:: file Represents a file component. + .. versionadded:: 2.6 + .. attribute:: separator Represents a separator component. + .. versionadded:: 2.6 + .. attribute:: container Represents a component which holds different components in a container. + .. versionadded:: 2.6 + .. class:: ButtonStyle Represents the style of the button component. @@ -596,21 +610,21 @@ View :members: :inherited-members: -Modal -~~~~~~ +LayoutView +~~~~~~~~~~ -.. attributetable:: discord.ui.Modal +.. attributetable:: discord.ui.LayoutView -.. autoclass:: discord.ui.Modal +.. autoclass:: discord.ui.LayoutView :members: :inherited-members: -LayoutView -~~~~~~~~~~ +Modal +~~~~~~ -.. attributetable:: discord.ui.LayoutView +.. attributetable:: discord.ui.Modal -.. autoclass:: discord.ui.LayoutView +.. autoclass:: discord.ui.Modal :members: :inherited-members: From 0f9b6054d15002823990019ed46683ecb92fdb48 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Mon, 30 Jun 2025 12:55:18 +0200 Subject: [PATCH 198/272] add LayoutView to Item.view docstring --- discord/ui/item.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/item.py b/discord/ui/item.py index 3315c3667556..e6b5bb44618a 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -136,7 +136,7 @@ def width(self) -> int: @property def view(self) -> Optional[V]: - """Optional[:class:`View`]: The underlying view for this item.""" + """Optional[Union[:class:`View`, :class:`LayoutView`]]: The underlying view for this item.""" return self._view @property From c2bc49104b8244217aa82e5e6b5eddaedc777672 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Mon, 28 Jul 2025 17:44:46 +0200 Subject: [PATCH 199/272] code qol changes and docs --- discord/components.py | 18 ++++++++++++++++-- discord/ui/action_row.py | 4 ++-- discord/ui/container.py | 7 ++----- discord/ui/file.py | 9 +++++++-- discord/ui/media_gallery.py | 2 +- discord/ui/section.py | 6 +++--- discord/ui/thumbnail.py | 18 ++++++++++++++++-- discord/ui/view.py | 9 ++++++--- 8 files changed, 53 insertions(+), 20 deletions(-) diff --git a/discord/components.py b/discord/components.py index 357aa59754e7..fe68f0aa7787 100644 --- a/discord/components.py +++ b/discord/components.py @@ -1013,7 +1013,7 @@ class MediaGalleryItem: """ __slots__ = ( - 'media', + '_media', 'description', 'spoiler', '_state', @@ -1026,7 +1026,7 @@ def __init__( description: Optional[str] = None, spoiler: bool = False, ) -> None: - self.media: UnfurledMediaItem = UnfurledMediaItem(media) if isinstance(media, str) else media + self._media: UnfurledMediaItem = UnfurledMediaItem(media) if isinstance(media, str) else media self.description: Optional[str] = description self.spoiler: bool = spoiler self._state: Optional[ConnectionState] = None @@ -1034,6 +1034,20 @@ def __init__( def __repr__(self) -> str: return f'' + @property + def media(self) -> UnfurledMediaItem: + """:class:`UnfurledMediaItem`: This item's media data.""" + return self._media + + @media.setter + def media(self, value: Union[str, UnfurledMediaItem]) -> None: + if isinstance(value, str): + self._media = UnfurledMediaItem(value) + elif isinstance(value, UnfurledMediaItem): + self._media = value + else: + raise TypeError(f'Expected a str or UnfurledMediaItem, not {value.__class__.__name__}') + @classmethod def _from_data(cls, data: MediaGalleryItemPayload, state: Optional[ConnectionState]) -> MediaGalleryItem: media = data['media'] diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 9fbec833a83c..ff868e7d5b10 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -126,7 +126,7 @@ async def row_button(self, interaction: discord.Interaction, button: discord.ui. Parameters ---------- - *children: :class:`Item` + \*children: :class:`Item` The initial children of this action row. row: Optional[:class:`int`] The relative row this action row belongs to. By default @@ -288,7 +288,7 @@ def remove_item(self, item: Item[Any]) -> Self: return self - def get_item(self, id: int, /) -> Optional[Item[V]]: + def find_item(self, id: int, /) -> Optional[Item[V]]: """Gets an item with :attr:`Item.id` set as ``id``, or ``None`` if not found. diff --git a/discord/ui/container.py b/discord/ui/container.py index 3767c6eab3a0..e5a07f81f25f 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -106,7 +106,7 @@ class MyView(ui.LayoutView): Parameters ---------- - *children: :class:`Item` + \*children: :class:`Item` The initial children of this container. accent_colour: Optional[Union[:class:`.Colour`, :class:`int`]] The colour of the container. Defaults to ``None``. @@ -169,9 +169,6 @@ def _init_children(self) -> List[Item[Any]]: if isinstance(raw, Item): item = copy.deepcopy(raw) item._parent = self - if getattr(item, '__discord_ui_action_row__', False) and item.is_dispatchable(): - if item.is_dispatchable(): - self.__dispatchable.extend(item._children) # type: ignore if getattr(item, '__discord_ui_section__', False) and item.accessory.is_dispatchable(): # type: ignore if item.accessory._provided_custom_id is False: # type: ignore item.accessory.custom_id = os.urandom(16).hex() # type: ignore @@ -368,7 +365,7 @@ def remove_item(self, item: Item[Any]) -> Self: self._view._total_children -= 1 return self - def get_item(self, id: int, /) -> Optional[Item[V]]: + def find_item(self, id: int, /) -> Optional[Item[V]]: """Gets an item with :attr:`Item.id` set as ``id``, or ``None`` if not found. diff --git a/discord/ui/file.py b/discord/ui/file.py index 630258cf4815..9ad659f86d45 100644 --- a/discord/ui/file.py +++ b/discord/ui/file.py @@ -119,8 +119,13 @@ def media(self) -> UnfurledMediaItem: return self._underlying.media @media.setter - def media(self, value: UnfurledMediaItem) -> None: - self._underlying.media = value + def media(self, value: Union[str, UnfurledMediaItem]) -> None: + if isinstance(value, str): + self._underlying.media = UnfurledMediaItem(value) + elif isinstance(value, UnfurledMediaItem): + self._underlying.media = value + else: + raise TypeError(f'expected a str or UnfurledMediaItem, not {value.__class__.__name__!r}') @property def url(self) -> str: diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index 166d99b22c9f..764c2b27cb8f 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -54,7 +54,7 @@ class MediaGallery(Item[V]): Parameters ---------- - *items: :class:`.MediaGalleryItem` + \*items: :class:`.MediaGalleryItem` The initial items of this gallery. row: Optional[:class:`int`] The relative row this media gallery belongs to. By default diff --git a/discord/ui/section.py b/discord/ui/section.py index 830efb88a179..209d681be012 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -43,7 +43,7 @@ class Section(Item[V]): - """Represents a UI section. + r"""Represents a UI section. This is a top-level layout component that can only be used on :class:`LayoutView` @@ -51,7 +51,7 @@ class Section(Item[V]): Parameters ---------- - *children: Union[:class:`str`, :class:`TextDisplay`] + \*children: Union[:class:`str`, :class:`TextDisplay`] The text displays of this section. Up to 3. accessory: :class:`Item` The section accessory. @@ -192,7 +192,7 @@ def remove_item(self, item: Item[Any]) -> Self: return self - def get_item(self, id: int, /) -> Optional[Item[V]]: + def find_item(self, id: int, /) -> Optional[Item[V]]: """Gets an item with :attr:`Item.id` set as ``id``, or ``None`` if not found. diff --git a/discord/ui/thumbnail.py b/discord/ui/thumbnail.py index e0fbd3a645b4..f16cdc89ef1b 100644 --- a/discord/ui/thumbnail.py +++ b/discord/ui/thumbnail.py @@ -41,7 +41,7 @@ class Thumbnail(Item[V]): - """Represents a UI Thumbnail. + """Represents a UI Thumbnail. This currently can only be used as a :class:`Section`\'s accessory. .. versionadded:: 2.6 @@ -85,7 +85,7 @@ def __init__( ) -> None: super().__init__() - self.media: UnfurledMediaItem = UnfurledMediaItem(media) if isinstance(media, str) else media + self._media: UnfurledMediaItem = UnfurledMediaItem(media) if isinstance(media, str) else media self.description: Optional[str] = description self.spoiler: bool = spoiler @@ -96,6 +96,20 @@ def __init__( def width(self): return 5 + @property + def media(self) -> UnfurledMediaItem: + """:class:`discord.UnfurledMediaItem`: This thumbnail unfurled media data.""" + return self._media + + @media.setter + def media(self, value: Union[str, UnfurledMediaItem]) -> None: + if isinstance(value, str): + self._media = UnfurledMediaItem(value) + elif isinstance(value, UnfurledMediaItem): + self._media = value + else: + raise TypeError(f'expected a str or UnfurledMediaItem, got {value.__class__.__name__!r}') + @property def type(self) -> Literal[ComponentType.thumbnail]: return ComponentType.thumbnail diff --git a/discord/ui/view.py b/discord/ui/view.py index 504d88f133b7..bf40348bb288 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -102,7 +102,7 @@ def _walk_all_components(components: List[Component]) -> Iterator[Component]: for item in components: - if isinstance(item, ActionRowComponent): + if isinstance(item, (ActionRowComponent, ContainerComponent, SectionComponent)): yield from item.children else: yield item @@ -590,7 +590,7 @@ def clear_items(self) -> Self: self._total_children = 0 return self - def get_item(self, id: int, /) -> Optional[Item[Self]]: + def find_item(self, id: int, /) -> Optional[Item[Self]]: """Gets an item with :attr:`Item.id` set as ``id``, or ``None`` if not found. @@ -711,7 +711,7 @@ def _refresh(self, components: List[Component]) -> None: # fmt: off old_state: Dict[str, Item[Any]] = { item.custom_id: item # type: ignore - for item in self._children + for item in self.walk_children() if item.is_dispatchable() } # fmt: on @@ -983,6 +983,9 @@ def __init_subclass__(cls) -> None: for base in reversed(cls.__mro__): for name, member in base.__dict__.items(): if isinstance(member, Item): + if member._parent is not None: + continue + member._rendered_row = member._row children[name] = member elif hasattr(member, '__discord_ui_model_type__') and getattr(member, '__discord_ui_parent__', None): From 6a75fd97f8ba496d8923568db734344d7a71de84 Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 29 Jul 2025 12:41:18 +0200 Subject: [PATCH 200/272] Update discord/ui/view.py Co-authored-by: Danny <1695103+Rapptz@users.noreply.github.com> --- discord/ui/view.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index bf40348bb288..5f5dace2f1b5 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -463,14 +463,9 @@ def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) In order to modify and edit message components they must be converted into a :class:`View` or :class:`LayoutView` first. - If the message has any v2 component, then you must use + If the message has any v2 components, then you must use :class:`LayoutView` in order for them to be converted into - their respective items. - - This method should be called on the respective class (or subclass), so - if you want to convert v2 items, you should call :meth:`LayoutView.from_message`, - or the same method from any subclass of it; and not :meth:`View.from_message`, or the - same method from any subclass of it. + their respective items. :class:`View` does not support v2 components. Parameters ----------- From d601adf2e21ae6b39f982ab09ede2523dc02884b Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 29 Jul 2025 12:41:26 +0200 Subject: [PATCH 201/272] Update discord/ui/action_row.py Co-authored-by: Danny <1695103+Rapptz@users.noreply.github.com> --- discord/ui/action_row.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index ff868e7d5b10..1ee64268541a 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -91,11 +91,7 @@ class ActionRow(Item[V]): This is a top-level layout component that can only be used on :class:`LayoutView` and can contain :class:`Button`\s and :class:`Select`\s in it. - This can be inherited. - - .. note:: - - Action rows can contain up to 5 components, which is, 5 buttons or 1 select. + Action rows can only have 5 children. This can be inherited. .. versionadded:: 2.6 From a8c66dc9f9e59b04c157d5df5056c17c605f2250 Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 29 Jul 2025 12:41:38 +0200 Subject: [PATCH 202/272] Update discord/ui/action_row.py Co-authored-by: Danny <1695103+Rapptz@users.noreply.github.com> --- discord/ui/action_row.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 1ee64268541a..8d7667eb4e0b 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -176,7 +176,7 @@ def __init_subclass__(cls) -> None: cls.__action_row_children_items__ = list(children.values()) def __repr__(self) -> str: - return f'{super().__repr__()[:-1]} children={len(self._children)}>' + return f'{self.__class__.__name__} children={len(self._children)}>' def _init_children(self) -> List[Item[Any]]: children = [] From 099bd48a2dc08b4cea67af80de5e6a0e5e806005 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 29 Jul 2025 12:45:00 +0200 Subject: [PATCH 203/272] use view._is_v2() instead of __discord_ui_layout_view__ i forgot i had made that method --- discord/ui/action_row.py | 6 +++--- discord/ui/container.py | 6 +++--- discord/ui/section.py | 6 +++--- discord/ui/view.py | 2 -- 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index ff868e7d5b10..e35850636520 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -261,7 +261,7 @@ def add_item(self, item: Item[Any]) -> Self: item._parent = self self._children.append(item) - if self._view and getattr(self._view, '__discord_ui_layout_view__', False): + if self._view and self._view._is_v2(): self._view._total_children += 1 return self @@ -283,7 +283,7 @@ def remove_item(self, item: Item[Any]) -> Self: except ValueError: pass else: - if self._view and getattr(self._view, '__discord_ui_layout_view__', False): + if self._view and self._view._is_v2(): self._view._total_children -= 1 return self @@ -314,7 +314,7 @@ def clear_items(self) -> Self: This function returns the class instance to allow for fluent-style chaining. """ - if self._view and getattr(self._view, '__discord_ui_layout_view__', False): + if self._view and self._view._is_v2(): self._view._total_children -= len(self._children) self._children.clear() return self diff --git a/discord/ui/container.py b/discord/ui/container.py index e5a07f81f25f..2e0aabbf77de 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -327,7 +327,7 @@ def add_item(self, item: Item[Any]) -> Self: raise TypeError(f'expected Item not {item.__class__.__name__}') self._children.append(item) - is_layout_view = self._view and getattr(self._view, '__discord_ui_layout_view__', False) + is_layout_view = self._view and self._view._is_v2() if getattr(item, '__discord_ui_update_view__', False): item._update_children_view(self.view) # type: ignore @@ -358,7 +358,7 @@ def remove_item(self, item: Item[Any]) -> Self: except ValueError: pass else: - if self._view and getattr(self._view, '__discord_ui_layout_view__', False): + if self._view and self._view._is_v2(): if getattr(item, '__discord_ui_update_view__', False): self._view._total_children -= len(tuple(item.walk_children())) # type: ignore else: @@ -392,7 +392,7 @@ def clear_items(self) -> Self: chaining. """ - if self._view and getattr(self._view, '__discord_ui_layout_view__', False): + if self._view and self._view._is_v2(): self._view._total_children -= sum(1 for _ in self.walk_children()) self._children.clear() return self diff --git a/discord/ui/section.py b/discord/ui/section.py index 209d681be012..845a3f296f2e 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -165,7 +165,7 @@ def add_item(self, item: Union[str, Item[Any]]) -> Self: item._parent = self self._children.append(item) - if self._view and getattr(self._view, '__discord_ui_layout_view__', False): + if self._view and self._view._is_v2(): self._view._total_children += 1 return self @@ -187,7 +187,7 @@ def remove_item(self, item: Item[Any]) -> Self: except ValueError: pass else: - if self._view and getattr(self._view, '__discord_ui_layout_view__', False): + if self._view and self._view._is_v2(): self._view._total_children -= 1 return self @@ -218,7 +218,7 @@ def clear_items(self) -> Self: This function returns the class instance to allow for fluent-style chaining. """ - if self._view and getattr(self._view, '__discord_ui_layout_view__', False): + if self._view and self._view._is_v2(): self._view._total_children -= len(self._children) # we don't count the accessory because it is required self._children.clear() diff --git a/discord/ui/view.py b/discord/ui/view.py index bf40348bb288..daa99e678f54 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -956,8 +956,6 @@ class LayoutView(BaseView): If ``None`` then there is no timeout. """ - __discord_ui_layout_view__: ClassVar[bool] = True - if TYPE_CHECKING: @classmethod From 3cf3e1a07464d539423c76ccb316a022669f0456 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 29 Jul 2025 12:45:14 +0200 Subject: [PATCH 204/272] remove _can_be_dynamic --- discord/ui/dynamic.py | 3 --- discord/ui/item.py | 7 ------- 2 files changed, 10 deletions(-) diff --git a/discord/ui/dynamic.py b/discord/ui/dynamic.py index 1c0efae69791..fb38b4b2e0cb 100644 --- a/discord/ui/dynamic.py +++ b/discord/ui/dynamic.py @@ -109,9 +109,6 @@ def __init__( if not self.item.is_dispatchable(): raise TypeError('item must be dispatchable, e.g. not a URL button') - if not self.item._can_be_dynamic(): - raise TypeError(f'{self.item.__class__.__name__} cannot be set as a dynamic item') - if not self.template.match(self.custom_id): raise ValueError(f'item custom_id {self.custom_id!r} must match the template {self.template.pattern!r}') diff --git a/discord/ui/item.py b/discord/ui/item.py index e6b5bb44618a..4b1abcf4fd41 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -156,13 +156,6 @@ async def _run_checks(self, interaction: Interaction[ClientT]) -> bool: return can_run - def _can_be_dynamic(self) -> bool: - # if an item can be dynamic then it must override this, this is mainly used - # by DynamicItem's so a user cannot set, for example, a Container with a dispatchable - # button as a dynamic item, and cause errors where Container can't be dispatched - # or lost interactions - return False - async def callback(self, interaction: Interaction[ClientT]) -> Any: """|coro| From 482a1cdd352c12388762b03557e63baad9145dab Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 29 Jul 2025 12:45:49 +0200 Subject: [PATCH 205/272] remove _can_be_dynamic --- discord/ui/button.py | 3 --- discord/ui/select.py | 3 --- 2 files changed, 6 deletions(-) diff --git a/discord/ui/button.py b/discord/ui/button.py index 93ec4fa4b280..98ced5f2a2f1 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -268,9 +268,6 @@ def is_persistent(self) -> bool: return self.url is not None return super().is_persistent() - def _can_be_dynamic(self) -> bool: - return True - def _refresh_component(self, button: ButtonComponent) -> None: self._underlying = button diff --git a/discord/ui/select.py b/discord/ui/select.py index 21aeb66f0ff6..c702dfc3c242 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -362,9 +362,6 @@ def from_component(cls, component: SelectMenu) -> BaseSelect[V]: kwrgs = {key: getattr(component, key) for key in constructor.__component_attributes__} return constructor(**kwrgs) - def _can_be_dynamic(self) -> bool: - return True - class Select(BaseSelect[V]): """Represents a UI select menu with a list of custom options. This is represented From 97932c75146150c0a5a2a65b42c11812d2d5ba3c Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 29 Jul 2025 12:45:57 +0200 Subject: [PATCH 206/272] Update discord/ui/media_gallery.py Co-authored-by: Danny <1695103+Rapptz@users.noreply.github.com> --- discord/ui/media_gallery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index 764c2b27cb8f..fac35310f79f 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -90,7 +90,7 @@ def __init__( self.id = id def __repr__(self) -> str: - return f'<{super().__repr__()[:-1]} items={len(self._underlying.items)}>' + return f'<{self.__class__.__name__} items={len(self._underlying.items)}>' @property def items(self) -> List[MediaGalleryItem]: From 0a054837e2925859f012ba71360ebdd985fa3326 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 29 Jul 2025 12:48:00 +0200 Subject: [PATCH 207/272] rename ._is_v2() to ._is_layout() --- discord/ui/action_row.py | 6 +++--- discord/ui/container.py | 6 +++--- discord/ui/section.py | 6 +++--- discord/ui/view.py | 16 ++++++++-------- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index e35850636520..8a773d08b746 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -261,7 +261,7 @@ def add_item(self, item: Item[Any]) -> Self: item._parent = self self._children.append(item) - if self._view and self._view._is_v2(): + if self._view and self._view._is_layout(): self._view._total_children += 1 return self @@ -283,7 +283,7 @@ def remove_item(self, item: Item[Any]) -> Self: except ValueError: pass else: - if self._view and self._view._is_v2(): + if self._view and self._view._is_layout(): self._view._total_children -= 1 return self @@ -314,7 +314,7 @@ def clear_items(self) -> Self: This function returns the class instance to allow for fluent-style chaining. """ - if self._view and self._view._is_v2(): + if self._view and self._view._is_layout(): self._view._total_children -= len(self._children) self._children.clear() return self diff --git a/discord/ui/container.py b/discord/ui/container.py index 2e0aabbf77de..47fec04f7b65 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -327,7 +327,7 @@ def add_item(self, item: Item[Any]) -> Self: raise TypeError(f'expected Item not {item.__class__.__name__}') self._children.append(item) - is_layout_view = self._view and self._view._is_v2() + is_layout_view = self._view and self._view._is_layout() if getattr(item, '__discord_ui_update_view__', False): item._update_children_view(self.view) # type: ignore @@ -358,7 +358,7 @@ def remove_item(self, item: Item[Any]) -> Self: except ValueError: pass else: - if self._view and self._view._is_v2(): + if self._view and self._view._is_layout(): if getattr(item, '__discord_ui_update_view__', False): self._view._total_children -= len(tuple(item.walk_children())) # type: ignore else: @@ -392,7 +392,7 @@ def clear_items(self) -> Self: chaining. """ - if self._view and self._view._is_v2(): + if self._view and self._view._is_layout(): self._view._total_children -= sum(1 for _ in self.walk_children()) self._children.clear() return self diff --git a/discord/ui/section.py b/discord/ui/section.py index 845a3f296f2e..51106b798ee4 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -165,7 +165,7 @@ def add_item(self, item: Union[str, Item[Any]]) -> Self: item._parent = self self._children.append(item) - if self._view and self._view._is_v2(): + if self._view and self._view._is_layout(): self._view._total_children += 1 return self @@ -187,7 +187,7 @@ def remove_item(self, item: Item[Any]) -> Self: except ValueError: pass else: - if self._view and self._view._is_v2(): + if self._view and self._view._is_layout(): self._view._total_children -= 1 return self @@ -218,7 +218,7 @@ def clear_items(self) -> Self: This function returns the class instance to allow for fluent-style chaining. """ - if self._view and self._view._is_v2(): + if self._view and self._view._is_layout(): self._view._total_children -= len(self._children) # we don't count the accessory because it is required self._children.clear() diff --git a/discord/ui/view.py b/discord/ui/view.py index daa99e678f54..fa667823027b 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -84,7 +84,7 @@ if TYPE_CHECKING: - from typing_extensions import Self + from typing_extensions import Self, TypeGuard import re from ..interactions import Interaction @@ -360,7 +360,7 @@ def __init__(self, *, timeout: Optional[float] = 180.0) -> None: self.__stopped: asyncio.Future[bool] = asyncio.get_running_loop().create_future() self._total_children: int = sum(1 for _ in self.walk_children()) - def _is_v2(self) -> bool: + def _is_layout(self) -> TypeGuard[LayoutView]: return False def __repr__(self) -> str: @@ -490,7 +490,7 @@ def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) row = 0 for component in message.components: - if not view._is_v2() and isinstance(component, ActionRowComponent): + if not view._is_layout() and isinstance(component, ActionRowComponent): for child in component.children: item = _component_to_item(child) item.row = row @@ -505,7 +505,7 @@ def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) item = _component_to_item(component) item.row = row - if item._is_v2() and not view._is_v2(): + if item._is_v2() and not view._is_layout(): raise RuntimeError(f'{item.__class__.__name__} cannot be added to {view.__class__.__name__}') view.add_item(item) @@ -536,7 +536,7 @@ def add_item(self, item: Item[Any]) -> Self: if not isinstance(item, Item): raise TypeError(f'expected Item not {item.__class__.__name__}') - if item._is_v2() and not self._is_v2(): + if item._is_v2() and not self._is_layout(): raise ValueError('v2 items cannot be added to this view') item._view = self @@ -546,7 +546,7 @@ def add_item(self, item: Item[Any]) -> Self: item._update_children_view(self) # type: ignore added += len(tuple(item.walk_children())) # type: ignore - if self._is_v2() and self._total_children + added > 40: + if self._is_layout() and self._total_children + added > 40: raise ValueError('maximum number of children exceeded') self._total_children += added self._children.append(item) @@ -834,7 +834,7 @@ def from_dict(cls, data: List[ComponentPayload], *, timeout: Optional[float] = 1 for raw in data: item = _component_data_to_item(raw) - if item._is_v2() and not self._is_v2(): + if item._is_v2() and not self._is_layout(): continue self.add_item(item) @@ -992,7 +992,7 @@ def __init_subclass__(cls) -> None: children.update(callback_children) cls.__view_children_items__ = children - def _is_v2(self) -> bool: + def _is_layout(self) -> bool: return True def to_components(self): From 61162c6a2011e488335d163b73b212454d3639f0 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 29 Jul 2025 12:48:22 +0200 Subject: [PATCH 208/272] remove outdated width property --- discord/ui/view.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index fa667823027b..fb626c69c36f 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -887,10 +887,6 @@ def __init__(self, *, timeout: Optional[float] = 180.0): super().__init__(timeout=timeout) self.__weights = _ViewWeights(self._children) - @property - def width(self): - return 5 - def to_components(self) -> List[Dict[str, Any]]: def key(item: Item) -> int: return item._rendered_row or 0 From db711723e097b54fd4c95f97216fd5d1cd96c47c Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 29 Jul 2025 12:56:39 +0200 Subject: [PATCH 209/272] rename SectionComponent.components to SectionComponent.children --- discord/components.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/discord/components.py b/discord/components.py index fe68f0aa7787..00e5db0154d3 100644 --- a/discord/components.py +++ b/discord/components.py @@ -77,7 +77,7 @@ from .state import ConnectionState ActionRowChildComponentType = Union['Button', 'SelectMenu', 'TextInput'] - SectionComponentType = Union['TextDisplay', 'Button'] + SectionComponentType = Union['TextDisplay'] MessageComponentType = Union[ ActionRowChildComponentType, SectionComponentType, @@ -753,7 +753,7 @@ class SectionComponent(Component): Attributes ---------- - components: List[Union[:class:`TextDisplay`, :class:`Button`]] + children: List[:class:`TextDisplay`] The components on this section. accessory: :class:`Component` The section accessory. @@ -762,7 +762,7 @@ class SectionComponent(Component): """ __slots__ = ( - 'components', + 'children', 'accessory', 'id', ) @@ -770,14 +770,14 @@ class SectionComponent(Component): __repr_info__ = __slots__ def __init__(self, data: SectionComponentPayload, state: Optional[ConnectionState]) -> None: - self.components: List[SectionComponentType] = [] + self.children: List[SectionComponentType] = [] self.accessory: Component = _component_factory(data['accessory'], state) # type: ignore self.id: Optional[int] = data.get('id') for component_data in data['components']: component = _component_factory(component_data, state) if component is not None: - self.components.append(component) # type: ignore # should be the correct type here + self.children.append(component) # type: ignore # should be the correct type here @property def type(self) -> Literal[ComponentType.section]: @@ -786,7 +786,7 @@ def type(self) -> Literal[ComponentType.section]: def to_dict(self) -> SectionComponentPayload: payload: SectionComponentPayload = { 'type': self.type.value, - 'components': [c.to_dict() for c in self.components], + 'components': [c.to_dict() for c in self.children], 'accessory': self.accessory.to_dict(), } From 5a137e8214e5fd88dc39d039b8819fb1ede2d830 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 29 Jul 2025 12:59:24 +0200 Subject: [PATCH 210/272] typings and remove View.from_dict --- discord/ui/view.py | 197 +++------------------------------------------ 1 file changed, 9 insertions(+), 188 deletions(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index fb626c69c36f..eb4187413545 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -56,9 +56,6 @@ from ..components import ( Component, ActionRow as ActionRowComponent, - MediaGalleryItem, - SelectDefaultValue, - UnfurledMediaItem, _component_factory, Button as ButtonComponent, SelectMenu as SelectComponent, @@ -68,12 +65,9 @@ FileComponent, SeparatorComponent, ThumbnailComponent, - SelectOption, Container as ContainerComponent, ) -from ..utils import get as _utils_get, _get_as_snowflake, find as _utils_find -from ..enums import SeparatorSpacing, TextStyle, try_enum, ButtonStyle -from ..emoji import PartialEmoji +from ..utils import get as _utils_get, find as _utils_find # fmt: off __all__ = ( @@ -102,8 +96,13 @@ def _walk_all_components(components: List[Component]) -> Iterator[Component]: for item in components: - if isinstance(item, (ActionRowComponent, ContainerComponent, SectionComponent)): + if isinstance(item, ActionRowComponent): yield from item.children + elif isinstance(item, ContainerComponent): + yield from _walk_all_components(item.children) + elif isinstance(item, SectionComponent): + yield from item.children + yield item.accessory else: yield item @@ -156,138 +155,6 @@ def _component_to_item(component: Component, parent: Optional[Item] = None) -> I return item -def _component_data_to_item(data: ComponentPayload, parent: Optional[Item] = None) -> Item: - if data['type'] == 1: - from .action_row import ActionRow - - item = ActionRow( - *(_component_data_to_item(c) for c in data['components']), - id=data.get('id'), - ) - elif data['type'] == 2: - from .button import Button - - emoji = data.get('emoji') - - item = Button( - style=try_enum(ButtonStyle, data['style']), - custom_id=data.get('custom_id'), - url=data.get('url'), - disabled=data.get('disabled', False), - emoji=PartialEmoji.from_dict(emoji) if emoji else None, - label=data.get('label'), - sku_id=_get_as_snowflake(data, 'sku_id'), - ) - elif data['type'] == 3: - from .select import Select - - item = Select( - custom_id=data['custom_id'], - placeholder=data.get('placeholder'), - min_values=data.get('min_values', 1), - max_values=data.get('max_values', 1), - disabled=data.get('disabled', False), - id=data.get('id'), - options=[SelectOption.from_dict(o) for o in data.get('options', [])], - ) - elif data['type'] == 4: - from .text_input import TextInput - - item = TextInput( - label=data['label'], - style=try_enum(TextStyle, data['style']), - custom_id=data['custom_id'], - placeholder=data.get('placeholder'), - default=data.get('value'), - required=data.get('required', True), - min_length=data.get('min_length'), - max_length=data.get('max_length'), - id=data.get('id'), - ) - elif data['type'] in (5, 6, 7, 8): - from .select import ( - UserSelect, - RoleSelect, - MentionableSelect, - ChannelSelect, - ) - - cls_map: Dict[int, Type[Union[UserSelect, RoleSelect, MentionableSelect, ChannelSelect]]] = { - 5: UserSelect, - 6: RoleSelect, - 7: MentionableSelect, - 8: ChannelSelect, - } - - item = cls_map[data['type']]( - custom_id=data['custom_id'], # type: ignore # will always be present in this point - placeholder=data.get('placeholder'), - min_values=data.get('min_values', 1), - max_values=data.get('max_values', 1), - disabled=data.get('disabled', False), - default_values=[SelectDefaultValue.from_dict(v) for v in data.get('default_values', [])], - id=data.get('id'), - ) - elif data['type'] == 9: - from .section import Section - - item = Section( - *(_component_data_to_item(c) for c in data['components']), - accessory=_component_data_to_item(data['accessory']), - id=data.get('id'), - ) - elif data['type'] == 10: - from .text_display import TextDisplay - - item = TextDisplay(data['content'], id=data.get('id')) - elif data['type'] == 11: - from .thumbnail import Thumbnail - - item = Thumbnail( - UnfurledMediaItem._from_data(data['media'], None), - description=data.get('description'), - spoiler=data.get('spoiler', False), - id=data.get('id'), - ) - elif data['type'] == 12: - from .media_gallery import MediaGallery - - item = MediaGallery( - *(MediaGalleryItem._from_data(m, None) for m in data['items']), - id=data.get('id'), - ) - elif data['type'] == 13: - from .file import File - - item = File( - UnfurledMediaItem._from_data(data['file'], None), - spoiler=data.get('spoiler', False), - id=data.get('id'), - ) - elif data['type'] == 14: - from .separator import Separator - - item = Separator( - visible=data.get('divider', True), - spacing=try_enum(SeparatorSpacing, data.get('spacing', 1)), - id=data.get('id'), - ) - elif data['type'] == 17: - from .container import Container - - item = Container( - *(_component_data_to_item(c) for c in data['components']), - accent_colour=data.get('accent_color'), - spoiler=data.get('spoiler', False), - id=data.get('type'), - ) - else: - raise ValueError(f'invalid item with type {data["type"]} provided') - - item._parent = parent - return item - - class _ViewWeights: # fmt: off __slots__ = ( @@ -455,7 +322,7 @@ def children(self) -> List[Item[Self]]: return self._children.copy() @classmethod - def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> Any: + def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> Self: """Converts a message's components into a :class:`View`. The :attr:`.Message.components` of a message are read-only @@ -485,7 +352,6 @@ def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) The converted view. This will always return one of :class:`View` or :class:`LayoutView`, and not one of its subclasses. """ - cls = cls._to_minimal_cls() view = cls(timeout=timeout) row = 0 @@ -795,51 +661,6 @@ def walk_children(self) -> Generator[Item[Any], None, None]: # if it has this attribute then it can contain children yield from child.walk_children() # type: ignore - @classmethod - def _to_minimal_cls(cls) -> Type[Union[View, LayoutView]]: - if issubclass(cls, View): - return View - elif issubclass(cls, LayoutView): - return LayoutView - raise RuntimeError - - @classmethod - def from_dict(cls, data: List[ComponentPayload], *, timeout: Optional[float] = 180.0) -> Any: - r"""Converts a :class:`list` of :class:`dict`\s to a :class:`View` or :class:`LayoutView`, - provided as in the format that Discord expects it to be in. - - You can find out about this format in the :ddocs:`official Discord documentation `. - - This method should be called on the respective class (or subclass), so if you - want to convert v2 items, you should call :meth:`LayoutView.from_dict`, or the same - method from any subclass of it; and not :meth:`View.from_message`, or the same - method from any subclass of it. - - Parameters - ---------- - data: List[:class:`dict`] - The array of dictionaries to convert into a LayoutView - timeout: Optional[:class:`float`] - The timeout of the converted view. - - Returns - ------- - Union[:class:`View`, :class:`LayoutView`] - The converted view. This will always return one of :class:`View` or - :class:`LayoutView`, and not one of its subclasses. - """ - cls = cls._to_minimal_cls() - self = cls(timeout=timeout) - - for raw in data: - item = _component_data_to_item(raw) - - if item._is_v2() and not self._is_layout(): - continue - - self.add_item(item) - return self - class View(BaseView): """Represents a UI view. @@ -988,7 +809,7 @@ def __init_subclass__(cls) -> None: children.update(callback_children) cls.__view_children_items__ = children - def _is_layout(self) -> bool: + def _is_layout(self) -> TypeGuard[LayoutView]: return True def to_components(self): From ad86240f7139f671c64a07a0967d9bdad476477d Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 29 Jul 2025 13:01:43 +0200 Subject: [PATCH 211/272] fix docs --- docs/api.rst | 8 -------- 1 file changed, 8 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index e6cc9b476299..157f59e65ef9 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -5846,14 +5846,6 @@ PollMedia .. autoclass:: PollMedia :members: -CallMessage -~~~~~~~~~~~~~~~~~~~ - -.. attributetable:: CallMessage - -.. autoclass:: CallMessage() - :members: - UnfurledMediaItem ~~~~~~~~~~~~~~~~~ From 5a5d2704d33f11defcd585abc83c7086ca7f7ed1 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 29 Jul 2025 13:02:13 +0200 Subject: [PATCH 212/272] typing --- discord/ui/view.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index 364899196afc..4e94cbf08c3e 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -227,7 +227,7 @@ def __init__(self, *, timeout: Optional[float] = 180.0) -> None: self.__stopped: asyncio.Future[bool] = asyncio.get_running_loop().create_future() self._total_children: int = sum(1 for _ in self.walk_children()) - def _is_layout(self) -> TypeGuard[LayoutView]: + def _is_layout(self) -> TypeGuard[LayoutView]: # type: ignore return False def __repr__(self) -> str: @@ -804,7 +804,7 @@ def __init_subclass__(cls) -> None: children.update(callback_children) cls.__view_children_items__ = children - def _is_layout(self) -> TypeGuard[LayoutView]: + def _is_layout(self) -> TypeGuard[LayoutView]: # type: ignore return True def to_components(self): From fc6283a46136e9c18686625db7a666018e05eee6 Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 29 Jul 2025 13:05:40 +0200 Subject: [PATCH 213/272] Update discord/ui/container.py Co-authored-by: Danny <1695103+Rapptz@users.noreply.github.com> --- discord/ui/container.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index 47fec04f7b65..cf0685decc7f 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -159,7 +159,7 @@ def __init__( self.id = id def __repr__(self) -> str: - return f'<{super().__repr__()[:-1]} children={len(self._children)}>' + return f'<{self.__class__.__name__} children={len(self._children)}>' def _init_children(self) -> List[Item[Any]]: children = [] From 9f13513e1ac52cd56d8b7662451f1fa477f1c40f Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 29 Jul 2025 13:07:21 +0200 Subject: [PATCH 214/272] Update discord/ui/section.py Co-authored-by: Danny <1695103+Rapptz@users.noreply.github.com> --- discord/ui/section.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/section.py b/discord/ui/section.py index 51106b798ee4..151e7ae7f27c 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -99,7 +99,7 @@ def __init__( self.id = id def __repr__(self) -> str: - return f'<{super().__repr__()[:-1]} children={len(self._children)}' + return f'<{self.__class__.__name__} children={len(self._children)}>' @property def type(self) -> Literal[ComponentType.section]: From a1ba2f26235c1d1beda3180b1fcdd289481053e1 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 29 Jul 2025 13:08:29 +0200 Subject: [PATCH 215/272] update ActionRow --- discord/ui/action_row.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 10bbe1ca2fb9..40409fbfcec3 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -150,10 +150,9 @@ def __init__( id: Optional[int] = None, ) -> None: super().__init__() - self._weight: int = 0 self._children: List[Item[V]] = self._init_children() self._children.extend(children) - self._weight += sum(i.width for i in children) + self._weight: int = sum(i.width for i in self._children) if self._weight > 5: raise ValueError('maximum number of children exceeded') @@ -186,7 +185,6 @@ def _init_children(self) -> List[Item[Any]]: item.callback = _ActionRowCallback(func, self, item) # type: ignore item._parent = getattr(func, '__discord_ui_parent__', self) setattr(self, func.__name__, item) - self._weight += item.width children.append(item) return children From f1c11f133bba33084772ec27af22002ca33e8346 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 29 Jul 2025 13:10:24 +0200 Subject: [PATCH 216/272] update Section.from_component --- discord/ui/section.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/section.py b/discord/ui/section.py index 151e7ae7f27c..d3e4d8fb0384 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -232,7 +232,7 @@ def from_component(cls, component: SectionComponent) -> Self: self = cls(id=component.id, accessory=MISSING) self.accessory = _component_to_item(component.accessory, self) self.id = component.id - self._children = [_component_to_item(c, self) for c in component.components] + self._children = [_component_to_item(c, self) for c in component.children] return self From e75b1d5538ec4caceeb9288f8e7c1f98e5c21eca Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 29 Jul 2025 13:49:22 +0200 Subject: [PATCH 217/272] remove row from v2 components, and add notes on button and select --- discord/ui/action_row.py | 23 ++++------------------- discord/ui/button.py | 4 ++++ discord/ui/container.py | 21 +-------------------- discord/ui/file.py | 11 ----------- discord/ui/item.py | 9 ++++++--- discord/ui/media_gallery.py | 11 ----------- discord/ui/section.py | 19 +------------------ discord/ui/select.py | 4 ++++ discord/ui/separator.py | 14 +++----------- discord/ui/text_display.py | 11 ++--------- discord/ui/thumbnail.py | 16 +++++----------- discord/ui/view.py | 13 +------------ 12 files changed, 31 insertions(+), 125 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 40409fbfcec3..c6a7ec558aac 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -124,13 +124,6 @@ async def row_button(self, interaction: discord.Interaction, button: discord.ui. ---------- \*children: :class:`Item` The initial children of this action row. - row: Optional[:class:`int`] - The relative row this action row belongs to. By default - items are arranged automatically into those 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 39 (i.e. zero indexed) id: Optional[:class:`int`] The ID of this component. This must be unique across the view. """ @@ -139,14 +132,12 @@ async def row_button(self, interaction: discord.Interaction, button: discord.ui. __discord_ui_action_row__: ClassVar[bool] = True __discord_ui_update_view__: ClassVar[bool] = True __item_repr_attributes__ = ( - 'row', 'id', ) def __init__( self, *children: Item[V], - row: Optional[int] = None, id: Optional[int] = None, ) -> None: super().__init__() @@ -158,7 +149,6 @@ def __init__( raise ValueError('maximum number of children exceeded') self.id = id - self.row = row def __init_subclass__(cls) -> None: super().__init_subclass__() @@ -253,6 +243,7 @@ def add_item(self, item: Item[Any]) -> Self: item._view = self._view item._parent = self + self._weight += 1 self._children.append(item) if self._view and self._view._is_layout(): @@ -279,6 +270,7 @@ def remove_item(self, item: Item[Any]) -> Self: else: if self._view and self._view._is_layout(): self._view._total_children -= 1 + self._weight -= 1 return self @@ -311,19 +303,12 @@ def clear_items(self) -> Self: if self._view and self._view._is_layout(): self._view._total_children -= len(self._children) self._children.clear() + self._weight = 0 return self def to_component_dict(self) -> Dict[str, Any]: components = [] - - def key(item: Item) -> int: - if item._rendered_row is not None: - return item._rendered_row - if item._row is not None: - return item._row - return sys.maxsize - - for component in sorted(self.children, key=key): + for component in self.children: components.append(component.to_component_dict()) base = { diff --git a/discord/ui/button.py b/discord/ui/button.py index 98ced5f2a2f1..5a30f288cd36 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -318,6 +318,10 @@ def button( 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). + + .. note:: + + This parameter is ignored when used in a :class:`ActionRow`. id: Optional[:class:`int`] The ID of this component. This must be unique across the view. diff --git a/discord/ui/container.py b/discord/ui/container.py index cf0685decc7f..3a311308d18e 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -115,13 +115,6 @@ class MyView(ui.LayoutView): spoiler: :class:`bool` Whether to flag this container as a spoiler. Defaults to ``False``. - row: Optional[:class:`int`] - The relative row this container belongs to. By default - items are arranged automatically into those 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 39 (i.e. zero indexed) id: Optional[:class:`int`] The ID of this component. This must be unique across the view. """ @@ -132,7 +125,6 @@ class MyView(ui.LayoutView): __item_repr_attributes__ = ( 'accent_colour', 'spoiler', - 'row', 'id', ) @@ -142,7 +134,6 @@ def __init__( accent_colour: Optional[Union[Colour, int]] = None, accent_color: Optional[Union[Color, int]] = None, spoiler: bool = False, - row: Optional[int] = None, id: Optional[int] = None, ) -> None: super().__init__() @@ -154,8 +145,6 @@ def __init__( self.spoiler: bool = spoiler self._colour = accent_colour if accent_colour is not None else accent_color - - self.row = row self.id = id def __repr__(self) -> str: @@ -251,15 +240,7 @@ def _is_v2(self) -> bool: def to_components(self) -> List[Dict[str, Any]]: components = [] - - def key(item: Item) -> int: - if item._rendered_row is not None: - return item._rendered_row - if item._row is not None: - return item._row - return sys.maxsize - - for i in sorted(self._children, key=key): + for i in self._children: components.append(i.to_component_dict()) return components diff --git a/discord/ui/file.py b/discord/ui/file.py index 9ad659f86d45..746f18fe059c 100644 --- a/discord/ui/file.py +++ b/discord/ui/file.py @@ -66,13 +66,6 @@ class MyView(ui.LayoutView): meet the ``attachment://`` format. spoiler: :class:`bool` Whether to flag this file as a spoiler. Defaults to ``False``. - row: Optional[:class:`int`] - The relative row this file component belongs to. By default - items are arranged automatically into those 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 39 (i.e. zero indexed) id: Optional[:class:`int`] The ID of this component. This must be unique across the view. """ @@ -80,7 +73,6 @@ class MyView(ui.LayoutView): __item_repr_attributes__ = ( 'media', 'spoiler', - 'row', 'id', ) @@ -89,7 +81,6 @@ def __init__( media: Union[str, UnfurledMediaItem], *, spoiler: bool = False, - row: Optional[int] = None, id: Optional[int] = None, ) -> None: super().__init__() @@ -98,8 +89,6 @@ def __init__( spoiler=spoiler, id=id, ) - - self.row = row self.id = id def _is_v2(self): diff --git a/discord/ui/item.py b/discord/ui/item.py index 4b1abcf4fd41..65d8d9c54c7c 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -79,7 +79,6 @@ def __init__(self): # only called upon edit and we're mainly interested during initial creation time. self._provided_custom_id: bool = False self._id: Optional[int] = None - self._max_row: int = 5 if not self._is_v2() else 40 self._parent: Optional[Item] = None def to_component_dict(self) -> Dict[str, Any]: @@ -120,12 +119,16 @@ def row(self) -> Optional[int]: @row.setter def row(self, value: Optional[int]) -> None: + if self._is_v2(): + # row is ignored on v2 components + return + if value is None: self._row = None - elif self._max_row > value >= 0: + elif 5 > value >= 0: self._row = value else: - raise ValueError(f'row cannot be negative or greater than or equal to {self._max_row}') + raise ValueError('row cannot be negative or greater than or equal to 5') if self._rendered_row is None: self._rendered_row = value diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index fac35310f79f..d2dc231e4f74 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -56,27 +56,18 @@ class MediaGallery(Item[V]): ---------- \*items: :class:`.MediaGalleryItem` The initial items of this gallery. - row: Optional[:class:`int`] - The relative row this media gallery belongs to. By default - items are arranged automatically into those 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 39 (i.e. zero indexed) id: Optional[:class:`int`] The ID of this component. This must be unique across the view. """ __item_repr_attributes__ = ( 'items', - 'row', 'id', ) def __init__( self, *items: MediaGalleryItem, - row: Optional[int] = None, id: Optional[int] = None, ) -> None: super().__init__() @@ -85,8 +76,6 @@ def __init__( items=list(items), id=id, ) - - self.row = row self.id = id def __repr__(self) -> str: diff --git a/discord/ui/section.py b/discord/ui/section.py index d3e4d8fb0384..594513649e51 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -55,20 +55,12 @@ class Section(Item[V]): The text displays of this section. Up to 3. accessory: :class:`Item` The section accessory. - row: Optional[:class:`int`] - The relative row this section belongs to. By default - items are arranged automatically into those 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 39 (i.e. zero indexed) id: Optional[:class:`int`] The ID of this component. This must be unique across the view. """ __item_repr_attributes__ = ( 'accessory', - 'row', 'id', ) __discord_ui_section__: ClassVar[bool] = True @@ -83,7 +75,6 @@ def __init__( self, *children: Union[Item[V], str], accessory: Item[V], - row: Optional[int] = None, id: Optional[int] = None, ) -> None: super().__init__() @@ -95,7 +86,6 @@ def __init__( [c if isinstance(c, Item) else TextDisplay(c) for c in children], ) self.accessory: Item[V] = accessory - self.row = row self.id = id def __repr__(self) -> str: @@ -239,14 +229,7 @@ def from_component(cls, component: SectionComponent) -> Self: def to_components(self) -> List[Dict[str, Any]]: components = [] - def key(item: Item) -> int: - if item._rendered_row is not None: - return item._rendered_row - if item._row is not None: - return item._row - return sys.maxsize - - for component in sorted(self._children, key=key): + for component in self._children: components.append(component.to_component_dict()) return components diff --git a/discord/ui/select.py b/discord/ui/select.py index c702dfc3c242..5c14c5ef0095 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -395,6 +395,10 @@ class Select(BaseSelect[V]): 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). + + .. note:: + + This parameter is ignored when used in a :class:`ActionRow`. id: Optional[:class:`int`] The ID of the component. This must be unique across the view. diff --git a/discord/ui/separator.py b/discord/ui/separator.py index f90fbaa4b2f8..e149a9321fd8 100644 --- a/discord/ui/separator.py +++ b/discord/ui/separator.py @@ -53,21 +53,16 @@ class Separator(Item[V]): is whether a divider line should be shown or not. spacing: :class:`.SeparatorSpacing` The spacing of this separator. - row: Optional[:class:`int`] - The relative row this separator belongs to. By default - items are arranged automatically into those 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 39 (i.e. zero indexed) id: Optional[:class:`int`] The ID of this component. This must be unique across the view. """ + __slots__ = ( + '_underlying', + ) __item_repr_attributes__ = ( 'visible', 'spacing', - 'row', 'id', ) @@ -76,7 +71,6 @@ def __init__( *, visible: bool = True, spacing: SeparatorSpacing = SeparatorSpacing.small, - row: Optional[int] = None, id: Optional[int] = None, ) -> None: super().__init__() @@ -85,8 +79,6 @@ def __init__( visible=visible, id=id, ) - - self.row = row self.id = id def _is_v2(self): diff --git a/discord/ui/text_display.py b/discord/ui/text_display.py index 9ba7f294e4d0..5bc8ce3a3e8e 100644 --- a/discord/ui/text_display.py +++ b/discord/ui/text_display.py @@ -50,22 +50,15 @@ class TextDisplay(Item[V]): ---------- content: :class:`str` The content of this text display. Up to 4000 characters. - row: Optional[:class:`int`] - The relative row this text display belongs to. By default - items are arranged automatically into those 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 39 (i.e. zero indexed) id: Optional[:class:`int`] The ID of this component. This must be unique across the view. """ + __slots__ = ('content',) + def __init__(self, content: str, *, row: Optional[int] = None, id: Optional[int] = None) -> None: super().__init__() self.content: str = content - - self.row = row self.id = id def to_component_dict(self): diff --git a/discord/ui/thumbnail.py b/discord/ui/thumbnail.py index f16cdc89ef1b..3a485aa05b71 100644 --- a/discord/ui/thumbnail.py +++ b/discord/ui/thumbnail.py @@ -55,17 +55,15 @@ class Thumbnail(Item[V]): The description of this thumbnail. Up to 256 characters. Defaults to ``None``. spoiler: :class:`bool` Whether to flag this thumbnail as a spoiler. Defaults to ``False``. - row: Optional[:class:`int`] - The relative row this thumbnail belongs to. By default - items are arranged automatically into those 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 39 (i.e. zero indexed) id: Optional[:class:`int`] The ID of this component. This must be unique across the view. """ + __slots__ = ( + '_media', + 'description', + 'spoiler', + ) __item_repr_attributes__ = ( 'media', 'description', @@ -80,16 +78,12 @@ def __init__( *, description: Optional[str] = None, spoiler: bool = False, - row: Optional[int] = None, id: Optional[int] = None, ) -> None: super().__init__() - self._media: UnfurledMediaItem = UnfurledMediaItem(media) if isinstance(media, str) else media self.description: Optional[str] = description self.spoiler: bool = spoiler - - self.row = row self.id = id @property diff --git a/discord/ui/view.py b/discord/ui/view.py index 4e94cbf08c3e..bc186cb4db80 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -809,18 +809,7 @@ def _is_layout(self) -> TypeGuard[LayoutView]: # type: ignore def to_components(self): components: List[Dict[str, Any]] = [] - - # sorted by row, which in LayoutView indicates the position of the component in the - # payload instead of in which ActionRow it should be placed on. - - def key(item: Item) -> int: - if item._rendered_row is not None: - return item._rendered_row - if item._row is not None: - return item._row - return sys.maxsize - - for i in sorted(self._children, key=key): + for i in self._children: components.append(i.to_component_dict()) return components From bd5e1638b9e2f41aa1dc657102bcba1ab2bd1e31 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 29 Jul 2025 13:51:33 +0200 Subject: [PATCH 218/272] add note on missing places --- discord/ui/button.py | 6 +++++- discord/ui/select.py | 22 +++++++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/discord/ui/button.py b/discord/ui/button.py index 5a30f288cd36..7bec05e89722 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -77,6 +77,10 @@ class Button(Item[V]): 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). + + .. note:: + + This parameter is ignored when used in a :class:`ActionRow` or v2 component. sku_id: Optional[:class:`int`] The SKU ID this button sends you to. Can't be combined with ``url``, ``label``, ``emoji`` nor ``custom_id``. @@ -321,7 +325,7 @@ def button( .. note:: - This parameter is ignored when used in a :class:`ActionRow`. + This parameter is ignored when used in a :class:`ActionRow` or v2 component. id: Optional[:class:`int`] The ID of this component. This must be unique across the view. diff --git a/discord/ui/select.py b/discord/ui/select.py index 5c14c5ef0095..4e1c783004fb 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -398,7 +398,7 @@ class Select(BaseSelect[V]): .. note:: - This parameter is ignored when used in a :class:`ActionRow`. + This parameter is ignored when used in a :class:`ActionRow` or v2 component. id: Optional[:class:`int`] The ID of the component. This must be unique across the view. @@ -560,6 +560,10 @@ class UserSelect(BaseSelect[V]): 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). + + .. note:: + + This parameter is ignored when used in a :class:`ActionRow` or v2 component. id: Optional[:class:`int`] The ID of the component. This must be unique across the view. @@ -658,6 +662,10 @@ class RoleSelect(BaseSelect[V]): 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). + + .. note:: + + This parameter is ignored when used in a :class:`ActionRow` or v2 component. id: Optional[:class:`int`] The ID of the component. This must be unique across the view. @@ -752,6 +760,10 @@ class MentionableSelect(BaseSelect[V]): 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). + + .. note:: + + This parameter is ignored when used in a :class:`ActionRow` or v2 component. id: Optional[:class:`int`] The ID of the component. This must be unique across the view. @@ -852,6 +864,10 @@ class ChannelSelect(BaseSelect[V]): 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). + + .. note:: + + This parameter is ignored when used in a :class:`ActionRow` or v2 component. id: Optional[:class:`int`] The ID of the component. This must be unique across the view. @@ -1086,6 +1102,10 @@ async def select_channels(self, interaction: discord.Interaction, select: Channe 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). + + .. note:: + + This parameter is ignored when used in a :class:`ActionRow` or v2 component. min_values: :class:`int` The minimum number of items that must be chosen for this select menu. Defaults to 1 and must be between 0 and 25. From 8041a0860dc5d7b00619fab81a26bf1290de7d85 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 31 Jul 2025 17:05:58 +0200 Subject: [PATCH 219/272] remove MISSING check --- discord/ui/container.py | 11 ++++++----- discord/ui/section.py | 3 +-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index 3a311308d18e..14db3a243b6f 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -138,10 +138,8 @@ def __init__( ) -> None: super().__init__() self._children: List[Item[V]] = self._init_children() - - if children is not MISSING: - for child in children: - self.add_item(child) + for child in children: + self.add_item(child) self.spoiler: bool = spoiler self._colour = accent_colour if accent_colour is not None else accent_color @@ -197,7 +195,10 @@ def __init_subclass__(cls) -> None: cls.__container_children_items__ = children - def _update_children_view(self, view) -> None: + def _update_children_view(self, view: V) -> None: + if not view._is_layout(): + return + for child in self._children: child._view = view if getattr(child, '__discord_ui_update_view__', False): diff --git a/discord/ui/section.py b/discord/ui/section.py index 594513649e51..16ec2d459cc2 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -23,7 +23,6 @@ """ from __future__ import annotations -import sys from typing import TYPE_CHECKING, Any, Dict, Generator, List, Literal, Optional, TypeVar, Union, ClassVar from .item import Item @@ -79,7 +78,7 @@ def __init__( ) -> None: super().__init__() self._children: List[Item[V]] = [] - if children is not MISSING: + if children: if len(children) > 3: raise ValueError('maximum number of children exceeded') self._children.extend( From 7a13bb028514e19bcbff9d7324577f93df78178f Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 31 Jul 2025 17:08:27 +0200 Subject: [PATCH 220/272] run black --- discord/ui/action_row.py | 4 +--- discord/ui/separator.py | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index c6a7ec558aac..63a5e656454f 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -131,9 +131,7 @@ async def row_button(self, interaction: discord.Interaction, button: discord.ui. __action_row_children_items__: ClassVar[List[ItemCallbackType[Any]]] = [] __discord_ui_action_row__: ClassVar[bool] = True __discord_ui_update_view__: ClassVar[bool] = True - __item_repr_attributes__ = ( - 'id', - ) + __item_repr_attributes__ = ('id',) def __init__( self, diff --git a/discord/ui/separator.py b/discord/ui/separator.py index e149a9321fd8..e9ba7d789c5d 100644 --- a/discord/ui/separator.py +++ b/discord/ui/separator.py @@ -57,9 +57,7 @@ class Separator(Item[V]): The ID of this component. This must be unique across the view. """ - __slots__ = ( - '_underlying', - ) + __slots__ = ('_underlying',) __item_repr_attributes__ = ( 'visible', 'spacing', From f8ed5cbfc4fa0066e28d5e7be35391f51957f9f5 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 31 Jul 2025 17:10:12 +0200 Subject: [PATCH 221/272] remove unused imports --- discord/ui/action_row.py | 1 - discord/ui/container.py | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 63a5e656454f..f0c3355330e8 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -23,7 +23,6 @@ """ from __future__ import annotations -import sys from typing import ( TYPE_CHECKING, Any, diff --git a/discord/ui/container.py b/discord/ui/container.py index 14db3a243b6f..bfdba1775ba0 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -25,7 +25,6 @@ import copy import os -import sys from typing import ( TYPE_CHECKING, Any, @@ -43,7 +42,7 @@ from .item import Item, ItemCallbackType from .view import _component_to_item, LayoutView from ..enums import ComponentType -from ..utils import MISSING, get as _utils_get +from ..utils import get as _utils_get from ..colour import Colour, Color if TYPE_CHECKING: From 12d8acbe41712d0c60ce166caa916db8314de85a Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 31 Jul 2025 17:12:18 +0200 Subject: [PATCH 222/272] # type: ignore --- discord/ui/container.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index bfdba1775ba0..72100b28e317 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -194,7 +194,7 @@ def __init_subclass__(cls) -> None: cls.__container_children_items__ = children - def _update_children_view(self, view: V) -> None: + def _update_children_view(self, view: V) -> None: # type: ignore if not view._is_layout(): return From 5f227bfd4a0c4df3c24c39ffb8305b9a2371ec69 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Fri, 1 Aug 2025 16:10:34 +0200 Subject: [PATCH 223/272] replace \_\_discord_ui_update_view_\_ with methods --- discord/ui/action_row.py | 11 +++++++---- discord/ui/container.py | 36 ++++++++++++++---------------------- discord/ui/item.py | 6 ++++++ discord/ui/section.py | 13 +++++++++---- discord/ui/view.py | 17 +++++++---------- 5 files changed, 43 insertions(+), 40 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index f0c3355330e8..57d65ff3752c 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -129,7 +129,6 @@ async def row_button(self, interaction: discord.Interaction, button: discord.ui. __action_row_children_items__: ClassVar[List[ItemCallbackType[Any]]] = [] __discord_ui_action_row__: ClassVar[bool] = True - __discord_ui_update_view__: ClassVar[bool] = True __item_repr_attributes__ = ('id',) def __init__( @@ -175,9 +174,13 @@ def _init_children(self) -> List[Item[Any]]: children.append(item) return children - def _update_children_view(self, view: LayoutView) -> None: + def _update_view(self, view) -> None: + self._view = view for child in self._children: - child._view = view # pyright: ignore[reportAttributeAccessIssue] + child._view = view + + def _has_nested(self): + return True def _is_v2(self) -> bool: # although it is not really a v2 component the only usecase here is for @@ -238,7 +241,7 @@ def add_item(self, item: Item[Any]) -> Self: if not isinstance(item, Item): raise TypeError(f'expected Item not {item.__class__.__name__}') - item._view = self._view + item._update_view(self.view) item._parent = self self._weight += 1 self._children.append(item) diff --git a/discord/ui/container.py b/discord/ui/container.py index 72100b28e317..0351f24f545e 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -119,7 +119,6 @@ class MyView(ui.LayoutView): """ __container_children_items__: ClassVar[Dict[str, Union[ItemCallbackType[Any], Item[Any]]]] = {} - __discord_ui_update_view__: ClassVar[bool] = True __discord_ui_container__: ClassVar[bool] = True __item_repr_attributes__ = ( 'accent_colour', @@ -194,15 +193,14 @@ def __init_subclass__(cls) -> None: cls.__container_children_items__ = children - def _update_children_view(self, view: V) -> None: # type: ignore - if not view._is_layout(): - return - + def _update_view(self, view) -> bool: + self._view = view for child in self._children: - child._view = view - if getattr(child, '__discord_ui_update_view__', False): - # if the item is an action row which child's view can be updated, then update it - child._update_children_view(view) # type: ignore + child._update_view(view) + return True + + def _has_nested(self): + return True @property def children(self) -> List[Item[V]]: @@ -284,8 +282,7 @@ def walk_children(self) -> Generator[Item[V], None, None]: for child in self.children: yield child - if getattr(child, '__discord_ui_update_view__', False): - # if it has this attribute then it can contain children + if child._has_nested(): yield from child.walk_children() # type: ignore def add_item(self, item: Item[Any]) -> Self: @@ -308,18 +305,13 @@ def add_item(self, item: Item[Any]) -> Self: raise TypeError(f'expected Item not {item.__class__.__name__}') self._children.append(item) - is_layout_view = self._view and self._view._is_layout() - - if getattr(item, '__discord_ui_update_view__', False): - item._update_children_view(self.view) # type: ignore + item._update_view(self.view) + item._parent = self - if is_layout_view: - self._view._total_children += sum(1 for _ in item.walk_children()) # type: ignore - elif is_layout_view: + if item._has_nested(): + self._view._total_children += sum(1 for _ in item.walk_children()) # type: ignore + else: self._view._total_children += 1 # type: ignore - - item._view = self.view - item._parent = self return self def remove_item(self, item: Item[Any]) -> Self: @@ -340,7 +332,7 @@ def remove_item(self, item: Item[Any]) -> Self: pass else: if self._view and self._view._is_layout(): - if getattr(item, '__discord_ui_update_view__', False): + if item._has_nested(): self._view._total_children -= len(tuple(item.walk_children())) # type: ignore else: self._view._total_children -= 1 diff --git a/discord/ui/item.py b/discord/ui/item.py index 65d8d9c54c7c..d3ac23764502 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -159,6 +159,12 @@ async def _run_checks(self, interaction: Interaction[ClientT]) -> bool: return can_run + def _update_view(self, view) -> None: + self._view = view + + def _has_nested(self) -> bool: + return False + async def callback(self, interaction: Interaction[ClientT]) -> Any: """|coro| diff --git a/discord/ui/section.py b/discord/ui/section.py index 16ec2d459cc2..6d623d21086e 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -44,7 +44,7 @@ class Section(Item[V]): r"""Represents a UI section. - This is a top-level layout component that can only be used on :class:`LayoutView` + This is a top-level layout component that can only be used on :class:`LayoutView`. .. versionadded:: 2.6 @@ -63,7 +63,6 @@ class Section(Item[V]): 'id', ) __discord_ui_section__: ClassVar[bool] = True - __discord_ui_update_view__: ClassVar[bool] = True __slots__ = ( '_children', @@ -120,8 +119,14 @@ def walk_children(self) -> Generator[Item[V], None, None]: yield child yield self.accessory - def _update_children_view(self, view) -> None: + def _update_view(self, view) -> None: + self._view = view self.accessory._view = view + for child in self._children: + child._view = view + + def _has_nested(self) -> bool: + return True def add_item(self, item: Union[str, Item[Any]]) -> Self: """Adds an item to this section. @@ -150,7 +155,7 @@ def add_item(self, item: Union[str, Item[Any]]) -> Self: raise TypeError(f'expected Item or str not {item.__class__.__name__}') item = item if isinstance(item, Item) else TextDisplay(item) - item._view = self.view + item._update_view(self.view) item._parent = self self._children.append(item) diff --git a/discord/ui/view.py b/discord/ui/view.py index bc186cb4db80..7f4e25ab740d 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -241,12 +241,10 @@ def _init_children(self) -> List[Item[Self]]: if isinstance(raw, Item): item = copy.deepcopy(raw) setattr(self, name, item) - item._view = self + item._update_view(self) parent = getattr(item, '__discord_ui_parent__', None) if parent and parent._view is None: parent._view = self - if getattr(item, '__discord_ui_update_view__', False): - item._update_children_view(self) # type: ignore children.append(item) parents[raw] = item else: @@ -358,7 +356,7 @@ def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) # this error should never be raised, because ActionRows can only # contain items that View accepts, but check anyways if item._is_v2(): - raise RuntimeError(f'{item.__class__.__name__} cannot be added to {view.__class__.__name__}') + raise ValueError(f'{item.__class__.__name__} cannot be added to {view.__class__.__name__}') view.add_item(item) row += 1 continue @@ -367,7 +365,7 @@ def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) item.row = row if item._is_v2() and not view._is_layout(): - raise RuntimeError(f'{item.__class__.__name__} cannot be added to {view.__class__.__name__}') + raise ValueError(f'{item.__class__.__name__} cannot be added to {view.__class__.__name__}') view.add_item(item) row += 1 @@ -400,11 +398,10 @@ def add_item(self, item: Item[Any]) -> Self: if item._is_v2() and not self._is_layout(): raise ValueError('v2 items cannot be added to this view') - item._view = self + item._update_view(self) added = 1 - if getattr(item, '__discord_ui_update_view__', False): - item._update_children_view(self) # type: ignore + if item._has_nested(): added += len(tuple(item.walk_children())) # type: ignore if self._is_layout() and self._total_children + added > 40: @@ -431,7 +428,7 @@ def remove_item(self, item: Item[Any]) -> Self: pass else: removed = 1 - if getattr(item, '__discord_ui_update_view__', False): + if item._has_nested(): removed += len(tuple(item.walk_children())) # type: ignore if self._total_children - removed < 0: @@ -652,7 +649,7 @@ def walk_children(self) -> Generator[Item[Any], None, None]: for child in self.children: yield child - if getattr(child, '__discord_ui_update_view__', False): + if child._has_nested(): # if it has this attribute then it can contain children yield from child.walk_children() # type: ignore From ef43ef6e656557d9c3c4640b9f50361f866ddf8e Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Fri, 1 Aug 2025 16:11:00 +0200 Subject: [PATCH 224/272] remove old comment --- discord/ui/view.py | 1 - 1 file changed, 1 deletion(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index 7f4e25ab740d..0f36b033473e 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -650,7 +650,6 @@ def walk_children(self) -> Generator[Item[Any], None, None]: yield child if child._has_nested(): - # if it has this attribute then it can contain children yield from child.walk_children() # type: ignore From 5361ff1a2fd99ada5fca511b6c72ac7b4c58c62b Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Fri, 1 Aug 2025 16:31:45 +0200 Subject: [PATCH 225/272] add Item.parent property --- discord/ui/item.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/discord/ui/item.py b/discord/ui/item.py index d3ac23764502..4d6f9ed0b941 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -151,6 +151,13 @@ def id(self) -> Optional[int]: def id(self, value: Optional[int]) -> None: self._id = value + @property + def parent(self) -> Optional[Item[V]]: + """Optional[:class:`Item`]: This item's parent. If this item is a :class:`View` this will + always be ``None``. + """ + return self._parent + async def _run_checks(self, interaction: Interaction[ClientT]) -> bool: can_run = await self.interaction_check(interaction) From 3025e3d6ba494939f44b3319c18aae2deb306c8e Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Fri, 1 Aug 2025 16:39:52 +0200 Subject: [PATCH 226/272] fix attribute errors --- discord/ui/action_row.py | 2 +- discord/ui/container.py | 6 +++--- discord/ui/section.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 57d65ff3752c..dbd5172b45cc 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -246,7 +246,7 @@ def add_item(self, item: Item[Any]) -> Self: self._weight += 1 self._children.append(item) - if self._view and self._view._is_layout(): + if self._view: self._view._total_children += 1 return self diff --git a/discord/ui/container.py b/discord/ui/container.py index 0351f24f545e..126f31dde615 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -308,10 +308,10 @@ def add_item(self, item: Item[Any]) -> Self: item._update_view(self.view) item._parent = self - if item._has_nested(): + if item._has_nested() and self._view: self._view._total_children += sum(1 for _ in item.walk_children()) # type: ignore - else: - self._view._total_children += 1 # type: ignore + elif self._view: + self._view._total_children += 1 return self def remove_item(self, item: Item[Any]) -> Self: diff --git a/discord/ui/section.py b/discord/ui/section.py index 6d623d21086e..418b0f4992ae 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -159,7 +159,7 @@ def add_item(self, item: Union[str, Item[Any]]) -> Self: item._parent = self self._children.append(item) - if self._view and self._view._is_layout(): + if self._view: self._view._total_children += 1 return self @@ -181,7 +181,7 @@ def remove_item(self, item: Item[Any]) -> Self: except ValueError: pass else: - if self._view and self._view._is_layout(): + if self._view: self._view._total_children -= 1 return self From 89a2930109a0d519a2cbacd5341960e8f890c090 Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 2 Aug 2025 17:27:05 +0200 Subject: [PATCH 227/272] Update discord/ui/action_row.py Co-authored-by: Danny <1695103+Rapptz@users.noreply.github.com> --- discord/ui/action_row.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index dbd5172b45cc..2acc03f86896 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -161,7 +161,7 @@ def __init_subclass__(cls) -> None: cls.__action_row_children_items__ = list(children.values()) def __repr__(self) -> str: - return f'{self.__class__.__name__} children={len(self._children)}>' + return f'<{self.__class__.__name__} children={len(self._children)}>' def _init_children(self) -> List[Item[Any]]: children = [] From 478e62998ee50c74dcbeaee50a7344e82d31cd75 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 2 Aug 2025 17:29:23 +0200 Subject: [PATCH 228/272] typos --- discord/webhook/sync.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/webhook/sync.py b/discord/webhook/sync.py index d5295c1fc0a6..cb21f94bde46 100644 --- a/discord/webhook/sync.py +++ b/discord/webhook/sync.py @@ -1035,7 +1035,7 @@ def send( .. versionadded:: 2.4 view: Union[:class:`~discord.ui.View`, :class:`~discord.ui.LayoutView`] - The view to send with the message. This can only have non-interactible items, which donnot + The view to send with the message. This can only have non-interactible items, which do not require a state to be attached to it. If you want to send a view with any component attached to it, check :meth:`Webhook.send`. @@ -1248,7 +1248,7 @@ def edit_message( .. versionadded:: 2.0 view: Optional[Union[:class:`~discord.ui.View`, :class:`~discord.ui.LayoutView`]] - The updated view to update this message with. This can only have non-interactible items, which donnot + The updated view to update this message with. This can only have non-interactible items, which do not require a state to be attached to it. If ``None`` is passed then the view is removed. If you want to edit a webhook message with any component attached to it, check :meth:`WebhookMessage.edit`. From f4a68f22c72bd3cedfa1ee03d8432e97a3a423b9 Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 2 Aug 2025 17:44:17 +0200 Subject: [PATCH 229/272] Update discord/ui/item.py Co-authored-by: Danny <1695103+Rapptz@users.noreply.github.com> --- discord/ui/item.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/discord/ui/item.py b/discord/ui/item.py index 4d6f9ed0b941..fc7c97e7c2da 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -153,8 +153,11 @@ def id(self, value: Optional[int]) -> None: @property def parent(self) -> Optional[Item[V]]: - """Optional[:class:`Item`]: This item's parent. If this item is a :class:`View` this will - always be ``None``. + """Optional[:class:`Item`]: This item's parent. Only components that can have children + can be parents. Any item that has :class:`View` as a view will have this set to `None` + since only :class:`LayoutView` component v2 items can contain "container" like items. + + .. versionadded:: 2.6 """ return self._parent From 917b37c3ceba039480fc1803503ab9f8b1e75ef7 Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 2 Aug 2025 17:44:27 +0200 Subject: [PATCH 230/272] Update discord/ui/thumbnail.py Co-authored-by: Soheab <33902984+Soheab@users.noreply.github.com> --- discord/ui/thumbnail.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/thumbnail.py b/discord/ui/thumbnail.py index 3a485aa05b71..855b27a27abd 100644 --- a/discord/ui/thumbnail.py +++ b/discord/ui/thumbnail.py @@ -102,7 +102,7 @@ def media(self, value: Union[str, UnfurledMediaItem]) -> None: elif isinstance(value, UnfurledMediaItem): self._media = value else: - raise TypeError(f'expected a str or UnfurledMediaItem, got {value.__class__.__name__!r}') + raise TypeError(f'expected a str or UnfurledMediaItem, not {value.__class__.__name__!r}') @property def type(self) -> Literal[ComponentType.thumbnail]: From eb4fe85d6b513f45f7333ad51e3a0270df15b24c Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 2 Aug 2025 17:44:36 +0200 Subject: [PATCH 231/272] Update discord/ui/media_gallery.py Co-authored-by: Soheab <33902984+Soheab@users.noreply.github.com> --- discord/ui/media_gallery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index d2dc231e4f74..6dec0e681d1b 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -159,7 +159,7 @@ def append_item(self, item: MediaGalleryItem) -> Self: raise ValueError('maximum number of items has been exceeded') if not isinstance(item, MediaGalleryItem): - raise TypeError(f'expected MediaGalleryItem not {item.__class__.__name__}') + raise TypeError(f'expected MediaGalleryItem, not {item.__class__.__name__!r}') self._underlying.items.append(item) return self From 23cfe7b833d2d88c3626d63a3af4486e88e04c90 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 2 Aug 2025 17:47:46 +0200 Subject: [PATCH 232/272] docstring --- discord/interactions.py | 14 +++++++------- discord/message.py | 8 ++++---- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/discord/interactions.py b/discord/interactions.py index 9a8085af4283..7af6516dc9d2 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -546,9 +546,9 @@ async def edit_original_response( .. note:: - To update the message to add a :class:`~discord.ui.LayoutView`, you - must explicitly set the ``content``, ``embed``, ``embeds``, and - ``attachments`` parameters to either ``None`` or an empty array, as appropriate. + If you want to update the message to have a :class:`~discord.ui.LayoutView`, you must + explicitly set the ``content``, ``embed``, ``embeds``, and ``attachments`` parameters to + ``None`` if the previous message had any. .. versionchanged:: 2.6 This now accepts :class:`~discord.ui.LayoutView` instances. @@ -1557,9 +1557,9 @@ async def edit( .. note:: - To update the message to add a :class:`~discord.ui.LayoutView`, you - must explicitly set the ``content``, ``embed``, ``embeds``, and - ``attachments`` parameters to either ``None`` or an empty array, as appropriate. + If you want to update the message to have a :class:`~discord.ui.LayoutView`, you must + explicitly set the ``content``, ``embed``, ``embeds``, and ``attachments`` parameters to + ``None`` if the previous message had any. .. versionchanged:: 2.6 This now accepts :class:`~discord.ui.LayoutView` instances. @@ -1600,7 +1600,7 @@ async def edit( embeds=embeds, embed=embed, attachments=attachments, - view=view, # type: ignore + view=view, allowed_mentions=allowed_mentions, poll=poll, ) diff --git a/discord/message.py b/discord/message.py index d9fee394f843..28ea0a2aca1f 100644 --- a/discord/message.py +++ b/discord/message.py @@ -1408,8 +1408,8 @@ async def edit( .. note:: If you want to update the message to have a :class:`~discord.ui.LayoutView`, you must - explicitly set to ``None`` or empty array, as required, the ``content``, ``embed``, - ``embeds``, and ``attachments`` parameters. + explicitly set the ``content``, ``embed``, ``embeds``, and ``attachments`` parameters to + ``None`` if the previous message had any. .. versionchanged:: 2.6 This now accepts :class:`~discord.ui.LayoutView` instances. @@ -3000,8 +3000,8 @@ async def edit( .. note:: If you want to update the message to have a :class:`~discord.ui.LayoutView`, you must - explicitly set to ``None`` or empty array, as required, the ``content``, ``embed``, - ``embeds``, and ``attachments`` parameters. + explicitly set the ``content``, ``embed``, ``embeds``, and ``attachments`` parameters to + ``None`` if the previous message had any. .. versionchanged:: 2.6 This now accepts :class:`~discord.ui.LayoutView` instances. From 79223e5ac360ceb69740c98938bb9d972b2bd92f Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 2 Aug 2025 17:47:57 +0200 Subject: [PATCH 233/272] consistency --- discord/types/components.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/types/components.py b/discord/types/components.py index a93abd12755b..189122baef32 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -34,7 +34,7 @@ ButtonStyle = Literal[1, 2, 3, 4, 5, 6] TextStyle = Literal[1, 2] DefaultValueType = Literal['user', 'role', 'channel'] -SeparatorSize = Literal[1, 2] +SeparatorSpacing = Literal[1, 2] MediaItemLoadingState = Literal[0, 1, 2, 3] @@ -177,7 +177,7 @@ class FileComponent(ComponentBase): class SeparatorComponent(ComponentBase): type: Literal[14] divider: NotRequired[bool] - spacing: NotRequired[SeparatorSize] + spacing: NotRequired[SeparatorSpacing] class ContainerComponent(ComponentBase): From f2bddc0e96b431853145bcb306416214b0feb028 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 2 Aug 2025 17:48:20 +0200 Subject: [PATCH 234/272] do actual weight checking --- discord/ui/action_row.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index dbd5172b45cc..118c4ffe85bb 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -179,7 +179,7 @@ def _update_view(self, view) -> None: for child in self._children: child._view = view - def _has_nested(self): + def _has_children(self): return True def _is_v2(self) -> bool: @@ -235,6 +235,9 @@ def add_item(self, item: Item[Any]) -> Self: Maximum number of children has been exceeded (5). """ + if (self._weight + item.width) > 5: + raise ValueError('maximum number of children exceeded') + if len(self._children) >= 5: raise ValueError('maximum number of children exceeded') From 5cb171e507e42a522bd25e9ac13218e8c76d1a76 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 2 Aug 2025 17:48:54 +0200 Subject: [PATCH 235/272] changes --- discord/ui/container.py | 16 ++++++++-------- discord/ui/item.py | 9 ++++++++- discord/ui/section.py | 2 +- discord/ui/view.py | 8 ++++---- 4 files changed, 21 insertions(+), 14 deletions(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index 126f31dde615..f5aa81742f0c 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -175,7 +175,7 @@ def _init_children(self) -> List[Item[Any]]: if parent is None: raise RuntimeError(f'{raw.__name__} is not a valid item for a Container') parents.get(parent, parent)._children.append(item) - # we donnot append it to the children list because technically these buttons and + # we do not append it to the children list because technically these buttons and # selects are not from the container but the action row itself. return children @@ -199,7 +199,7 @@ def _update_view(self, view) -> bool: child._update_view(view) return True - def _has_nested(self): + def _has_children(self): return True @property @@ -282,8 +282,8 @@ def walk_children(self) -> Generator[Item[V], None, None]: for child in self.children: yield child - if child._has_nested(): - yield from child.walk_children() # type: ignore + if child._has_children(child): + yield from child.walk_children() def add_item(self, item: Item[Any]) -> Self: """Adds an item to this container. @@ -308,8 +308,8 @@ def add_item(self, item: Item[Any]) -> Self: item._update_view(self.view) item._parent = self - if item._has_nested() and self._view: - self._view._total_children += sum(1 for _ in item.walk_children()) # type: ignore + if item._has_children() and self._view: + self._view._total_children += len(tuple(item.walk_children())) # type: ignore elif self._view: self._view._total_children += 1 return self @@ -332,7 +332,7 @@ def remove_item(self, item: Item[Any]) -> Self: pass else: if self._view and self._view._is_layout(): - if item._has_nested(): + if item._has_children(): self._view._total_children -= len(tuple(item.walk_children())) # type: ignore else: self._view._total_children -= 1 @@ -366,6 +366,6 @@ def clear_items(self) -> Self: """ if self._view and self._view._is_layout(): - self._view._total_children -= sum(1 for _ in self.walk_children()) + self._view._total_children -= len(tuple(self.walk_children())) self._children.clear() return self diff --git a/discord/ui/item.py b/discord/ui/item.py index 4d6f9ed0b941..c0605067d5ad 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -36,10 +36,17 @@ # fmt: on if TYPE_CHECKING: + from typing import Generator + from typing_extensions import TypeGuard + from ..enums import ComponentType from .view import BaseView from ..components import Component + class ContainerItem(Generic[V]): + def walk_children(self) -> Generator[Item['V'], None, None]: + ... + I = TypeVar('I', bound='Item[Any]') V = TypeVar('V', bound='BaseView', covariant=True) ItemCallbackType = Callable[[Any, Interaction[Any], I], Coroutine[Any, Any, Any]] @@ -169,7 +176,7 @@ async def _run_checks(self, interaction: Interaction[ClientT]) -> bool: def _update_view(self, view) -> None: self._view = view - def _has_nested(self) -> bool: + def _has_children(self) -> TypeGuard[ContainerItem[V]]: return False async def callback(self, interaction: Interaction[ClientT]) -> Any: diff --git a/discord/ui/section.py b/discord/ui/section.py index 418b0f4992ae..74a19e7ac28b 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -125,7 +125,7 @@ def _update_view(self, view) -> None: for child in self._children: child._view = view - def _has_nested(self) -> bool: + def _has_children(self): return True def add_item(self, item: Union[str, Item[Any]]) -> Self: diff --git a/discord/ui/view.py b/discord/ui/view.py index 0f36b033473e..2081402f8967 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -225,7 +225,7 @@ def __init__(self, *, timeout: Optional[float] = 180.0) -> None: self.__timeout_expiry: Optional[float] = None self.__timeout_task: Optional[asyncio.Task[None]] = None self.__stopped: asyncio.Future[bool] = asyncio.get_running_loop().create_future() - self._total_children: int = sum(1 for _ in self.walk_children()) + self._total_children: int = len(tuple(self.walk_children())) def _is_layout(self) -> TypeGuard[LayoutView]: # type: ignore return False @@ -401,7 +401,7 @@ def add_item(self, item: Item[Any]) -> Self: item._update_view(self) added = 1 - if item._has_nested(): + if item._has_children(): added += len(tuple(item.walk_children())) # type: ignore if self._is_layout() and self._total_children + added > 40: @@ -428,7 +428,7 @@ def remove_item(self, item: Item[Any]) -> Self: pass else: removed = 1 - if item._has_nested(): + if item._has_children(): removed += len(tuple(item.walk_children())) # type: ignore if self._total_children - removed < 0: @@ -649,7 +649,7 @@ def walk_children(self) -> Generator[Item[Any], None, None]: for child in self.children: yield child - if child._has_nested(): + if child._has_children(): yield from child.walk_children() # type: ignore From 291bcb28c52d0bebc680cf00fee682fb506bbc6c Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 2 Aug 2025 17:50:04 +0200 Subject: [PATCH 236/272] run black --- discord/ui/item.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/discord/ui/item.py b/discord/ui/item.py index 3089201220ab..02c6fca9bbfb 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -47,6 +47,7 @@ class ContainerItem(Generic[V]): def walk_children(self) -> Generator[Item['V'], None, None]: ... + I = TypeVar('I', bound='Item[Any]') V = TypeVar('V', bound='BaseView', covariant=True) ItemCallbackType = Callable[[Any, Interaction[Any], I], Coroutine[Any, Any, Any]] @@ -163,7 +164,7 @@ def parent(self) -> Optional[Item[V]]: """Optional[:class:`Item`]: This item's parent. Only components that can have children can be parents. Any item that has :class:`View` as a view will have this set to `None` since only :class:`LayoutView` component v2 items can contain "container" like items. - + .. versionadded:: 2.6 """ return self._parent From 8bf48e179b9b6f7a1714d686c44526e4d378d715 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 7 Aug 2025 02:50:05 +0200 Subject: [PATCH 237/272] fix typings on interaction --- discord/interactions.py | 28 ++-------------------------- 1 file changed, 2 insertions(+), 26 deletions(-) diff --git a/discord/interactions.py b/discord/interactions.py index 7af6516dc9d2..6cd224fdfb01 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -475,30 +475,6 @@ async def original_response(self) -> InteractionMessage: self._original_response = message return message - @overload - async def edit_original_response( - self, - *, - attachments: Sequence[Union[Attachment, File]] = MISSING, - view: LayoutView, - allowed_mentions: Optional[AllowedMentions] = None, - ) -> InteractionMessage: - ... - - @overload - async def edit_original_response( - self, - *, - content: Optional[str] = MISSING, - embeds: Sequence[Embed] = MISSING, - embed: Optional[Embed] = MISSING, - attachments: Sequence[Union[Attachment, File]] = MISSING, - view: Optional[View] = MISSING, - allowed_mentions: Optional[AllowedMentions] = None, - poll: Poll = MISSING, - ) -> InteractionMessage: - ... - async def edit_original_response( self, *, @@ -506,7 +482,7 @@ async def edit_original_response( embeds: Sequence[Embed] = MISSING, embed: Optional[Embed] = MISSING, attachments: Sequence[Union[Attachment, File]] = MISSING, - view: Optional[BaseView] = MISSING, + view: Optional[Union[View, LayoutView]] = MISSING, allowed_mentions: Optional[AllowedMentions] = None, poll: Poll = MISSING, ) -> InteractionMessage: @@ -1522,7 +1498,7 @@ async def edit( embeds: Sequence[Embed] = MISSING, embed: Optional[Embed] = MISSING, attachments: Sequence[Union[Attachment, File]] = MISSING, - view: Optional[BaseView] = MISSING, + view: Optional[Union[View, LayoutView]] = MISSING, allowed_mentions: Optional[AllowedMentions] = None, delete_after: Optional[float] = None, poll: Poll = MISSING, From ce5a4be244fe70c94d5bea81f7691c7e728ab9ab Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 7 Aug 2025 02:59:59 +0200 Subject: [PATCH 238/272] add item._has_children --- discord/ui/container.py | 4 ++-- discord/ui/item.py | 10 +--------- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index f5aa81742f0c..1ef67d75e1e1 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -282,8 +282,8 @@ def walk_children(self) -> Generator[Item[V], None, None]: for child in self.children: yield child - if child._has_children(child): - yield from child.walk_children() + if child._has_children(): + yield from child.walk_children() # type: ignore def add_item(self, item: Item[Any]) -> Self: """Adds an item to this container. diff --git a/discord/ui/item.py b/discord/ui/item.py index 02c6fca9bbfb..03e63550cb7f 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -36,18 +36,10 @@ # fmt: on if TYPE_CHECKING: - from typing import Generator - from typing_extensions import TypeGuard - from ..enums import ComponentType from .view import BaseView from ..components import Component - class ContainerItem(Generic[V]): - def walk_children(self) -> Generator[Item['V'], None, None]: - ... - - I = TypeVar('I', bound='Item[Any]') V = TypeVar('V', bound='BaseView', covariant=True) ItemCallbackType = Callable[[Any, Interaction[Any], I], Coroutine[Any, Any, Any]] @@ -180,7 +172,7 @@ async def _run_checks(self, interaction: Interaction[ClientT]) -> bool: def _update_view(self, view) -> None: self._view = view - def _has_children(self) -> TypeGuard[ContainerItem[V]]: + def _has_children(self) -> bool: return False async def callback(self, interaction: Interaction[ClientT]) -> Any: From d9817037f46cfa01cf78b2dd7ab04ca9c9262e63 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 7 Aug 2025 03:00:13 +0200 Subject: [PATCH 239/272] remove versionchanged note on send/edit methods --- discord/abc.py | 2 - discord/channel.py | 3 -- discord/client.py | 3 -- discord/ext/commands/context.py | 2 - discord/interactions.py | 41 +----------------- discord/message.py | 75 +-------------------------------- discord/webhook/async_.py | 4 -- discord/webhook/sync.py | 2 - 8 files changed, 3 insertions(+), 129 deletions(-) diff --git a/discord/abc.py b/discord/abc.py index 9f8066502406..a56451da6a11 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -1594,8 +1594,6 @@ async def send( A Discord UI View to add to the message. .. versionadded:: 2.0 - .. versionchanged:: 2.6 - This now accepts :class:`discord.ui.LayoutView` instances. stickers: Sequence[Union[:class:`~discord.GuildSticker`, :class:`~discord.StickerItem`]] A list of stickers to upload. Must be a maximum of 3. diff --git a/discord/channel.py b/discord/channel.py index 58a9943d47be..de764f6f730a 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -2950,9 +2950,6 @@ async def create_thread( A list of tags to apply to the thread. view: Union[:class:`discord.ui.View`, :class:`discord.ui.LayoutView`] A Discord UI View to add to the message. - - .. versionchanged:: 2.6 - This now accepts :class:`discord.ui.LayoutView` instances. stickers: Sequence[Union[:class:`~discord.GuildSticker`, :class:`~discord.StickerItem`]] A list of stickers to upload. Must be a maximum of 3. suppress_embeds: :class:`bool` diff --git a/discord/client.py b/discord/client.py index 11d0f6c337ca..25f5f20cad5a 100644 --- a/discord/client.py +++ b/discord/client.py @@ -3166,9 +3166,6 @@ def add_view(self, view: BaseView, *, message_id: Optional[int] = None) -> None: ------------ view: Union[:class:`discord.ui.View`, :class:`discord.ui.LayoutView`] The view to register for dispatching. - - .. versionchanged:: 2.6 - This now accepts :class:`discord.ui.LayoutView` instances. message_id: Optional[:class:`int`] The message ID that the view is attached to. This is currently used to refresh the view's state during message update events. If not given diff --git a/discord/ext/commands/context.py b/discord/ext/commands/context.py index e70096136fc8..4171a82c65ba 100644 --- a/discord/ext/commands/context.py +++ b/discord/ext/commands/context.py @@ -1058,8 +1058,6 @@ async def send( A Discord UI View to add to the message. .. versionadded:: 2.0 - .. versionchanged:: 2.6 - This now accepts :class:`discord.ui.LayoutView` instances. embeds: List[:class:`~discord.Embed`] A list of embeds to upload. Must be a maximum of 10. diff --git a/discord/interactions.py b/discord/interactions.py index 6cd224fdfb01..3cbc4107d3f6 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -525,9 +525,6 @@ async def edit_original_response( If you want to update the message to have a :class:`~discord.ui.LayoutView`, you must explicitly set the ``content``, ``embed``, ``embeds``, and ``attachments`` parameters to ``None`` if the previous message had any. - - .. versionchanged:: 2.6 - This now accepts :class:`~discord.ui.LayoutView` instances. poll: :class:`Poll` The poll to create when editing the message. @@ -984,9 +981,6 @@ async def send_message( Indicates if the message should be sent using text-to-speech. view: Union[:class:`discord.ui.View`, :class:`discord.ui.LayoutView`] The view to send with the message. - - .. versionchanged:: 2.6 - This now accepts :class:`discord.ui.LayoutView` instances. ephemeral: :class:`bool` Indicates if the message should only be visible to the user who started the interaction. If a view is sent with an ephemeral message and it has no timeout set then the timeout @@ -1095,33 +1089,6 @@ async def inner_call(delay: float = delete_after): type=self._response_type, ) - @overload - async def edit_message( - self, - *, - attachments: Sequence[Union[Attachment, File]] = MISSING, - view: LayoutView, - allowed_mentions: Optional[AllowedMentions] = MISSING, - delete_after: Optional[float] = None, - suppress_embeds: bool = MISSING, - ) -> Optional[InteractionCallbackResponse[ClientT]]: - ... - - @overload - async def edit_message( - self, - *, - content: Optional[Any] = MISSING, - embed: Optional[Embed] = MISSING, - embeds: Sequence[Embed] = MISSING, - attachments: Sequence[Union[Attachment, File]] = MISSING, - view: Optional[View] = MISSING, - allowed_mentions: Optional[AllowedMentions] = MISSING, - delete_after: Optional[float] = None, - suppress_embeds: bool = MISSING, - ) -> Optional[InteractionCallbackResponse[ClientT]]: - ... - async def edit_message( self, *, @@ -1129,7 +1096,7 @@ async def edit_message( embed: Optional[Embed] = MISSING, embeds: Sequence[Embed] = MISSING, attachments: Sequence[Union[Attachment, File]] = MISSING, - view: Optional[BaseView] = MISSING, + view: Optional[Union[View, LayoutView]] = MISSING, allowed_mentions: Optional[AllowedMentions] = MISSING, delete_after: Optional[float] = None, suppress_embeds: bool = MISSING, @@ -1168,9 +1135,6 @@ async def edit_message( To update the message to add a :class:`~discord.ui.LayoutView`, you must explicitly set the ``content``, ``embed``, ``embeds``, and ``attachments`` parameters to either ``None`` or an empty array, as appropriate. - - .. versionchanged:: 2.6 - This now accepts :class:`~discord.ui.LayoutView` instances. allowed_mentions: Optional[:class:`~discord.AllowedMentions`] Controls the mentions being processed in this message. See :meth:`.Message.edit` for more information. @@ -1536,9 +1500,6 @@ async def edit( If you want to update the message to have a :class:`~discord.ui.LayoutView`, you must explicitly set the ``content``, ``embed``, ``embeds``, and ``attachments`` parameters to ``None`` if the previous message had any. - - .. versionchanged:: 2.6 - This now accepts :class:`~discord.ui.LayoutView` instances. delete_after: Optional[:class:`float`] If provided, the number of seconds to wait in the background before deleting the message we just sent. If the deletion fails, diff --git a/discord/message.py b/discord/message.py index 28ea0a2aca1f..61e8d5650e3c 100644 --- a/discord/message.py +++ b/discord/message.py @@ -1305,43 +1305,6 @@ async def delete(delay: float): else: await self._state.http.delete_message(self.channel.id, self.id) - @overload - async def edit( - self, - *, - view: LayoutView, - attachments: Sequence[Union[Attachment, File]] = ..., - delete_after: Optional[float] = ..., - allowed_mentions: Optional[AllowedMentions] = ..., - ) -> Message: - ... - - @overload - async def edit( - self, - *, - content: Optional[str] = ..., - embed: Optional[Embed] = ..., - attachments: Sequence[Union[Attachment, File]] = ..., - delete_after: Optional[float] = ..., - allowed_mentions: Optional[AllowedMentions] = ..., - view: Optional[View] = ..., - ) -> Message: - ... - - @overload - async def edit( - self, - *, - content: Optional[str] = ..., - embeds: Sequence[Embed] = ..., - attachments: Sequence[Union[Attachment, File]] = ..., - delete_after: Optional[float] = ..., - allowed_mentions: Optional[AllowedMentions] = ..., - view: Optional[View] = ..., - ) -> Message: - ... - async def edit( self, *, @@ -1351,7 +1314,7 @@ async def edit( attachments: Sequence[Union[Attachment, File]] = MISSING, delete_after: Optional[float] = None, allowed_mentions: Optional[AllowedMentions] = MISSING, - view: Optional[BaseView] = MISSING, + view: Optional[Union[View, LayoutView]] = MISSING, ) -> Message: """|coro| @@ -1411,9 +1374,6 @@ async def edit( explicitly set the ``content``, ``embed``, ``embeds``, and ``attachments`` parameters to ``None`` if the previous message had any. - .. versionchanged:: 2.6 - This now accepts :class:`~discord.ui.LayoutView` instances. - Raises ------- HTTPException @@ -2897,34 +2857,6 @@ def system_content(self) -> str: # Fallback for unknown message types return '' - @overload - async def edit( - self, - *, - content: Optional[str] = ..., - embed: Optional[Embed] = ..., - attachments: Sequence[Union[Attachment, File]] = ..., - suppress: bool = ..., - delete_after: Optional[float] = ..., - allowed_mentions: Optional[AllowedMentions] = ..., - view: Optional[BaseView] = ..., - ) -> Message: - ... - - @overload - async def edit( - self, - *, - content: Optional[str] = ..., - embeds: Sequence[Embed] = ..., - attachments: Sequence[Union[Attachment, File]] = ..., - suppress: bool = ..., - delete_after: Optional[float] = ..., - allowed_mentions: Optional[AllowedMentions] = ..., - view: Optional[BaseView] = ..., - ) -> Message: - ... - async def edit( self, *, @@ -2935,7 +2867,7 @@ async def edit( suppress: bool = False, delete_after: Optional[float] = None, allowed_mentions: Optional[AllowedMentions] = MISSING, - view: Optional[BaseView] = MISSING, + view: Optional[Union[View, LayoutView]] = MISSING, ) -> Message: """|coro| @@ -3003,9 +2935,6 @@ async def edit( explicitly set the ``content``, ``embed``, ``embeds``, and ``attachments`` parameters to ``None`` if the previous message had any. - .. versionchanged:: 2.6 - This now accepts :class:`~discord.ui.LayoutView` instances. - Raises ------- HTTPException diff --git a/discord/webhook/async_.py b/discord/webhook/async_.py index a539918b9a04..b93b627a73a8 100644 --- a/discord/webhook/async_.py +++ b/discord/webhook/async_.py @@ -1780,8 +1780,6 @@ async def send( Otherwise, you can send views with any type of components. .. versionadded:: 2.0 - .. versionchanged:: 2.6 - This now accepts :class:`discord.ui.LayoutView` instances. thread: :class:`~discord.abc.Snowflake` The thread to send this webhook to. @@ -2066,8 +2064,6 @@ async def edit_message( ``attachments`` parameters to either ``None`` or an empty array, as appropriate. .. versionadded:: 2.0 - .. versionchanged:: 2.6 - This now accepts :class:`~discord.ui.LayoutView` instances. thread: :class:`~discord.abc.Snowflake` The thread the webhook message belongs to. diff --git a/discord/webhook/sync.py b/discord/webhook/sync.py index cb21f94bde46..3891a9a39e73 100644 --- a/discord/webhook/sync.py +++ b/discord/webhook/sync.py @@ -1041,8 +1041,6 @@ def send( If you want to send a view with any component attached to it, check :meth:`Webhook.send`. .. versionadded:: 2.5 - .. versionchanged:: 2.6 - This now accepts :class:`discord.ui.LayoutView` instances. Raises -------- From fc1634cb28fdac5b5955134567579195fa6d6dda Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 7 Aug 2025 03:02:58 +0200 Subject: [PATCH 240/272] add missing id params on ActionRow.button and ActionRow.select decorators --- discord/ui/action_row.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 73325c163b50..12334285bda8 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -330,6 +330,7 @@ def button( disabled: bool = False, style: ButtonStyle = ButtonStyle.secondary, emoji: Optional[Union[str, Emoji, PartialEmoji]] = None, + id: Optional[int] = None, ) -> Callable[[ItemCallbackType[Button[V]]], Button[V]]: """A decorator that attaches a button to a component. @@ -361,6 +362,10 @@ def button( emoji: Optional[Union[:class:`str`, :class:`.Emoji`, :class:`.PartialEmoji`]] The emoji of the button. This can be in string form or a :class:`.PartialEmoji` or a full :class:`.Emoji`. + id: Optional[:class:`int`] + The ID of the component. This must be unique across the view. + + .. versionadded:: 2.6 """ def decorator(func: ItemCallbackType[Button[V]]) -> ItemCallbackType[Button[V]]: @@ -371,6 +376,7 @@ def decorator(func: ItemCallbackType[Button[V]]) -> ItemCallbackType[Button[V]]: style=style, emoji=emoji, row=None, + id=id, )(func) ret.__discord_ui_parent__ = self # type: ignore return ret # type: ignore @@ -389,6 +395,7 @@ def select( min_values: int = ..., max_values: int = ..., disabled: bool = ..., + id: Optional[int] = ..., ) -> SelectCallbackDecorator[SelectT]: ... @@ -405,6 +412,7 @@ def select( max_values: int = ..., disabled: bool = ..., default_values: Sequence[ValidDefaultValues] = ..., + id: Optional[int] = ..., ) -> SelectCallbackDecorator[UserSelectT]: ... @@ -421,6 +429,7 @@ def select( max_values: int = ..., disabled: bool = ..., default_values: Sequence[ValidDefaultValues] = ..., + id: Optional[int] = ..., ) -> SelectCallbackDecorator[RoleSelectT]: ... @@ -437,6 +446,7 @@ def select( max_values: int = ..., disabled: bool = ..., default_values: Sequence[ValidDefaultValues] = ..., + id: Optional[int] = ..., ) -> SelectCallbackDecorator[ChannelSelectT]: ... @@ -453,6 +463,7 @@ def select( max_values: int = ..., disabled: bool = ..., default_values: Sequence[ValidDefaultValues] = ..., + id: Optional[int] = ..., ) -> SelectCallbackDecorator[MentionableSelectT]: ... @@ -468,6 +479,7 @@ def select( max_values: int = 1, disabled: bool = False, default_values: Sequence[ValidDefaultValues] = MISSING, + id: Optional[int] = None, ) -> SelectCallbackDecorator[BaseSelectT]: """A decorator that attaches a select menu to a component. @@ -536,6 +548,10 @@ async def select_channels(self, interaction: discord.Interaction, select: Channe A list of objects representing the default values for the select menu. This cannot be used with regular :class:`Select` instances. If ``cls`` is :class:`MentionableSelect` and :class:`.Object` is passed, then the type must be specified in the constructor. Number of items must be in range of ``min_values`` and ``max_values``. + id: Optional[:class:`int`] + The ID of the component. This must be unique across the view. + + .. versionadded:: 2.6 """ def decorator(func: ItemCallbackType[BaseSelectT]) -> ItemCallbackType[BaseSelectT]: @@ -549,6 +565,7 @@ def decorator(func: ItemCallbackType[BaseSelectT]) -> ItemCallbackType[BaseSelec channel_types=channel_types, disabled=disabled, default_values=default_values, + id=id, )(func) r.__discord_ui_parent__ = self return r From 64668e77e8c110536bc5e38a30cba01812550019 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 7 Aug 2025 03:03:47 +0200 Subject: [PATCH 241/272] add Section in TextDisplay docstring --- discord/ui/text_display.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/text_display.py b/discord/ui/text_display.py index 5bc8ce3a3e8e..399428a06b85 100644 --- a/discord/ui/text_display.py +++ b/discord/ui/text_display.py @@ -42,7 +42,7 @@ class TextDisplay(Item[V]): """Represents a UI text display. - This is a top-level layout component that can only be used on :class:`LayoutView`. + This is a top-level layout component that can only be used on :class:`LayoutView` or :class:`Section`. .. versionadded:: 2.6 From 9ff8d82bdab99a0eef32b99732125d116539ab1d Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 7 Aug 2025 03:04:44 +0200 Subject: [PATCH 242/272] remove unused import --- discord/message.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/message.py b/discord/message.py index 61e8d5650e3c..039ac1ba745b 100644 --- a/discord/message.py +++ b/discord/message.py @@ -101,7 +101,7 @@ from .mentions import AllowedMentions from .user import User from .role import Role - from .ui.view import BaseView, View, LayoutView + from .ui.view import View, LayoutView EmojiInputType = Union[Emoji, PartialEmoji, str] From e1e12fefcdcbb70465850e4ad0807756e6290924 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 7 Aug 2025 03:09:25 +0200 Subject: [PATCH 243/272] i broke docs lol --- discord/ui/action_row.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 12334285bda8..d73fea371dbd 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -549,9 +549,9 @@ async def select_channels(self, interaction: discord.Interaction, select: Channe If ``cls`` is :class:`MentionableSelect` and :class:`.Object` is passed, then the type must be specified in the constructor. Number of items must be in range of ``min_values`` and ``max_values``. id: Optional[:class:`int`] - The ID of the component. This must be unique across the view. + The ID of the component. This must be unique across the view. - .. versionadded:: 2.6 + .. versionadded:: 2.6 """ def decorator(func: ItemCallbackType[BaseSelectT]) -> ItemCallbackType[BaseSelectT]: From 40aced5c8b993ec1d8a9c360fd493d73d938688a Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 7 Aug 2025 03:12:09 +0200 Subject: [PATCH 244/272] add Separator to docstring --- discord/ui/container.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index 1ef67d75e1e1..228de796f85e 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -73,7 +73,7 @@ class Container(Item[V]): This is a top-level layout component that can only be used on :class:`LayoutView` and can contain :class:`ActionRow`\s, :class:`TextDisplay`\s, :class:`Section`\s, - :class:`MediaGallery`\s, and :class:`File`\s in it. + :class:`MediaGallery`\s, :class:`File`\s, and :class:`Separator`\s in it. This can be inherited. From e6d3f32e578930c03b603429edf9fc87b31ef9a6 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 7 Aug 2025 03:14:11 +0200 Subject: [PATCH 245/272] replace RuntimeError with ValueError --- discord/ui/view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index 2081402f8967..37126f422193 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -688,7 +688,7 @@ def __init_subclass__(cls) -> None: if hasattr(member, '__discord_ui_model_type__'): children[name] = member elif isinstance(member, Item) and member._is_v2(): - raise RuntimeError(f'{name} cannot be added to this View') + raise ValueError(f'{name} cannot be added to this View') if len(children) > 25: raise TypeError('View cannot have more than 25 children') From 6beab8ed4396d4b6e7d7080b4661bc08718010a0 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 7 Aug 2025 03:14:24 +0200 Subject: [PATCH 246/272] replace RuntimeError with ValueError --- discord/ui/container.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index 228de796f85e..0b13f81b21c1 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -173,7 +173,7 @@ def _init_children(self) -> List[Item[Any]]: # guarding it parent = getattr(raw, '__discord_ui_parent__', None) if parent is None: - raise RuntimeError(f'{raw.__name__} is not a valid item for a Container') + raise ValueError(f'{raw.__name__} is not a valid item for a Container') parents.get(parent, parent)._children.append(item) # we do not append it to the children list because technically these buttons and # selects are not from the container but the action row itself. From a55186d9ca6ec4b607f549226bcbac81495a8085 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 7 Aug 2025 03:15:31 +0200 Subject: [PATCH 247/272] not set rendered row in row.setter --- discord/ui/item.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/discord/ui/item.py b/discord/ui/item.py index 03e63550cb7f..50fd86d3876f 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -130,9 +130,6 @@ def row(self, value: Optional[int]) -> None: else: raise ValueError('row cannot be negative or greater than or equal to 5') - if self._rendered_row is None: - self._rendered_row = value - @property def width(self) -> int: return 1 From 9a6b77913059541e66159668be9f2a60e75f2556 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 7 Aug 2025 03:18:30 +0200 Subject: [PATCH 248/272] fix MediaGallery.id not updating the corresponding payload --- discord/ui/media_gallery.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index 6dec0e681d1b..8d9a1c9e1731 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -76,7 +76,6 @@ def __init__( items=list(items), id=id, ) - self.id = id def __repr__(self) -> str: return f'<{self.__class__.__name__} items={len(self._underlying.items)}>' @@ -93,6 +92,15 @@ def items(self, value: List[MediaGalleryItem]) -> None: self._underlying.items = value + @property + def id(self) -> Optional[int]: + """Optional[:class:`int`]: The ID of this component.""" + return self._underlying.id + + @id.setter + def id(self, value: Optional[int]) -> None: + self._underlying.id = value + def to_component_dict(self): return self._underlying.to_dict() From 791331cc83dca6d52b3919648fc0c32e975ac035 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 7 Aug 2025 03:25:38 +0200 Subject: [PATCH 249/272] fix Button & Select .id not updating the corresponding payload --- discord/ui/button.py | 10 +++++++++- discord/ui/select.py | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/discord/ui/button.py b/discord/ui/button.py index 7bec05e89722..a64b2b49b0e0 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -156,7 +156,15 @@ def __init__( id=id, ) self.row = row - self.id = id + + @property + def id(self) -> Optional[int]: + """Optional[:class:`int`]: The ID of this button.""" + return self._underlying.id + + @id.setter + def id(self, value: Optional[int]) -> None: + self._underlying.id = value @property def style(self) -> ButtonStyle: diff --git a/discord/ui/select.py b/discord/ui/select.py index 4e1c783004fb..eccfde2d842e 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -262,9 +262,17 @@ def __init__( ) self.row = row - self.id = id self._values: List[PossibleValue] = [] + @property + def id(self) -> Optional[int]: + """Optional[:class:`int`]: The ID of this select.""" + return self._underlying.id + + @id.setter + def id(self, value: Optional[int]) -> None: + self._underlying.id = value + @property def values(self) -> List[PossibleValue]: values = selected_values.get({}) From 01014f1a234ccc151ce3c5050fffebaf70898484 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 7 Aug 2025 16:32:54 +0200 Subject: [PATCH 250/272] update ActionRow.select example --- discord/ui/action_row.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index d73fea371dbd..9e9a6e5b61be 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -508,9 +508,10 @@ def select( --------- .. code-block:: python3 - class ActionRow(discord.ui.ActionRow): + class MyView(discord.ui.LayoutView): + action_row = discord.ui.ActionRow() - @discord.ui.select(cls=ChannelSelect, channel_types=[discord.ChannelType.text]) + @action_row.select(cls=ChannelSelect, channel_types=[discord.ChannelType.text]) async def select_channels(self, interaction: discord.Interaction, select: ChannelSelect): return await interaction.response.send_message(f'You selected {select.values[0].mention}') From 0e9d25a5c689b9bf3bed7976912669f19877861d Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 7 Aug 2025 16:51:42 +0200 Subject: [PATCH 251/272] move custom_id stuff of Section.accessory to Section.__init__ --- discord/ui/container.py | 4 ---- discord/ui/section.py | 5 +++++ 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index 0b13f81b21c1..2172e84b464c 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -154,10 +154,6 @@ def _init_children(self) -> List[Item[Any]]: if isinstance(raw, Item): item = copy.deepcopy(raw) item._parent = self - if getattr(item, '__discord_ui_section__', False) and item.accessory.is_dispatchable(): # type: ignore - if item.accessory._provided_custom_id is False: # type: ignore - item.accessory.custom_id = os.urandom(16).hex() # type: ignore - setattr(self, name, item) children.append(item) diff --git a/discord/ui/section.py b/discord/ui/section.py index 74a19e7ac28b..21c4d1bc7ae9 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -23,6 +23,7 @@ """ from __future__ import annotations +import os from typing import TYPE_CHECKING, Any, Dict, Generator, List, Literal, Optional, TypeVar, Union, ClassVar from .item import Item @@ -84,6 +85,10 @@ def __init__( [c if isinstance(c, Item) else TextDisplay(c) for c in children], ) self.accessory: Item[V] = accessory + + if self.accessory.is_dispatchable() and not self.accessory._provided_custom_id: + self.accessory.custom_id = os.urandom(16).hex() # type: ignore + self.id = id def __repr__(self) -> str: From d9c4fe29400f39104882d02c48d80fcd77bf8d24 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 7 Aug 2025 17:00:45 +0200 Subject: [PATCH 252/272] custom ids --- discord/ui/button.py | 4 ++++ discord/ui/container.py | 2 +- discord/ui/item.py | 3 +++ discord/ui/section.py | 7 +++---- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/discord/ui/button.py b/discord/ui/button.py index a64b2b49b0e0..09d6d93af98f 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -157,6 +157,10 @@ def __init__( ) self.row = row + def _update_custom_ids(self) -> None: + if not self._provided_custom_id and self._underlying.custom_id is not None: + self._underlying.custom_id = os.urandom(16).hex() + @property def id(self) -> Optional[int]: """Optional[:class:`int`]: The ID of this button.""" diff --git a/discord/ui/container.py b/discord/ui/container.py index 2172e84b464c..360220a371f3 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -154,9 +154,9 @@ def _init_children(self) -> List[Item[Any]]: if isinstance(raw, Item): item = copy.deepcopy(raw) item._parent = self + item._update_custom_ids() setattr(self, name, item) children.append(item) - parents[raw] = item else: # action rows can be created inside containers, and then callbacks can exist here diff --git a/discord/ui/item.py b/discord/ui/item.py index 50fd86d3876f..67e3a52f493c 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -169,6 +169,9 @@ async def _run_checks(self, interaction: Interaction[ClientT]) -> bool: def _update_view(self, view) -> None: self._view = view + def _update_custom_ids(self) -> None: + pass + def _has_children(self) -> bool: return False diff --git a/discord/ui/section.py b/discord/ui/section.py index 21c4d1bc7ae9..b692d868be8e 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -85,10 +85,6 @@ def __init__( [c if isinstance(c, Item) else TextDisplay(c) for c in children], ) self.accessory: Item[V] = accessory - - if self.accessory.is_dispatchable() and not self.accessory._provided_custom_id: - self.accessory.custom_id = os.urandom(16).hex() # type: ignore - self.id = id def __repr__(self) -> str: @@ -130,6 +126,9 @@ def _update_view(self, view) -> None: for child in self._children: child._view = view + def _update_custom_ids(self) -> None: + self.accessory._update_custom_ids() + def _has_children(self): return True From ec31af927bf81aea4b57a328a3bdf7ebd88d568e Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Fri, 8 Aug 2025 03:03:30 +0200 Subject: [PATCH 253/272] add Item.copy --- discord/ui/button.py | 27 +++++++++++++++++++++++---- discord/ui/container.py | 4 +--- discord/ui/item.py | 7 +++++-- discord/ui/section.py | 4 ---- discord/ui/view.py | 3 +-- 5 files changed, 30 insertions(+), 15 deletions(-) diff --git a/discord/ui/button.py b/discord/ui/button.py index 09d6d93af98f..fe1d65e1bffc 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -24,6 +24,7 @@ from __future__ import annotations +import copy from typing import Callable, Literal, Optional, TYPE_CHECKING, Tuple, TypeVar, Union import inspect import os @@ -157,10 +158,6 @@ def __init__( ) self.row = row - def _update_custom_ids(self) -> None: - if not self._provided_custom_id and self._underlying.custom_id is not None: - self._underlying.custom_id = os.urandom(16).hex() - @property def id(self) -> Optional[int]: """Optional[:class:`int`]: The ID of this button.""" @@ -287,6 +284,28 @@ def is_persistent(self) -> bool: def _refresh_component(self, button: ButtonComponent) -> None: self._underlying = button + def copy(self) -> Self: + new = copy.copy(self) + custom_id = self.custom_id + + if self.custom_id is not None and not self._provided_custom_id: + custom_id = os.urandom(16).hex() + + new._underlying = ButtonComponent._raw_construct( + custom_id=custom_id, + url=self.url, + disabled=self.disabled, + label=self.label, + style=self.style, + emoji=self.emoji, + sku_id=self.sku_id, + id=self.id, + ) + return new + + def __deepcopy__(self, memo) -> Self: + return self.copy() + def button( *, diff --git a/discord/ui/container.py b/discord/ui/container.py index 360220a371f3..cf551e05586f 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -24,7 +24,6 @@ from __future__ import annotations import copy -import os from typing import ( TYPE_CHECKING, Any, @@ -152,9 +151,8 @@ def _init_children(self) -> List[Item[Any]]: for name, raw in self.__container_children_items__.items(): if isinstance(raw, Item): - item = copy.deepcopy(raw) + item = raw.copy() item._parent = self - item._update_custom_ids() setattr(self, name, item) children.append(item) parents[raw] = item diff --git a/discord/ui/item.py b/discord/ui/item.py index 67e3a52f493c..16236a183d42 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -24,6 +24,7 @@ from __future__ import annotations +import copy from typing import Any, Callable, Coroutine, Dict, Generic, Optional, TYPE_CHECKING, Tuple, Type, TypeVar from ..interactions import Interaction @@ -36,6 +37,8 @@ # fmt: on if TYPE_CHECKING: + from typing_extensions import Self + from ..enums import ComponentType from .view import BaseView from ..components import Component @@ -169,8 +172,8 @@ async def _run_checks(self, interaction: Interaction[ClientT]) -> bool: def _update_view(self, view) -> None: self._view = view - def _update_custom_ids(self) -> None: - pass + def copy(self) -> Self: + return copy.deepcopy(self) def _has_children(self) -> bool: return False diff --git a/discord/ui/section.py b/discord/ui/section.py index b692d868be8e..74a19e7ac28b 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -23,7 +23,6 @@ """ from __future__ import annotations -import os from typing import TYPE_CHECKING, Any, Dict, Generator, List, Literal, Optional, TypeVar, Union, ClassVar from .item import Item @@ -126,9 +125,6 @@ def _update_view(self, view) -> None: for child in self._children: child._view = view - def _update_custom_ids(self) -> None: - self.accessory._update_custom_ids() - def _has_children(self): return True diff --git a/discord/ui/view.py b/discord/ui/view.py index 37126f422193..61b2da0bda7b 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -48,7 +48,6 @@ import sys import time import os -import copy from .item import Item, ItemCallbackType from .select import Select @@ -239,7 +238,7 @@ def _init_children(self) -> List[Item[Self]]: for name, raw in self.__view_children_items__.items(): if isinstance(raw, Item): - item = copy.deepcopy(raw) + item = raw.copy() setattr(self, name, item) item._update_view(self) parent = getattr(item, '__discord_ui_parent__', None) From 678367e1b5602f0257d4e81b4fe11457345a2631 Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Mon, 11 Aug 2025 21:26:15 +0200 Subject: [PATCH 254/272] Update discord/ui/action_row.py Co-authored-by: Soheab <33902984+Soheab@users.noreply.github.com> --- discord/ui/action_row.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 9e9a6e5b61be..3f20cde96af5 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -204,7 +204,7 @@ def children(self) -> List[Item[V]]: return self._children.copy() def walk_children(self) -> Generator[Item[V], Any, None]: - """An iterator that recursively walks through all the children of this view + """An iterator that recursively walks through all the children of this action row and it's children, if applicable. Yields From fdbc06e9c24cb95b29771f34e070e2972a621655 Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Mon, 11 Aug 2025 21:26:29 +0200 Subject: [PATCH 255/272] Update discord/ui/action_row.py Co-authored-by: Soheab <33902984+Soheab@users.noreply.github.com> --- discord/ui/action_row.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 3f20cde96af5..fe1b305c7873 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -114,7 +114,7 @@ class MyView(ui.LayoutView): # or you can use your subclass: # row = MyActionRow() - # you can create items with row.button and row.select + # you can add items with row.button and row.select @row.button(label='A button!') async def row_button(self, interaction: discord.Interaction, button: discord.ui.Button): await interaction.response.send_message('You clicked a button!') From 20bf99a43e7d4d0695584b2fa2d8b7783671827e Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Mon, 11 Aug 2025 21:26:36 +0200 Subject: [PATCH 256/272] Update discord/ui/action_row.py Co-authored-by: Soheab <33902984+Soheab@users.noreply.github.com> --- discord/ui/action_row.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index fe1b305c7873..c4033d3e0825 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -217,7 +217,7 @@ def walk_children(self) -> Generator[Item[V], Any, None]: yield child def add_item(self, item: Item[Any]) -> Self: - """Adds an item to this row. + """Adds an item to this action row. This function returns the class instance to allow for fluent-style chaining. From ba38075a6cbfcdc540bb497b46ac4714cda45197 Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Mon, 11 Aug 2025 21:26:46 +0200 Subject: [PATCH 257/272] Update discord/ui/action_row.py Co-authored-by: Soheab <33902984+Soheab@users.noreply.github.com> --- discord/ui/action_row.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index c4033d3e0825..686215e480b8 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -225,7 +225,7 @@ def add_item(self, item: Item[Any]) -> Self: Parameters ---------- item: :class:`Item` - The item to add to the row. + The item to add to the action row. Raises ------ From 14523126c1c867d953bf9d906382ae322b799e1b Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Mon, 11 Aug 2025 21:26:57 +0200 Subject: [PATCH 258/272] Update discord/ui/action_row.py Co-authored-by: Soheab <33902984+Soheab@users.noreply.github.com> --- discord/ui/action_row.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 686215e480b8..08daec0f4572 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -255,7 +255,7 @@ def add_item(self, item: Item[Any]) -> Self: return self def remove_item(self, item: Item[Any]) -> Self: - """Removes an item from the row. + """Removes an item from the action row. This function returns the class instance to allow for fluent-style chaining. @@ -263,7 +263,7 @@ def remove_item(self, item: Item[Any]) -> Self: Parameters ---------- item: :class:`Item` - The item to remove from the view. + The item to remove from the action row. """ try: From 90fd44e16d07fb9cc09a8dc0a851ba3f9d139369 Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Mon, 11 Aug 2025 21:27:13 +0200 Subject: [PATCH 259/272] Update discord/ui/action_row.py Co-authored-by: Soheab <33902984+Soheab@users.noreply.github.com> --- discord/ui/action_row.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 08daec0f4572..ce658e9fd241 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -298,7 +298,7 @@ def find_item(self, id: int, /) -> Optional[Item[V]]: return _utils_get(self.walk_children(), id=id) def clear_items(self) -> Self: - """Removes all items from the row. + """Removes all items from the action row. This function returns the class instance to allow for fluent-style chaining. From 18850d92a965bc0874490d16555d9bf8a665edd3 Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Mon, 11 Aug 2025 21:27:38 +0200 Subject: [PATCH 260/272] Update discord/ui/action_row.py Co-authored-by: Soheab <33902984+Soheab@users.noreply.github.com> --- discord/ui/action_row.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index ce658e9fd241..8de7bbb2b628 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -332,12 +332,11 @@ def button( emoji: Optional[Union[str, Emoji, PartialEmoji]] = None, id: Optional[int] = None, ) -> Callable[[ItemCallbackType[Button[V]]], Button[V]]: - """A decorator that attaches a button to a component. + """A decorator that attaches a button to the action row. The function being decorated should have three parameters, ``self`` representing - the :class:`discord.ui.LayoutView`, the :class:`discord.Interaction` you receive and + the :class:`discord.ui.ActionRow`, the :class:`discord.Interaction` you receive and the :class:`discord.ui.Button` being pressed. - .. note:: Buttons with a URL or a SKU cannot be created with this function. From 923a006204406db4e611a9172e350b761b170fda Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Mon, 11 Aug 2025 21:27:51 +0200 Subject: [PATCH 261/272] Update discord/ui/action_row.py Co-authored-by: Soheab <33902984+Soheab@users.noreply.github.com> --- discord/ui/action_row.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 8de7bbb2b628..0648583815f9 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -480,10 +480,10 @@ def select( default_values: Sequence[ValidDefaultValues] = MISSING, id: Optional[int] = None, ) -> SelectCallbackDecorator[BaseSelectT]: - """A decorator that attaches a select menu to a component. + """A decorator that attaches a select menu to the action row. The function being decorated should have three parameters, ``self`` representing - the :class:`discord.ui.LayoutView`, the :class:`discord.Interaction` you receive and + the :class:`discord.ui.ActionRow`, the :class:`discord.Interaction` you receive and the chosen select class. To obtain the selected values inside the callback, you can use the ``values`` attribute of the chosen class in the callback. The list of values From ff80605c2e578f47e85ca5168f10994fe2342316 Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Mon, 11 Aug 2025 21:29:16 +0200 Subject: [PATCH 262/272] Update discord/ui/container.py Co-authored-by: Soheab <33902984+Soheab@users.noreply.github.com> --- discord/ui/container.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index cf551e05586f..8901c6b916f3 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -316,8 +316,8 @@ def remove_item(self, item: Item[Any]) -> Self: Parameters ---------- - item: :class:`TextDisplay` - The item to remove from the section. + item: :class:`Item` + The item to remove from the container. """ try: From d499c73e70f96503aacb01bc33fe62df94c4c93a Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Mon, 11 Aug 2025 21:29:39 +0200 Subject: [PATCH 263/272] Update discord/ui/section.py Co-authored-by: Soheab <33902984+Soheab@users.noreply.github.com> --- discord/ui/section.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/section.py b/discord/ui/section.py index 74a19e7ac28b..ca044bc920e5 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -172,7 +172,7 @@ def remove_item(self, item: Item[Any]) -> Self: Parameters ---------- - item: :class:`TextDisplay` + item: :class:`Item` The item to remove from the section. """ From 3b8d527fd1a952e710f2c78fa484c5c002edd07f Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Mon, 11 Aug 2025 21:30:35 +0200 Subject: [PATCH 264/272] Update discord/ui/section.py Co-authored-by: Soheab <33902984+Soheab@users.noreply.github.com> --- discord/ui/section.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/section.py b/discord/ui/section.py index ca044bc920e5..ce36a660c4d1 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -107,7 +107,7 @@ def _is_v2(self) -> bool: def walk_children(self) -> Generator[Item[V], None, None]: """An iterator that recursively walks through all the children of this section. - and it's children, if applicable. + and it's children, if applicable. This includes the `accessory`. Yields ------ From 72e79ff834073734827d4c2aeeef38cf0fc36e07 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Mon, 11 Aug 2025 21:31:37 +0200 Subject: [PATCH 265/272] it\'s -> its --- discord/ui/action_row.py | 2 +- discord/ui/container.py | 2 +- discord/ui/section.py | 4 ++-- discord/ui/view.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 0648583815f9..ac07326cb107 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -205,7 +205,7 @@ def children(self) -> List[Item[V]]: def walk_children(self) -> Generator[Item[V], Any, None]: """An iterator that recursively walks through all the children of this action row - and it's children, if applicable. + and its children, if applicable. Yields ------ diff --git a/discord/ui/container.py b/discord/ui/container.py index 8901c6b916f3..4920fcd838e8 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -265,7 +265,7 @@ def from_component(cls, component: ContainerComponent) -> Self: def walk_children(self) -> Generator[Item[V], None, None]: """An iterator that recursively walks through all the children of this container - and it's children, if applicable. + and its children, if applicable. Yields ------ diff --git a/discord/ui/section.py b/discord/ui/section.py index ce36a660c4d1..745f91ab3d63 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -106,8 +106,8 @@ def _is_v2(self) -> bool: return True def walk_children(self) -> Generator[Item[V], None, None]: - """An iterator that recursively walks through all the children of this section. - and it's children, if applicable. This includes the `accessory`. + """An iterator that recursively walks through all the children of this section + and its children, if applicable. This includes the `accessory`. Yields ------ diff --git a/discord/ui/view.py b/discord/ui/view.py index 61b2da0bda7b..ef22c8f5ef5f 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -637,7 +637,7 @@ async def wait(self) -> bool: def walk_children(self) -> Generator[Item[Any], None, None]: """An iterator that recursively walks through all the children of this view - and it's children, if applicable. + and its children, if applicable. Yields ------ From 895a750c8535e7b48054895d2e7f8fcffaf40131 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Wed, 13 Aug 2025 12:41:40 +0200 Subject: [PATCH 266/272] remove accessory handling in schedule_dynamic_item_call --- discord/ui/view.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index ef22c8f5ef5f..50d0c091257a 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -925,15 +925,7 @@ async def schedule_dynamic_item_call( try: child_index = parent._children.index(base_item) # type: ignore except ValueError: - # handle cases in which the item is a section accessory - if getattr(base_item._parent, '__discord_ui_section__', False): - if ( - base_item._parent.accessory.type.value == component_type # type: ignore - and getattr(base_item._parent.accessory, 'custom_id', None) == custom_id # type: ignore - ): - base_item._parent.accessory = item # type: ignore - else: - return + return else: parent._children[child_index] = item # type: ignore From 5677b3ad5f6db2c3e096bfb63a308f23c0b5c07a Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Wed, 13 Aug 2025 12:45:07 +0200 Subject: [PATCH 267/272] dont use cls, use View or LayoutView --- discord/ui/view.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index 50d0c091257a..a1aeefa41f1f 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -344,7 +344,15 @@ def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) The converted view. This will always return one of :class:`View` or :class:`LayoutView`, and not one of its subclasses. """ - view = cls(timeout=timeout) + + if issubclass(cls, View): + view_cls = View + elif issubclass(cls, LayoutView): + view_cls = LayoutView + else: + raise TypeError('unreachable exception') + + view = view_cls(timeout=timeout) row = 0 for component in message.components: From 81a8884519e3754a1fd7319fa2f87774c327f53b Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Wed, 13 Aug 2025 12:45:49 +0200 Subject: [PATCH 268/272] typing --- discord/ui/view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index a1aeefa41f1f..5f8fc214cfa5 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -319,7 +319,7 @@ def children(self) -> List[Item[Self]]: return self._children.copy() @classmethod - def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> Self: + def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> Union[View, LayoutView]: """Converts a message's components into a :class:`View`. The :attr:`.Message.components` of a message are read-only From 9424f916f6a59d59db6a140579c4f390a07297cf Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Wed, 13 Aug 2025 14:22:55 +0200 Subject: [PATCH 269/272] run black --- discord/ui/view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index 5f8fc214cfa5..eaf46544cd3a 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -352,7 +352,7 @@ def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) else: raise TypeError('unreachable exception') - view = view_cls(timeout=timeout) + view = view_cls(timeout=timeout) row = 0 for component in message.components: From 93ceddd05d829b9b8b730d863dbc8e6a4f35dc84 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Wed, 13 Aug 2025 16:02:18 +0200 Subject: [PATCH 270/272] use new types instead of replacing with Any --- discord/ui/action_row.py | 33 ++++++++++++++++++--------------- discord/ui/button.py | 13 +++++++++---- discord/ui/container.py | 14 +++++++++----- discord/ui/item.py | 2 +- discord/ui/select.py | 23 ++++++++++++++--------- discord/ui/view.py | 8 ++++---- 6 files changed, 55 insertions(+), 38 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index ac07326cb107..31ab6d17dd17 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -41,7 +41,7 @@ overload, ) -from .item import Item, ItemCallbackType +from .item import I, Item from .button import Button, button as _button from .select import select as _select, Select, UserSelect, RoleSelect, ChannelSelect, MentionableSelect from ..components import ActionRow as ActionRowComponent @@ -61,12 +61,15 @@ RoleSelectT, UserSelectT, SelectT, - SelectCallbackDecorator, ) from ..emoji import Emoji from ..components import SelectOption from ..interactions import Interaction + ItemCallbackType = Callable[['S', Interaction[Any], I], Coroutine[Any, Any, Any]] + SelectCallbackDecorator = Callable[[ItemCallbackType['S', BaseSelectT]], BaseSelectT] + +S = TypeVar('S', bound='ActionRow', covariant=True) V = TypeVar('V', bound='LayoutView', covariant=True) __all__ = ('ActionRow',) @@ -75,8 +78,8 @@ class _ActionRowCallback: __slots__ = ('row', 'callback', 'item') - def __init__(self, callback: ItemCallbackType[Any], row: ActionRow, item: Item[Any]) -> None: - self.callback: ItemCallbackType[Any] = callback + def __init__(self, callback: ItemCallbackType[S, Any], row: ActionRow, item: Item[Any]) -> None: + self.callback: ItemCallbackType[Any, Any] = callback self.row: ActionRow = row self.item: Item[Any] = item @@ -127,7 +130,7 @@ async def row_button(self, interaction: discord.Interaction, button: discord.ui. The ID of this component. This must be unique across the view. """ - __action_row_children_items__: ClassVar[List[ItemCallbackType[Any]]] = [] + __action_row_children_items__: ClassVar[List[ItemCallbackType[Self, Any]]] = [] __discord_ui_action_row__: ClassVar[bool] = True __item_repr_attributes__ = ('id',) @@ -149,7 +152,7 @@ def __init__( def __init_subclass__(cls) -> None: super().__init_subclass__() - children: Dict[str, ItemCallbackType[Any]] = {} + children: Dict[str, ItemCallbackType[Self, Any]] = {} for base in reversed(cls.__mro__): for name, member in base.__dict__.items(): if hasattr(member, '__discord_ui_model_type__'): @@ -331,7 +334,7 @@ def button( style: ButtonStyle = ButtonStyle.secondary, emoji: Optional[Union[str, Emoji, PartialEmoji]] = None, id: Optional[int] = None, - ) -> Callable[[ItemCallbackType[Button[V]]], Button[V]]: + ) -> Callable[[ItemCallbackType[S, Button[V]]], Button[V]]: """A decorator that attaches a button to the action row. The function being decorated should have three parameters, ``self`` representing @@ -367,7 +370,7 @@ def button( .. versionadded:: 2.6 """ - def decorator(func: ItemCallbackType[Button[V]]) -> ItemCallbackType[Button[V]]: + def decorator(func: ItemCallbackType[S, Button[V]]) -> ItemCallbackType[S, Button[V]]: ret = _button( label=label, custom_id=custom_id, @@ -395,7 +398,7 @@ def select( max_values: int = ..., disabled: bool = ..., id: Optional[int] = ..., - ) -> SelectCallbackDecorator[SelectT]: + ) -> SelectCallbackDecorator[S, SelectT]: ... @overload @@ -412,7 +415,7 @@ def select( disabled: bool = ..., default_values: Sequence[ValidDefaultValues] = ..., id: Optional[int] = ..., - ) -> SelectCallbackDecorator[UserSelectT]: + ) -> SelectCallbackDecorator[S, UserSelectT]: ... @overload @@ -429,7 +432,7 @@ def select( disabled: bool = ..., default_values: Sequence[ValidDefaultValues] = ..., id: Optional[int] = ..., - ) -> SelectCallbackDecorator[RoleSelectT]: + ) -> SelectCallbackDecorator[S, RoleSelectT]: ... @overload @@ -446,7 +449,7 @@ def select( disabled: bool = ..., default_values: Sequence[ValidDefaultValues] = ..., id: Optional[int] = ..., - ) -> SelectCallbackDecorator[ChannelSelectT]: + ) -> SelectCallbackDecorator[S, ChannelSelectT]: ... @overload @@ -463,7 +466,7 @@ def select( disabled: bool = ..., default_values: Sequence[ValidDefaultValues] = ..., id: Optional[int] = ..., - ) -> SelectCallbackDecorator[MentionableSelectT]: + ) -> SelectCallbackDecorator[S, MentionableSelectT]: ... def select( @@ -479,7 +482,7 @@ def select( disabled: bool = False, default_values: Sequence[ValidDefaultValues] = MISSING, id: Optional[int] = None, - ) -> SelectCallbackDecorator[BaseSelectT]: + ) -> SelectCallbackDecorator[S, BaseSelectT]: """A decorator that attaches a select menu to the action row. The function being decorated should have three parameters, ``self`` representing @@ -554,7 +557,7 @@ async def select_channels(self, interaction: discord.Interaction, select: Channe .. versionadded:: 2.6 """ - def decorator(func: ItemCallbackType[BaseSelectT]) -> ItemCallbackType[BaseSelectT]: + def decorator(func: ItemCallbackType[S, BaseSelectT]) -> ItemCallbackType[S, BaseSelectT]: r = _select( # type: ignore cls=cls, # type: ignore placeholder=placeholder, diff --git a/discord/ui/button.py b/discord/ui/button.py index fe1d65e1bffc..97dba390cf00 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -25,12 +25,12 @@ from __future__ import annotations import copy -from typing import Callable, Literal, Optional, TYPE_CHECKING, Tuple, TypeVar, Union +from typing import Any, Callable, Coroutine, Literal, Optional, TYPE_CHECKING, Tuple, TypeVar, Union import inspect import os -from .item import Item, ItemCallbackType +from .item import Item, I from ..enums import ButtonStyle, ComponentType from ..partial_emoji import PartialEmoji, _EmojiTag from ..components import Button as ButtonComponent @@ -44,9 +44,14 @@ from typing_extensions import Self from .view import BaseView + from .action_row import ActionRow from ..emoji import Emoji + from ..interactions import Interaction from ..types.components import ButtonComponent as ButtonComponentPayload + ItemCallbackType = Callable[['S', Interaction[Any], I], Coroutine[Any, Any, Any]] + +S = TypeVar('S', bound='Union[BaseView, ActionRow]', covariant=True) V = TypeVar('V', bound='BaseView', covariant=True) @@ -316,7 +321,7 @@ def button( emoji: Optional[Union[str, Emoji, PartialEmoji]] = None, row: Optional[int] = None, id: Optional[int] = None, -) -> Callable[[ItemCallbackType[Button[V]]], Button[V]]: +) -> Callable[[ItemCallbackType[S, Button[V]]], Button[V]]: """A decorator that attaches a button to a component. The function being decorated should have three parameters, ``self`` representing @@ -363,7 +368,7 @@ def button( .. versionadded:: 2.6 """ - def decorator(func: ItemCallbackType[Button[V]]) -> ItemCallbackType[Button[V]]: + def decorator(func: ItemCallbackType[S, Button[V]]) -> ItemCallbackType[S, Button[V]]: if not inspect.iscoroutinefunction(func): raise TypeError('button function must be a coroutine function') diff --git a/discord/ui/container.py b/discord/ui/container.py index 4920fcd838e8..a0d0a5f3211c 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -27,6 +27,7 @@ from typing import ( TYPE_CHECKING, Any, + Callable, ClassVar, Coroutine, Dict, @@ -38,7 +39,7 @@ Union, ) -from .item import Item, ItemCallbackType +from .item import Item, I from .view import _component_to_item, LayoutView from ..enums import ComponentType from ..utils import get as _utils_get @@ -50,6 +51,9 @@ from ..components import Container as ContainerComponent from ..interactions import Interaction + ItemCallbackType = Callable[['S', Interaction[Any], I], Coroutine[Any, Any, Any]] + +S = TypeVar('S', bound='Container', covariant=True) V = TypeVar('V', bound='LayoutView', covariant=True) __all__ = ('Container',) @@ -58,8 +62,8 @@ class _ContainerCallback: __slots__ = ('container', 'callback', 'item') - def __init__(self, callback: ItemCallbackType[Any], container: Container, item: Item[Any]) -> None: - self.callback: ItemCallbackType[Any] = callback + def __init__(self, callback: ItemCallbackType[S, Any], container: Container, item: Item[Any]) -> None: + self.callback: ItemCallbackType[Any, Any] = callback self.container: Container = container self.item: Item[Any] = item @@ -117,7 +121,7 @@ class MyView(ui.LayoutView): The ID of this component. This must be unique across the view. """ - __container_children_items__: ClassVar[Dict[str, Union[ItemCallbackType[Any], Item[Any]]]] = {} + __container_children_items__: ClassVar[Dict[str, Union[ItemCallbackType[Self, Any], Item[Any]]]] = {} __discord_ui_container__: ClassVar[bool] = True __item_repr_attributes__ = ( 'accent_colour', @@ -177,7 +181,7 @@ def _init_children(self) -> List[Item[Any]]: def __init_subclass__(cls) -> None: super().__init_subclass__() - children: Dict[str, Union[ItemCallbackType[Any], Item[Any]]] = {} + children: Dict[str, Union[ItemCallbackType[Self, Any], Item[Any]]] = {} for base in reversed(cls.__mro__): for name, member in base.__dict__.items(): if isinstance(member, Item): diff --git a/discord/ui/item.py b/discord/ui/item.py index 16236a183d42..97f528cdb5ba 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -45,7 +45,7 @@ I = TypeVar('I', bound='Item[Any]') V = TypeVar('V', bound='BaseView', covariant=True) -ItemCallbackType = Callable[[Any, Interaction[Any], I], Coroutine[Any, Any, Any]] +ItemCallbackType = Callable[[V, Interaction[Any], I], Coroutine[Any, Any, Any]] class Item(Generic[V]): diff --git a/discord/ui/select.py b/discord/ui/select.py index eccfde2d842e..47d513ac36d0 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -25,6 +25,7 @@ from __future__ import annotations from typing import ( Any, + Coroutine, List, Literal, Optional, @@ -42,7 +43,7 @@ import inspect import os -from .item import Item, ItemCallbackType +from .item import Item, I from ..enums import ChannelType, ComponentType, SelectDefaultValueType from ..partial_emoji import PartialEmoji from ..emoji import Emoji @@ -73,6 +74,7 @@ from typing_extensions import TypeAlias, TypeGuard from .view import BaseView + from .action_row import ActionRow from ..types.components import SelectMenu as SelectMenuPayload from ..types.interactions import SelectMessageComponentInteractionData from ..app_commands import AppCommandChannel, AppCommandThread @@ -101,6 +103,9 @@ Thread, ] + ItemCallbackType = Callable[['S', Interaction[Any], I], Coroutine[Any, Any, Any]] + +S = TypeVar('S', bound='Union[BaseView, ActionRow]', covariant=True) V = TypeVar('V', bound='BaseView', covariant=True) BaseSelectT = TypeVar('BaseSelectT', bound='BaseSelect[Any]') SelectT = TypeVar('SelectT', bound='Select[Any]') @@ -108,7 +113,7 @@ RoleSelectT = TypeVar('RoleSelectT', bound='RoleSelect[Any]') ChannelSelectT = TypeVar('ChannelSelectT', bound='ChannelSelect[Any]') MentionableSelectT = TypeVar('MentionableSelectT', bound='MentionableSelect[Any]') -SelectCallbackDecorator: TypeAlias = Callable[[ItemCallbackType[BaseSelectT]], BaseSelectT] +SelectCallbackDecorator: TypeAlias = Callable[[ItemCallbackType[S, BaseSelectT]], BaseSelectT] DefaultSelectComponentTypes = Literal[ ComponentType.user_select, ComponentType.role_select, @@ -963,7 +968,7 @@ def select( disabled: bool = ..., row: Optional[int] = ..., id: Optional[int] = ..., -) -> SelectCallbackDecorator[SelectT]: +) -> SelectCallbackDecorator[S, SelectT]: ... @@ -981,7 +986,7 @@ def select( default_values: Sequence[ValidDefaultValues] = ..., row: Optional[int] = ..., id: Optional[int] = ..., -) -> SelectCallbackDecorator[UserSelectT]: +) -> SelectCallbackDecorator[S, UserSelectT]: ... @@ -999,7 +1004,7 @@ def select( default_values: Sequence[ValidDefaultValues] = ..., row: Optional[int] = ..., id: Optional[int] = ..., -) -> SelectCallbackDecorator[RoleSelectT]: +) -> SelectCallbackDecorator[S, RoleSelectT]: ... @@ -1017,7 +1022,7 @@ def select( default_values: Sequence[ValidDefaultValues] = ..., row: Optional[int] = ..., id: Optional[int] = ..., -) -> SelectCallbackDecorator[ChannelSelectT]: +) -> SelectCallbackDecorator[S, ChannelSelectT]: ... @@ -1035,7 +1040,7 @@ def select( default_values: Sequence[ValidDefaultValues] = ..., row: Optional[int] = ..., id: Optional[int] = ..., -) -> SelectCallbackDecorator[MentionableSelectT]: +) -> SelectCallbackDecorator[S, MentionableSelectT]: ... @@ -1052,7 +1057,7 @@ def select( default_values: Sequence[ValidDefaultValues] = MISSING, row: Optional[int] = None, id: Optional[int] = None, -) -> SelectCallbackDecorator[BaseSelectT]: +) -> SelectCallbackDecorator[S, BaseSelectT]: """A decorator that attaches a select menu to a component. The function being decorated should have three parameters, ``self`` representing @@ -1141,7 +1146,7 @@ async def select_channels(self, interaction: discord.Interaction, select: Channe .. versionadded:: 2.6 """ - def decorator(func: ItemCallbackType[BaseSelectT]) -> ItemCallbackType[BaseSelectT]: + def decorator(func: ItemCallbackType[S, BaseSelectT]) -> ItemCallbackType[S, BaseSelectT]: if not inspect.iscoroutinefunction(func): raise TypeError('select function must be a coroutine function') callback_cls = getattr(cls, '__origin__', cls) diff --git a/discord/ui/view.py b/discord/ui/view.py index eaf46544cd3a..0677947f6f52 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -87,7 +87,7 @@ from ..state import ConnectionState from .modal import Modal - ItemLike = Union[ItemCallbackType[Any], Item[Any]] + ItemLike = Union[ItemCallbackType[Any, Any], Item[Any]] _log = logging.getLogger(__name__) @@ -201,8 +201,8 @@ def clear(self) -> None: class _ViewCallback: __slots__ = ('view', 'callback', 'item') - def __init__(self, callback: ItemCallbackType[Any], view: BaseView, item: Item[BaseView]) -> None: - self.callback: ItemCallbackType[Any] = callback + def __init__(self, callback: ItemCallbackType[Any, Any], view: BaseView, item: Item[BaseView]) -> None: + self.callback: ItemCallbackType[Any, Any] = callback self.view: BaseView = view self.item: Item[BaseView] = item @@ -791,7 +791,7 @@ def __init_subclass__(cls) -> None: super().__init_subclass__() children: Dict[str, ItemLike] = {} - callback_children: Dict[str, ItemCallbackType[Any]] = {} + callback_children: Dict[str, ItemCallbackType[Any, Any]] = {} for base in reversed(cls.__mro__): for name, member in base.__dict__.items(): From fb49079a373290bfdac57d184b22a7242fd1d92d Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Wed, 13 Aug 2025 16:04:35 +0200 Subject: [PATCH 271/272] oops --- discord/ui/select.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/discord/ui/select.py b/discord/ui/select.py index 47d513ac36d0..a0621c7826f1 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -103,8 +103,7 @@ Thread, ] - ItemCallbackType = Callable[['S', Interaction[Any], I], Coroutine[Any, Any, Any]] - +ItemCallbackType = Callable[['S', 'Interaction[Any]', I], Coroutine[Any, Any, Any]] S = TypeVar('S', bound='Union[BaseView, ActionRow]', covariant=True) V = TypeVar('V', bound='BaseView', covariant=True) BaseSelectT = TypeVar('BaseSelectT', bound='BaseSelect[Any]') From 5a2ad023aee3281cf3e9ca1ad985b43c08569e2c Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Wed, 13 Aug 2025 16:08:16 +0200 Subject: [PATCH 272/272] hmm --- discord/ui/select.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/discord/ui/select.py b/discord/ui/select.py index a0621c7826f1..55596ec6bad9 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -103,7 +103,8 @@ Thread, ] -ItemCallbackType = Callable[['S', 'Interaction[Any]', I], Coroutine[Any, Any, Any]] + ItemCallbackType = Callable[['S', Interaction[Any], I], Coroutine[Any, Any, Any]] + S = TypeVar('S', bound='Union[BaseView, ActionRow]', covariant=True) V = TypeVar('V', bound='BaseView', covariant=True) BaseSelectT = TypeVar('BaseSelectT', bound='BaseSelect[Any]') @@ -112,7 +113,7 @@ RoleSelectT = TypeVar('RoleSelectT', bound='RoleSelect[Any]') ChannelSelectT = TypeVar('ChannelSelectT', bound='ChannelSelect[Any]') MentionableSelectT = TypeVar('MentionableSelectT', bound='MentionableSelect[Any]') -SelectCallbackDecorator: TypeAlias = Callable[[ItemCallbackType[S, BaseSelectT]], BaseSelectT] +SelectCallbackDecorator: TypeAlias = Callable[['ItemCallbackType[S, BaseSelectT]'], BaseSelectT] DefaultSelectComponentTypes = Literal[ ComponentType.user_select, ComponentType.role_select,