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/158] 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 000000000..2be4eac1a --- /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 2af2d6d20..141c03cc2 100644 --- a/discord/components.py +++ b/discord/components.py @@ -4,7 +4,7 @@ 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"), +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 @@ 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 +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 @@ DEALINGS IN THE SOFTWARE. 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 @@ if TYPE_CHECKING: 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 @@ __all__ = ( 'SelectOption', 'TextInput', 'SelectDefaultValue', + 'SectionComponent', + 'TextComponent', + 'ThumbnailComponent', + 'MediaGalleryComponent', + 'FileComponent', + 'DividerComponent', + 'ComponentContainer', ) @@ -99,7 +129,7 @@ class Component: setattr(self, slot, value) return self - def to_dict(self) -> ComponentPayload: + def to_dict(self) -> ComponentBasePayload: raise NotImplementedError @@ -290,9 +320,13 @@ class SelectMenu(Component): 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 @@ class SelectMenu(Component): 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 @@ class SelectOption: 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 @@ class SelectDefaultValue: @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 @@ class SelectDefaultValue: ) -@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 ce772cc87..fc9303d19 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -77,6 +77,8 @@ __all__ = ( '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 de806ba9c..3be323983 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -498,6 +498,14 @@ class MessageFlags(BaseFlags): """ 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 3016d2f29..1010e1c12 100644 --- a/discord/message.py +++ b/discord/message.py @@ -27,8 +27,6 @@ from __future__ import annotations import asyncio import datetime import re -import io -from os import PathLike from typing import ( Dict, TYPE_CHECKING, @@ -55,7 +53,7 @@ from .errors import HTTPException 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 .sticker import StickerItem, GuildSticker 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 @@ if TYPE_CHECKING: __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 000000000..38d8ad667 --- /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 3b1295c13..4521f2514 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -29,19 +29,27 @@ from typing_extensions import NotRequired 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 ae38db46f..81bfdd23b 100644 --- a/discord/types/message.py +++ b/discord/types/message.py @@ -33,11 +33,12 @@ from .user import User 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 000000000..0f6f76006 --- /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/158] 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 2be4eac1a..45dab6c74 100644 --- a/discord/attachment.py +++ b/discord/attachment.py @@ -29,12 +29,19 @@ 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 .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 @@ __all__ = ( 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 @@ class AttachmentBase: 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 @@ class Attachment(Hashable, AttachmentBase): 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 a0fd1148d..1a40d3d0b 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 38d8ad667..20fcd8e1b 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 4521f2514..c169a5286 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 0f6f76006..fc8a9e142 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -26,6 +26,9 @@ from __future__ import annotations 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/158] 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 45dab6c74..195ce30b5 100644 --- a/discord/attachment.py +++ b/discord/attachment.py @@ -27,6 +27,7 @@ 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 @@ -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 @@ class AttachmentBase: 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 @@ class AttachmentBase: 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 1a40d3d0b..09f6d54ab 100644 --- a/discord/components.py +++ b/discord/components.py @@ -33,6 +33,8 @@ from typing import ( Tuple, Union, ) + +from .attachment import UnfurledAttachment from .enums import ( try_enum, ComponentType, @@ -40,8 +42,9 @@ from .enums import ( 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 @@ if TYPE_CHECKING: 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 @@ __all__ = ( 'TextInput', 'SelectDefaultValue', 'SectionComponent', - 'TextComponent', 'ThumbnailComponent', 'MediaGalleryComponent', 'FileComponent', - 'DividerComponent', - 'ComponentContainer', + 'SectionComponent', ) @@ -159,7 +164,7 @@ class ActionRow(Component): 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 @@ class SectionComponent(Component): 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 082a1a708..025f0bf14 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -77,7 +77,7 @@ __all__ = ( '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 8a916083e..000747e78 100644 --- a/discord/message.py +++ b/discord/message.py @@ -238,7 +238,7 @@ class MessageSnapshot: 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 20fcd8e1b..0084c334c 100644 --- a/discord/types/attachment.py +++ b/discord/types/attachment.py @@ -25,7 +25,7 @@ DEALINGS IN THE SOFTWARE. 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 c169a5286..cffb67ead 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -24,14 +24,14 @@ DEALINGS IN THE SOFTWARE. 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 c5a51777c..029717cb5 100644 --- a/discord/ui/__init__.py +++ b/discord/ui/__init__.py @@ -16,3 +16,4 @@ from .button import * 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 000000000..6792c188f --- /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 fc8a9e142..000000000 --- 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 dd44944ec..b6262cf22 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -40,6 +40,11 @@ from ..components import ( _component_factory, Button as ButtonComponent, SelectMenu as SelectComponent, + SectionComponent, + TextDisplay, + MediaGalleryComponent, + FileComponent, + SeparatorComponent, ) # fmt: off @@ -62,6 +67,7 @@ if TYPE_CHECKING: _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 @@ class ViewStore: 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/158] 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 09f6d54ab..4b25bcb00 100644 --- a/discord/components.py +++ b/discord/components.py @@ -4,7 +4,7 @@ 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'), +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 @@ 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 +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/158] 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 4b25bcb00..4e0196f7d 100644 --- a/discord/components.py +++ b/discord/components.py @@ -97,12 +97,19 @@ __all__ = ( 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 @@ class SeparatorComponent(Component): 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 @@ class SeparatorComponent(Component): 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 6617efa27..58b501722 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 cffb67ead..a50cbdd1e 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -184,5 +184,6 @@ ContainerChildComponent = Union[ SectionComponent, ContainerComponent, SeparatorComponent, + ThumbnailComponent, ] Component = Union[ActionRowChildComponent, ContainerChildComponent] diff --git a/discord/ui/container.py b/discord/ui/container.py index 6792c188f..4bd68b724 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -23,23 +23,32 @@ DEALINGS IN THE SOFTWARE. """ 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 @@ class Container: 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 1ee549283..2d2a3aaa6 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -80,6 +80,9 @@ class Item(Generic[V]): 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 000000000..81a0e4ba4 --- /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 000000000..a984a1892 --- /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 b6262cf22..4abac5116 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -41,7 +41,7 @@ from ..components import ( Button as ButtonComponent, SelectMenu as SelectComponent, SectionComponent, - TextDisplay, + TextDisplay as TextDisplayComponent, MediaGalleryComponent, FileComponent, SeparatorComponent, @@ -67,7 +67,6 @@ if TYPE_CHECKING: _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 @@ class _ViewWeights: 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 @@ class _ViewWeights: 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 @@ class View: 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 @@ class View: # 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/158] 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 58b501722..d8eedeb2e 100644 --- a/discord/http.py +++ b/discord/http.py @@ -57,6 +57,7 @@ from .file import File from .mentions import AllowedMentions from . import __version__, utils from .utils import MISSING +from .flags import MessageFlags _log = logging.getLogger(__name__) @@ -66,7 +67,6 @@ if TYPE_CHECKING: 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 4bd68b724..a2ca83a25 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -23,16 +23,16 @@ DEALINGS IN THE SOFTWARE. """ 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 @@ V = TypeVar('V', bound='View', covariant=True) __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 @@ class Container(Item[V]): 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 81a0e4ba4..5176d761b 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 @@ class Section(Item[V]): 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 @@ class Section(Item[V]): 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 4abac5116..19bc3f33b 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -23,7 +23,7 @@ DEALINGS IN THE SOFTWARE. """ 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 @@ class _ViewWeights: 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 @@ class _ViewWeights: 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 @@ class _ViewWeights: 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 @@ class View: 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 @@ class View: 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 @@ class ViewStore: 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 3b62b10fa..f1cfb573b 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/158] 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 70531fb20..1380b3048 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -1389,6 +1389,7 @@ class Messageable: reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., view: View = ..., + views: Sequence[View] = ..., suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., @@ -1410,6 +1411,7 @@ class Messageable: reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., view: View = ..., + views: Sequence[View] = ..., suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., @@ -1431,6 +1433,7 @@ class Messageable: reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., view: View = ..., + views: Sequence[View] = ..., suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., @@ -1452,6 +1455,7 @@ class Messageable: reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., view: View = ..., + views: Sequence[View] = ..., suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., @@ -1474,6 +1478,7 @@ class Messageable: 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 @@ class Messageable: 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 @@ class Messageable: 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 @@ class Messageable: 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 d8eedeb2e..c6e4d1377 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 43bd3a8b0..b4df36aed 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 a2ca83a25..a98b0d965 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -23,11 +23,11 @@ DEALINGS IN THE SOFTWARE. """ 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 @@ class Container(View, Item[V]): 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 5176d761b..0012d0118 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -62,6 +62,7 @@ class Section(Item[V]): *, 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 @@ class Section(Item[V]): 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 000000000..0daff9c89 --- /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 19bc3f33b..4afcd9fad 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 @@ class _ViewWeights: 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 @@ class _ViewWeights: 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 @@ class View: 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 @@ class View: 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 @@ class View: 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 @@ class View: # 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 @@ class ViewStore: 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/158] chore: Remove views things --- discord/abc.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/discord/abc.py b/discord/abc.py index 1380b3048..ae6a1fb15 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -1389,7 +1389,6 @@ class Messageable: reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., view: View = ..., - views: Sequence[View] = ..., suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., @@ -1411,7 +1410,6 @@ class Messageable: reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., view: View = ..., - views: Sequence[View] = ..., suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., @@ -1433,7 +1431,6 @@ class Messageable: reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., view: View = ..., - views: Sequence[View] = ..., suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., @@ -1455,7 +1452,6 @@ class Messageable: reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., view: View = ..., - views: Sequence[View] = ..., suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., @@ -1478,7 +1474,6 @@ class Messageable: 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 @@ class Messageable: 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 @@ class Messageable: 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/158] 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 195ce30b5..4b9765b99 100644 --- a/discord/attachment.py +++ b/discord/attachment.py @@ -52,9 +52,87 @@ __all__ = ( ) -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 @@ class AttachmentBase: 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 @@ class AttachmentBase: 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/158] 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 4b9765b99..000000000 --- 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 4e0196f7d..7330b82e9 100644 --- a/discord/components.py +++ b/discord/components.py @@ -34,7 +34,7 @@ from typing import ( Union, ) -from .attachment import UnfurledAttachment +from .asset import AssetMixin from .enums import ( try_enum, ComponentType, @@ -43,7 +43,9 @@ from .enums import ( 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 @@ if TYPE_CHECKING: MediaGalleryItem as MediaGalleryItemPayload, ThumbnailComponent as ThumbnailComponentPayload, ContainerComponent as ContainerComponentPayload, + UnfurledMediaItem as UnfurledMediaItemPayload, ) from .emoji import Emoji @@ -773,7 +776,7 @@ class ThumbnailComponent(Component): 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 @@ class TextDisplay(Component): } +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 @@ class MediaGalleryItem: ) -> 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 @@ class MediaGalleryItem: 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 @@ class FileComponent(Component): 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 025f0bf14..49684935f 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -78,7 +78,7 @@ __all__ = ( '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 a984a1892..67e380f65 100644 --- a/discord/ui/thumbnail.py +++ b/discord/ui/thumbnail.py @@ -23,10 +23,11 @@ DEALINGS IN THE SOFTWARE. """ 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 @@ class Thumbnail(Item[V]): 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 4afcd9fad..c2cef2248 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -255,6 +255,9 @@ class View: # 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/158] 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 ae6a1fb15..70531fb20 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -1580,8 +1580,7 @@ class Messageable: 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/158] 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 000747e78..c551138f7 100644 --- a/discord/message.py +++ b/discord/message.py @@ -27,6 +27,8 @@ from __future__ import annotations import asyncio import datetime import re +import io +from os import PathLike from typing import ( Dict, TYPE_CHECKING, @@ -53,7 +55,7 @@ from .errors import HTTPException 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 .sticker import StickerItem, GuildSticker 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 @@ if TYPE_CHECKING: __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 @@ class MessageSnapshot: 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/158] 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 0084c334c..000000000 --- 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 a50cbdd1e..68aa6156d 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -29,7 +29,6 @@ from typing_extensions import NotRequired 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 81bfdd23b..1d837d2d8 100644 --- a/discord/types/message.py +++ b/discord/types/message.py @@ -38,7 +38,6 @@ from .interactions import MessageInteraction, MessageInteractionMetadata 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/158] 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 1d837d2d8..6c260d44d 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/158] 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 7330b82e9..f13646910 100644 --- a/discord/components.py +++ b/discord/components.py @@ -986,7 +986,6 @@ class MediaGalleryComponent(Component): 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 000000000..b4285a654 --- /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 2d2a3aaa6..aaf15cee6 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -70,6 +70,7 @@ class Item(Generic[V]): # 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 @@ class Item(Generic[V]): 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 000000000..fa20af740 --- /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 0012d0118..4da36f86f 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 @@ class Section(Item[V]): 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 000000000..c275fad82 --- /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 0daff9c89..a51d60493 100644 --- a/discord/ui/text_display.py +++ b/discord/ui/text_display.py @@ -23,7 +23,7 @@ DEALINGS IN THE SOFTWARE. """ 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 67e380f65..fe1d96221 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 c2cef2248..08714bf3a 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -685,7 +685,12 @@ class ViewStore: 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/158] 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 f13646910..cb1a50976 100644 --- a/discord/components.py +++ b/discord/components.py @@ -91,6 +91,8 @@ __all__ = ( 'SelectDefaultValue', 'SectionComponent', 'ThumbnailComponent', + 'UnfurledMediaItem', + 'MediaGalleryItem', 'MediaGalleryComponent', 'FileComponent', 'SectionComponent', diff --git a/discord/ui/__init__.py b/discord/ui/__init__.py index 029717cb5..62a78634c 100644 --- a/discord/ui/__init__.py +++ b/discord/ui/__init__.py @@ -17,3 +17,9 @@ from .select import * 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 a98b0d965..978781dd8 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 b4285a654..fabf5b0f3 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 fa20af740..93638d7f6 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 4da36f86f..fece9b053 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -78,6 +78,8 @@ class Section(Item[V]): ] 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 c275fad82..cc49adecb 100644 --- a/discord/ui/separator.py +++ b/discord/ui/separator.py @@ -30,6 +30,8 @@ from ..components import SeparatorComponent 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 @@ class Separator(Item[V]): 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 a51d60493..9a70bd247 100644 --- a/discord/ui/text_display.py +++ b/discord/ui/text_display.py @@ -30,6 +30,8 @@ from ..components import TextDisplay as TextDisplayComponent 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 @@ class TextDisplay(Item[V]): 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 fe1d96221..ce178fb4c 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 08714bf3a..92ec768fa 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -45,6 +45,7 @@ from ..components import ( 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 934335c5a..07e04ca77 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 feab66907..df2d7418d 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/158] 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 cb1a50976..976906638 100644 --- a/discord/components.py +++ b/discord/components.py @@ -79,6 +79,17 @@ if TYPE_CHECKING: 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 @@ class SelectMenu(Component): 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 @@ class SelectOption: 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 @@ class SelectDefaultValue: @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 @@ class SectionComponent(Component): 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 @@ class ThumbnailComponent(Component): 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 @@ class MediaGalleryItem: 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 @@ class Container(Component): 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 @@ class Container(Component): """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 c551138f7..c0a853ce3 100644 --- a/discord/message.py +++ b/discord/message.py @@ -96,7 +96,7 @@ if TYPE_CHECKING: 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 @@ if TYPE_CHECKING: 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 68aa6156d..98201817a 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 fece9b053..f2b6554ca 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -72,10 +72,8 @@ class Section(Item[V]): ) -> 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 @@ class Section(Item[V]): @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 ce178fb4c..05e68b881 100644 --- a/discord/ui/thumbnail.py +++ b/discord/ui/thumbnail.py @@ -37,9 +37,8 @@ if TYPE_CHECKING: 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 92ec768fa..8dd7ca2d4 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -23,7 +23,7 @@ DEALINGS IN THE SOFTWARE. """ 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 @@ class ViewStore: 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/158] 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 976906638..c09adb913 100644 --- a/discord/components.py +++ b/discord/components.py @@ -107,6 +107,8 @@ __all__ = ( 'MediaGalleryComponent', 'FileComponent', 'SectionComponent', + 'Container', + 'TextDisplay', ) diff --git a/discord/ui/file.py b/discord/ui/file.py index fabf5b0f3..84ef4ef52 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 93638d7f6..b2da65df0 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -45,7 +45,7 @@ __all__ = ('MediaGallery',) 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 cc49adecb..2eadd2a4b 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 05e68b881..cf9bfd3cc 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 8dd7ca2d4..e701d09e9 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -61,7 +61,7 @@ if TYPE_CHECKING: 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 @@ class ViewStore: 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/158] 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 c09adb913..b12de4f5e 100644 --- a/discord/components.py +++ b/discord/components.py @@ -109,6 +109,7 @@ __all__ = ( '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/158] 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 b12de4f5e..d528f02ce 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 978781dd8..170d6eeca 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -42,7 +42,7 @@ __all__ = ('Container',) 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 @@ class Container(View, Item[V]): @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 @@ class Container(View, Item[V]): 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 b2da65df0..88991d40b 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -73,7 +73,7 @@ class MediaGallery(Item[V]): @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 @@ class MediaGallery(Item[V]): 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 @@ class MediaGallery(Item[V]): Parameters ---------- - item: :class:`MediaGalleryItem` + item: :class:`.MediaGalleryItem` The item to remove from the gallery. """ @@ -145,7 +145,7 @@ class MediaGallery(Item[V]): ---------- 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 07e04ca77..d331715c3 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/158] 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 aaf15cee6..bbd90464a 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -113,7 +113,7 @@ class Item(Generic[V]): 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/158] 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 d528f02ce..ed15e1c48 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 @@ class Container(Component): 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 84ef4ef52..3ff6c7d0f 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 @@ class File(Item[V]): @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 2eadd2a4b..33401f880 100644 --- a/discord/ui/separator.py +++ b/discord/ui/separator.py @@ -93,7 +93,7 @@ class Separator(Item[V]): @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/158] 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 ed15e1c48..962be86f7 100644 --- a/discord/components.py +++ b/discord/components.py @@ -829,8 +829,7 @@ class TextDisplay(Component): 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 170d6eeca..b49e1a700 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 88991d40b..4bc6c826f 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -45,7 +45,7 @@ __all__ = ('MediaGallery',) 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/158] 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 b4df36aed..43bd3a8b0 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/158] 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 f2b6554ca..f8b8ea4e2 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -89,6 +89,15 @@ class Section(Item[V]): 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/158] 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 962be86f7..f06eda2f6 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 @@ class SectionComponent(Component): 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 98201817a..bb241c9ac 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 b49e1a700..2acf95d20 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -29,6 +29,7 @@ from .item import Item 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 @@ class Container(View, Item[V]): 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 @@ class Container(View, Item[V]): 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 f8b8ea4e2..ce87b99f4 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -28,6 +28,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, TypeVar, U 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 @@ if TYPE_CHECKING: 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 @@ class Section(Item[V]): 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 @@ class Section(Item[V]): 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 9a70bd247..1bf88678d 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 @@ class TextDisplay(Item[V]): @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 e701d09e9..6ac69d66e 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -23,7 +23,7 @@ DEALINGS IN THE SOFTWARE. """ 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 @@ class _ViewWeights: 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 @@ class View: 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 @@ class View: # 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 @@ class View: 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/158] 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 6ac69d66e..208299c3a 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -277,13 +277,7 @@ class View: # 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/158] 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 f06eda2f6..af3e23e7b 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 208299c3a..3769d4c4c 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -215,6 +215,7 @@ class View: 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/158] 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 af3e23e7b..fc9a77de8 100644 --- a/discord/components.py +++ b/discord/components.py @@ -747,7 +747,7 @@ class SectionComponent(Component): 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 ce87b99f4..c0dfbfae7 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -79,10 +79,7 @@ class Section(Item[V]): 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 @@ class Section(Item[V]): 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 3769d4c4c..92910e7a6 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -23,7 +23,21 @@ DEALINGS IN THE SOFTWARE. """ 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/158] 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 fc9a77de8..4321d79dc 100644 --- a/discord/components.py +++ b/discord/components.py @@ -1018,9 +1018,9 @@ class FileComponent(Component): 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 c0dfbfae7..5a0ec7f27 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -174,8 +174,8 @@ class Section(Item[V]): 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/158] 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 92910e7a6..e490f1444 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -176,7 +176,7 @@ class _ViewWeights: 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/158] 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 4321d79dc..ac4ff987e 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 @@ class TextDisplay(Component): class UnfurledMediaItem(AssetMixin): """Represents an unfurled media item. + .. versionadded:: 2.6 + Parameters ---------- url: :class:`str` @@ -896,6 +915,9 @@ class UnfurledMediaItem(AssetMixin): 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 @@ class UnfurledMediaItem(AssetMixin): class MediaGalleryItem: """Represents a :class:`MediaGalleryComponent` media item. + .. versionadded:: 2.6 + Parameters ---------- media: Union[:class:`str`, :class:`UnfurledMediaItem`] @@ -936,6 +960,9 @@ class MediaGalleryItem: 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/158] 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 000000000..160a9eca8 --- /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 43bd3a8b0..f15910eff 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -43,6 +43,7 @@ if TYPE_CHECKING: 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 @@ class Button(Item[V]): 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 1ef085cc5..b2534e146 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -73,6 +73,7 @@ if TYPE_CHECKING: 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 @@ class BaseSelect(Item[V]): ) 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 e490f1444..e19f8bc6c 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -36,7 +36,6 @@ from typing import ( TYPE_CHECKING, Tuple, Type, - Union, ) from functools import partial from itertools import groupby @@ -47,6 +46,7 @@ import sys 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 @@ class _ViewWeights: 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 @@ class _ViewCallback: 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 @@ class View: 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 @@ class View: 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 @@ class View: 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 @@ class View: 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 @@ class View: 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/158] 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 160a9eca8..4101eb2dd 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -39,16 +39,20 @@ from typing import ( 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 @@ class ActionRow(Item[V]): 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 @@ class ActionRow(Item[V]): 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 @@ class ActionRow(Item[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, @@ -207,7 +298,90 @@ class ActionRow(Item[V]): 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 @@ class ActionRow(Item[V]): | :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 @@ class ActionRow(Item[V]): 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 e19f8bc6c..9ea612aeb 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -601,6 +601,7 @@ class View: # NOTE: maybe add a deprecation warning in favour of LayoutView? 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 @@ class LayoutView(View): 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 @@ class LayoutView(View): raise TypeError( 'LayoutView can only have items' ) + return children def _is_v2(self) -> bool: @@ -709,6 +723,11 @@ class ViewStore: 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/158] 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 ac4ff987e..ef7d67670 100644 --- a/discord/components.py +++ b/discord/components.py @@ -967,7 +967,7 @@ class MediaGalleryItem: 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 4101eb2dd..1df526cba 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -45,7 +45,8 @@ from typing import ( 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 @@ if TYPE_CHECKING: ChannelSelectT, RoleSelectT, UserSelectT, - SelectT + SelectT, + SelectCallbackDecorator, ) from ..emoji import Emoji from ..components import SelectOption @@ -125,7 +127,7 @@ class ActionRow(Item[V]): 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 @@ class ActionRow(Item[V]): 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 f15910eff..df21c770f 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -42,12 +42,12 @@ __all__ = ( 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 @@ class Button(Item[V]): ) 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 2acf95d20..1b50eceb9 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -26,7 +26,7 @@ from __future__ import annotations 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 @@ if TYPE_CHECKING: 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 @@ class Container(View, Item[V]): 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 @@ class Container(View, Item[V]): 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 0b65e90f3..ee3ad30d5 100644 --- a/discord/ui/dynamic.py +++ b/discord/ui/dynamic.py @@ -38,14 +38,14 @@ if TYPE_CHECKING: 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 3ff6c7d0f..2654d351c 100644 --- a/discord/ui/file.py +++ b/discord/ui/file.py @@ -32,9 +32,9 @@ from ..enums import ComponentType 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 @@ class File(Item[V]): *, spoiler: bool = False, row: Optional[int] = None, + id: Optional[str] = None, ) -> None: super().__init__() self._underlying = FileComponent._raw_construct( @@ -75,6 +78,7 @@ class File(Item[V]): ) 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 bbd90464a..1fa68b68c 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -37,11 +37,11 @@ __all__ = ( 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 @@ class Item(Generic[V]): # 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 @@ class Item(Generic[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 4bc6c826f..f9e1fb264 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -35,9 +35,9 @@ from ..components import ( 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 @@ class MediaGallery(Item[V]): ) 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 5a0ec7f27..ba919beb8 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -33,10 +33,10 @@ from ..utils import MISSING 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 @@ class Section(Item[V]): *, accessory: Item[Any], row: Optional[int] = None, + id: Optional[str] = None, ) -> None: super().__init__() self._children: List[Item[Any]] = [] @@ -84,6 +87,7 @@ class Section(Item[V]): 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 b2534e146..f5a9fcbee 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -72,7 +72,7 @@ __all__ = ( 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 @@ if TYPE_CHECKING: 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 @@ class BaseSelect(Item[V]): ) 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 33401f880..b9ff955ad 100644 --- a/discord/ui/separator.py +++ b/discord/ui/separator.py @@ -32,9 +32,9 @@ from ..enums import SeparatorSize, ComponentType 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 @@ class Separator(Item[V]): 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 @@ class Separator(Item[V]): ) 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 1bf88678d..e55c72ba4 100644 --- a/discord/ui/text_display.py +++ b/discord/ui/text_display.py @@ -32,9 +32,9 @@ from ..enums import ComponentType 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 cf9bfd3cc..0e7def382 100644 --- a/discord/ui/thumbnail.py +++ b/discord/ui/thumbnail.py @@ -32,10 +32,10 @@ from ..components import UnfurledMediaItem 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 @@ class Thumbnail(Item[V]): description: Optional[str] = None, spoiler: bool = False, row: Optional[int] = None, + id: Optional[str] = None, ) -> None: super().__init__() @@ -79,6 +82,7 @@ class Thumbnail(Item[V]): 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 9ea612aeb..c63ac00e7 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -36,6 +36,7 @@ from typing import ( TYPE_CHECKING, Tuple, Type, + Union, ) from functools import partial from itertools import groupby @@ -46,7 +47,6 @@ import sys 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 @@ from ..components import ( SeparatorComponent, ThumbnailComponent, ) +from ..utils import get as _utils_get # fmt: off __all__ = ( 'View', + 'LayoutView', ) # fmt: on @@ -80,6 +82,8 @@ if TYPE_CHECKING: from ..state import ConnectionState from .modal import Modal + ItemLike = Union[ItemCallbackType[Any, Any], Item[Any]] + _log = logging.getLogger(__name__) @@ -188,57 +192,18 @@ class _ViewCallback: 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 @@ class View: # NOTE: maybe add a deprecation warning in favour of LayoutView? 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 @@ class View: # NOTE: maybe add a deprecation warning in favour of LayoutView? 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 @@ class View: # NOTE: maybe add a deprecation warning in favour of LayoutView? 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 @@ class View: # NOTE: maybe add a deprecation warning in favour of LayoutView? 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 @@ class View: # NOTE: maybe add a deprecation warning in favour of LayoutView? 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 @@ class View: # NOTE: maybe add a deprecation warning in favour of LayoutView? self._children.remove(item) except ValueError: pass - else: - self.__weights.remove_item(item) return self def clear_items(self) -> Self: @@ -429,9 +367,30 @@ class View: # NOTE: maybe add a deprecation warning in favour of LayoutView? 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 @@ class View: # NOTE: maybe add a deprecation warning in favour of LayoutView? 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 @@ class LayoutView(View): 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 @@ class ViewStore: 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 @@ class ViewStore: 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 @@ class ViewStore: 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 @@ class ViewStore: 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 @@ class ViewStore: 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/158] 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 df2d7418d..c62e50a3a 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/158] 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 1df526cba..572786056 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -331,7 +331,6 @@ class ActionRow(Item[V]): ) -> SelectCallbackDecorator[V, UserSelectT]: ... - @overload def select( self, @@ -348,7 +347,6 @@ class ActionRow(Item[V]): ) -> SelectCallbackDecorator[V, RoleSelectT]: ... - @overload def select( self, @@ -365,7 +363,6 @@ class ActionRow(Item[V]): ) -> SelectCallbackDecorator[V, ChannelSelectT]: ... - @overload def select( self, @@ -484,6 +481,7 @@ class ActionRow(Item[V]): @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 c63ac00e7..cd8e50f37 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -750,7 +750,7 @@ class LayoutView(BaseView): 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 @@ class ViewStore: 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/158] 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 c62e50a3a..46a2fa188 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/158] 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 b997bd96f..2eaae2455 100644 --- a/discord/client.py +++ b/discord/client.py @@ -72,7 +72,7 @@ from .object import Object 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 @@ class Client: 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 @@ class Client: 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 @@ class Client: 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 c4b71b368..dd8c0d561 100644 --- a/discord/state.py +++ b/discord/state.py @@ -71,7 +71,7 @@ from .flags import ApplicationFlags, Intents, MemberCacheFlags 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 @@ class ConnectionState(Generic[ClientT]): 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 @@ class ConnectionState(Generic[ClientT]): 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/158] 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 cd8e50f37..22dad5e98 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -461,7 +461,7 @@ class BaseView: 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 @@ class ViewStore: 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 46a2fa188..a40058823 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/158] 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 22dad5e98..d0f187187 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -779,7 +779,7 @@ class ViewStore: # 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/158] 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 933039735..0e81f33b8 100644 --- a/discord/ext/commands/context.py +++ b/discord/ext/commands/context.py @@ -48,7 +48,7 @@ if TYPE_CHECKING: 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 @@ class Context(discord.abc.Messageable, Generic[BotT]): 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 @@ class Context(discord.abc.Messageable, Generic[BotT]): 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 @@ class Context(discord.abc.Messageable, Generic[BotT]): 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 @@ class Context(discord.abc.Messageable, Generic[BotT]): 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 @@ class Context(discord.abc.Messageable, Generic[BotT]): 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 @@ class Context(discord.abc.Messageable, Generic[BotT]): 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 @@ class Context(discord.abc.Messageable, Generic[BotT]): 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 @@ class Context(discord.abc.Messageable, Generic[BotT]): 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 @@ class Context(discord.abc.Messageable, Generic[BotT]): 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 d0f187187..aff931009 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -733,7 +733,7 @@ class LayoutView(BaseView): 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/158] 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 1b50eceb9..da1770028 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -26,7 +26,7 @@ from __future__ import annotations 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 @@ V = TypeVar('V', bound='LayoutView', covariant=True) __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 @@ class Container(View, Item[V]): 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 @@ class Container(View, Item[V]): 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/158] 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 62a78634c..4d613f14f 100644 --- a/discord/ui/__init__.py +++ b/discord/ui/__init__.py @@ -23,3 +23,4 @@ from .section import * 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 572786056..a7017159a 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -43,7 +43,7 @@ from typing import ( ) 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 @@ class ActionRow(Item[V]): """ 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 aff931009..bafcfedff 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -223,7 +223,7 @@ class BaseView: 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 @@ class BaseView: 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/158] 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 a7017159a..4daf02839 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -23,7 +23,6 @@ DEALINGS IN THE SOFTWARE. """ 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/158] 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 70531fb20..666120c54 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -95,7 +95,7 @@ if TYPE_CHECKING: ) 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 @@ class Messageable: 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 @@ class Messageable: 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 @@ class Messageable: 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 @@ class Messageable: 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 a306707d6..3dc43d388 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -100,7 +100,7 @@ if TYPE_CHECKING: 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 @@ class ForumChannel(discord.abc.GuildChannel, Hashable): 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 c6e4d1377..e0fca5958 100644 --- a/discord/http.py +++ b/discord/http.py @@ -64,7 +64,7 @@ _log = logging.getLogger(__name__) 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 b9d9a4d11..ddc5094a4 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -76,7 +76,7 @@ if TYPE_CHECKING: 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 @@ class Interaction(Generic[ClientT]): 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 @@ class InteractionResponse(Generic[ClientT]): 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 @@ class InteractionResponse(Generic[ClientT]): 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 @@ class InteractionMessage(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, delete_after: Optional[float] = None, poll: Poll = MISSING, diff --git a/discord/message.py b/discord/message.py index c0a853ce3..0dee8df9c 100644 --- a/discord/message.py +++ b/discord/message.py @@ -101,7 +101,7 @@ if TYPE_CHECKING: 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 @@ class PartialMessage(Hashable): attachments: Sequence[Union[Attachment, File]] = ..., delete_after: Optional[float] = ..., allowed_mentions: Optional[AllowedMentions] = ..., - view: Optional[View] = ..., + view: Optional[BaseView] = ..., ) -> Message: ... @@ -1318,7 +1318,7 @@ class PartialMessage(Hashable): attachments: Sequence[Union[Attachment, File]] = ..., delete_after: Optional[float] = ..., allowed_mentions: Optional[AllowedMentions] = ..., - view: Optional[View] = ..., + view: Optional[BaseView] = ..., ) -> Message: ... @@ -1331,7 +1331,7 @@ class PartialMessage(Hashable): 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 @@ class Message(PartialMessage, Hashable): suppress: bool = ..., delete_after: Optional[float] = ..., allowed_mentions: Optional[AllowedMentions] = ..., - view: Optional[View] = ..., + view: Optional[BaseView] = ..., ) -> Message: ... @@ -2853,7 +2853,7 @@ class Message(PartialMessage, Hashable): suppress: bool = ..., delete_after: Optional[float] = ..., allowed_mentions: Optional[AllowedMentions] = ..., - view: Optional[View] = ..., + view: Optional[BaseView] = ..., ) -> Message: ... @@ -2867,7 +2867,7 @@ class Message(PartialMessage, Hashable): 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 f1cfb573b..2ddc451f6 100644 --- a/discord/webhook/async_.py +++ b/discord/webhook/async_.py @@ -71,7 +71,7 @@ if TYPE_CHECKING: 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 @@ class Webhook(BaseWebhook): 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 @@ class Webhook(BaseWebhook): 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 @@ class Webhook(BaseWebhook): 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 171931b12..db59b4659 100644 --- a/discord/webhook/sync.py +++ b/discord/webhook/sync.py @@ -66,7 +66,7 @@ if TYPE_CHECKING: 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 @@ class SyncWebhook(BaseWebhook): silent: bool = MISSING, applied_tags: List[ForumTag] = MISSING, poll: Poll = MISSING, + view: BaseView = MISSING, ) -> SyncWebhookMessage: ... @@ -899,6 +900,7 @@ class SyncWebhook(BaseWebhook): silent: bool = MISSING, applied_tags: List[ForumTag] = MISSING, poll: Poll = MISSING, + view: BaseView = MISSING, ) -> None: ... @@ -921,7 +923,7 @@ class SyncWebhook(BaseWebhook): 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/158] 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 666120c54..5d264283e 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -1473,7 +1473,7 @@ class Messageable: 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 0dee8df9c..b3b807610 100644 --- a/discord/message.py +++ b/discord/message.py @@ -1760,7 +1760,7 @@ class PartialMessage(Hashable): 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 @@ class PartialMessage(Hashable): 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 @@ class PartialMessage(Hashable): 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 @@ class PartialMessage(Hashable): 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 2ddc451f6..d62807779 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 @@ class WebhookMessage(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, ) -> WebhookMessage: """|coro| @@ -1946,7 +1946,7 @@ class Webhook(BaseWebhook): 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/158] 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 4daf02839..510d6175b 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 df21c770f..82a485f91 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 @@ class Button(Item[V]): 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 @@ class Button(Item[V]): ) 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 da1770028..b60c1ec40 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -23,10 +23,10 @@ DEALINGS IN THE SOFTWARE. """ 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 @@ if TYPE_CHECKING: 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 2654d351c..7d065f0ff 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 @@ class File(Item[V]): *, 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 1fa68b68c..bcee854a8 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -70,7 +70,7 @@ class Item(Generic[V]): # 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 @@ class Item(Generic[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 f9e1fb264..ee0fb3cf0 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 @@ class MediaGallery(Item[V]): 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 ba919beb8..0aa164d88 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 @@ class Section(Item[V]): *, 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 f5a9fcbee..efa8a9e68 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -239,6 +239,7 @@ class BaseSelect(Item[V]): 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 @@ class BaseSelect(Item[V]): ) 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 @@ class Select(BaseSelect[V]): options: List[SelectOption] = MISSING, disabled: bool = False, row: Optional[int] = None, + id: Optional[int] = None, ) -> None: super().__init__( self.type, @@ -417,6 +423,7 @@ class Select(BaseSelect[V]): 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 @@ class UserSelect(BaseSelect[V]): 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 @@ class UserSelect(BaseSelect[V]): 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 @@ class RoleSelect(BaseSelect[V]): 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 @@ class RoleSelect(BaseSelect[V]): 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 @@ class MentionableSelect(BaseSelect[V]): 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 @@ class MentionableSelect(BaseSelect[V]): 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 @@ class ChannelSelect(BaseSelect[V]): 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 @@ class ChannelSelect(BaseSelect[V]): 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 @@ def select( 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 select( '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 b9ff955ad..394e9ac78 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 @@ class Separator(Item[V]): 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 e55c72ba4..8e22905eb 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 0e7def382..e9a2c13f5 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 @@ class Thumbnail(Item[V]): 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 bafcfedff..9b0709fd4 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -223,6 +223,8 @@ class BaseView: 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 @@ class View(BaseView): # NOTE: maybe add a deprecation warning in favour of Layo 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 @@ class LayoutView(BaseView): 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/158] 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 b60c1ec40..cc2405a75 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -140,7 +140,7 @@ class Container(Item[V]): 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/158] 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 ef7d67670..a9a6de24b 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 @@ class ActionRow(Component): 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 @@ class Button(Component): '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 @@ class SelectMenu(Component): 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 @@ class SelectMenu(Component): '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 @@ class TextInput(Component): 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 @@ class TextInput(Component): '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 @@ class SectionComponent(Component): '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 @@ class ThumbnailComponent(Component): 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 @@ class SeparatorComponent(Component): ) -> 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 @@ class Container(Component): 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 510d6175b..b13948899 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -234,10 +234,13 @@ class ActionRow(Item[V]): 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 bcee854a8..854affa39 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -127,8 +127,7 @@ class Item(Generic[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/158] 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 82a485f91..7a60333db 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -149,6 +149,7 @@ class Button(Item[V]): style=style, emoji=emoji, sku_id=sku_id, + id=id, ) self._parent: Optional[ActionRow] = None self.row = row @@ -250,6 +251,7 @@ class Button(Item[V]): 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 cc2405a75..b4aa574b6 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -194,12 +194,15 @@ class Container(Item[V]): 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 @@ class Container(Item[V]): 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 7d065f0ff..2e34c316d 100644 --- a/discord/ui/file.py +++ b/discord/ui/file.py @@ -75,6 +75,7 @@ class File(Item[V]): 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 @@ class File(Item[V]): 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 ee0fb3cf0..3deca63c8 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -75,6 +75,7 @@ class MediaGallery(Item[V]): self._underlying = MediaGalleryComponent._raw_construct( items=items, + id=id, ) self.row = row @@ -183,4 +184,5 @@ class MediaGallery(Item[V]): 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 0aa164d88..a034a1c08 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -174,6 +174,7 @@ class Section(Item[V]): 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 @@ class Section(Item[V]): '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 efa8a9e68..e2d3d34d2 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 @@ class BaseSelect(Item[V]): 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 394e9ac78..e212f4b4e 100644 --- a/discord/ui/separator.py +++ b/discord/ui/separator.py @@ -74,6 +74,7 @@ class Separator(Item[V]): self._underlying = SeparatorComponent._raw_construct( spacing=spacing, visible=visible, + id=id, ) self.row = row @@ -120,4 +121,5 @@ class Separator(Item[V]): 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 8e22905eb..409b68272 100644 --- a/discord/ui/text_display.py +++ b/discord/ui/text_display.py @@ -67,10 +67,13 @@ class TextDisplay(Item[V]): 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 @@ class TextDisplay(Item[V]): 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 96b4581f4..86f7373ee 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 @@ class TextInput(Item[V]): 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 @@ class TextInput(Item[V]): 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 @@ class TextInput(Item[V]): 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 e9a2c13f5..7f21edd3a 100644 --- a/discord/ui/thumbnail.py +++ b/discord/ui/thumbnail.py @@ -96,12 +96,15 @@ class Thumbnail(Item[V]): 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 @@ class Thumbnail(Item[V]): 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/158] 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 a9a6de24b..5ed52891f 100644 --- a/discord/components.py +++ b/discord/components.py @@ -1225,11 +1225,11 @@ class Container(Component): 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/158] 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 b4aa574b6..256e3cfc5 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -183,9 +183,6 @@ class Container(Item[V]): 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/158] 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 256e3cfc5..9750010ad 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 @@ class Container(Item[V]): 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/158] 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 9750010ad..d546d593a 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -97,6 +97,7 @@ class Container(Item[V]): 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 @@ class Container(Item[V]): 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 @@ class Container(Item[V]): 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/158] 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 d546d593a..0376895c0 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -136,7 +136,7 @@ class Container(Item[V]): 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/158] 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 b13948899..74dc151ce 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -23,7 +23,6 @@ DEALINGS IN THE SOFTWARE. """ 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/158] 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 a034a1c08..bbaef2994 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -23,7 +23,7 @@ DEALINGS IN THE SOFTWARE. """ 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/158] 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 9b0709fd4..1acf58870 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -844,6 +844,9 @@ class ViewStore: ) 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/158] 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 1acf58870..1eadf0a8b 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -845,8 +845,12 @@ class ViewStore: 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/158] 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 1eadf0a8b..e3771a8fe 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -847,7 +847,7 @@ class ViewStore: 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/158] 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 e3771a8fe..dcd9d90e7 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -850,7 +850,7 @@ class ViewStore: 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/158] 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 dcd9d90e7..7016ef9d6 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -845,12 +845,14 @@ class ViewStore: 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/158] 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 0376895c0..810f05b55 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -218,8 +218,12 @@ class Container(Item[V]): 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 bbaef2994..1cd972d5d 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 @@ class Section(Item[V]): # 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/158] 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 810f05b55..8218e1de6 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -116,6 +116,9 @@ class Container(Item[V]): 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 @@ class Container(Item[V]): 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 1cd972d5d..981d06e93 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/158] 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 8218e1de6..fd9dd0c49 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -233,3 +233,63 @@ class Container(Item[V]): 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 981d06e93..16b5fb6c5 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -121,7 +121,7 @@ class Section(Item[V]): 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/158] 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 16b5fb6c5..be53d6620 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/158] 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 fd9dd0c49..1e4aa0bf7 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -119,6 +119,8 @@ class Container(Item[V]): 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/158] 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 74dc151ce..4edf78b60 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 fd9dd0c49..7a034e3c6 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 @@ class Container(Item[V]): 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 2e34c316d..0f6875421 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 854affa39..597be4dab 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 be53d6620..88fe03e5d 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 7016ef9d6..4a3e2ac8d 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -23,6 +23,8 @@ DEALINGS IN THE SOFTWARE. """ from __future__ import annotations + +import warnings from typing import ( Any, Callable, @@ -183,10 +185,10 @@ class _ViewWeights: 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 @@ class BaseView: 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 @@ class BaseView: 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 @@ class View(BaseView): # NOTE: maybe add a deprecation warning in favour of Layo 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 @@ class LayoutView(BaseView): def __init_subclass__(cls) -> None: children: Dict[str, Item[Any]] = {} + callback_children: Dict[str, ItemCallbackType[Any, Any]] = {} row = 0 @@ -720,12 +726,12 @@ class LayoutView(BaseView): 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/158] 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 a40058823..b75d33044 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/158] 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 4edf78b60..481337384 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -47,7 +47,7 @@ from .select import select as _select, Select, UserSelect, RoleSelect, ChannelSe 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 @@ class ActionRow(Item[V]): 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 40583e17a..20aff903c 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -29,7 +29,7 @@ from .item import Item, ItemCallbackType 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 @@ class Container(Item[V]): 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 88fe03e5d..5a3104af8 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -28,7 +28,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, TypeVar, U 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 @@ class Section(Item[V]): 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/158] 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 481337384..70384cf9a 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -140,7 +140,7 @@ class ActionRow(Item[V]): 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 @@ class ActionRow(Item[V]): 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 @@ class ActionRow(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 20aff903c..198973dbe 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -97,6 +97,7 @@ class Container(Item[V]): 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 @@ class Container(Item[V]): 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 @@ class Container(Item[V]): 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 @@ class Container(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 597be4dab..614859d72 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -24,6 +24,7 @@ DEALINGS IN THE SOFTWARE. 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 @@ class Item(Generic[V]): 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 @@ class Item(Generic[V]): 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 5a3104af8..bacda7883 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -162,7 +162,7 @@ class Section(Item[V]): 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 @@ class Section(Item[V]): Parameters ---------- - id: :class:`str` + id: :class:`int` The ID of the component. Returns @@ -204,7 +204,13 @@ class Section(Item[V]): 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 4a3e2ac8d..e9bd6f773 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -372,7 +372,7 @@ class BaseView: 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 @@ class BaseView: 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/158] 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 bacda7883..13d13169c 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -205,8 +205,8 @@ class Section(Item[V]): 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/158] 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 5ed52891f..80842f7fc 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/158] 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 1a9d612aa..8bf4ee9c7 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -503,7 +503,9 @@ class MessageFlags(BaseFlags): 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 e9bd6f773..44a956b73 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/158] 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 198973dbe..c10d24119 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -224,7 +224,7 @@ class Container(Item[V]): 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 ee3ad30d5..b8aa78fdb 100644 --- a/discord/ui/dynamic.py +++ b/discord/ui/dynamic.py @@ -144,7 +144,7 @@ class DynamicItem(Generic[BaseT], Item['BaseView']): @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 @@ class DynamicItem(Generic[BaseT], Item['BaseView']): 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 44a956b73..5107716bd 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -492,7 +492,7 @@ class BaseView: 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 @@ class ViewStore: 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 @@ class ViewStore: 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/158] 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 b7fd0252e..748a021d7 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -1546,10 +1546,12 @@ class Messageable: 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 3dc43d388..8833f566e 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -2907,8 +2907,11 @@ class ForumChannel(discord.abc.GuildChannel, Hashable): 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 2eaae2455..c620dc23a 100644 --- a/discord/client.py +++ b/discord/client.py @@ -3159,8 +3159,11 @@ class Client: 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 @@ class Client: @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 d62807779..c2c40ada5 100644 --- a/discord/webhook/async_.py +++ b/discord/webhook/async_.py @@ -1734,12 +1734,14 @@ class Webhook(BaseWebhook): 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 db59b4659..904597761 100644 --- a/discord/webhook/sync.py +++ b/discord/webhook/sync.py @@ -996,13 +996,15 @@ class SyncWebhook(BaseWebhook): 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/158] 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 c10d24119..96bafde0d 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -104,6 +104,9 @@ class Container(Item[V]): 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/158] 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 70384cf9a..9b01cd3a0 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -411,7 +411,7 @@ class ActionRow(Item[V]): """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/158] 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 614859d72..d735641db 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -83,6 +83,8 @@ class Item(Generic[V]): 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/158] 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 9b01cd3a0..9fa8541c7 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -75,8 +75,8 @@ __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 + 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 @@ class ActionRow(Item[V]): 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 @@ class ActionRow(Item[V]): 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 @@ class ActionRow(Item[V]): 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 @@ class ActionRow(Item[V]): min_values: int = ..., max_values: int = ..., disabled: bool = ..., - ) -> SelectCallbackDecorator[V, SelectT]: + ) -> SelectCallbackDecorator[SelectT]: ... @overload @@ -344,7 +344,7 @@ class ActionRow(Item[V]): max_values: int = ..., disabled: bool = ..., default_values: Sequence[ValidDefaultValues] = ..., - ) -> SelectCallbackDecorator[V, UserSelectT]: + ) -> SelectCallbackDecorator[UserSelectT]: ... @overload @@ -360,7 +360,7 @@ class ActionRow(Item[V]): max_values: int = ..., disabled: bool = ..., default_values: Sequence[ValidDefaultValues] = ..., - ) -> SelectCallbackDecorator[V, RoleSelectT]: + ) -> SelectCallbackDecorator[RoleSelectT]: ... @overload @@ -376,7 +376,7 @@ class ActionRow(Item[V]): max_values: int = ..., disabled: bool = ..., default_values: Sequence[ValidDefaultValues] = ..., - ) -> SelectCallbackDecorator[V, ChannelSelectT]: + ) -> SelectCallbackDecorator[ChannelSelectT]: ... @overload @@ -392,7 +392,7 @@ class ActionRow(Item[V]): max_values: int = ..., disabled: bool = ..., default_values: Sequence[ValidDefaultValues] = ..., - ) -> SelectCallbackDecorator[V, MentionableSelectT]: + ) -> SelectCallbackDecorator[MentionableSelectT]: ... def select( @@ -407,7 +407,7 @@ class ActionRow(Item[V]): 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 @@ class ActionRow(Item[V]): 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 7a60333db..46230d480 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 button( 'emoji': emoji, 'row': row, 'sku_id': None, + 'id': id, } return func diff --git a/discord/ui/container.py b/discord/ui/container.py index 96bafde0d..58176d0f5 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -46,8 +46,8 @@ __all__ = ('Container',) 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 @@ class Container(Item[V]): 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 d735641db..4206274c3 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -43,7 +43,7 @@ if TYPE_CHECKING: 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 @@ class Item(Generic[V]): 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 e2d3d34d2..40b8a26f3 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -109,7 +109,7 @@ UserSelectT = TypeVar('UserSelectT', bound='UserSelect[Any]') 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 @@ def select( .. 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 5107716bd..0fb577871 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -84,7 +84,7 @@ if TYPE_CHECKING: 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 @@ class _ViewWeights: 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 @@ class BaseView: 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 @@ class LayoutView(BaseView): 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/158] 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 58176d0f5..d2fdf5e5e 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 @@ class Container(Item[V]): 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/158] 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 748a021d7..505552684 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -1551,7 +1551,7 @@ class Messageable: .. 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 8833f566e..f17a74ca8 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -2911,7 +2911,7 @@ class ForumChannel(discord.abc.GuildChannel, Hashable): 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 20eb70599..931142b38 100644 --- a/discord/ext/commands/context.py +++ b/discord/ext/commands/context.py @@ -986,10 +986,12 @@ class Context(discord.abc.Messageable, Generic[BotT]): 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/158] 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 d2fdf5e5e..e45c1dac6 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 @@ class Container(Item[V]): 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 @@ class Container(Item[V]): 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 @@ class Container(Item[V]): 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/158] 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 9fa8541c7..b3c9837ad 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -197,6 +197,7 @@ class ActionRow(Item[V]): 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 e45c1dac6..260577de9 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -110,7 +110,6 @@ class Container(Item[V]): 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 @@ class Container(Item[V]): 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 13d13169c..98784d50a 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -139,9 +139,9 @@ class Section(Item[V]): 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 0fb577871..716576ccb 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -342,6 +342,10 @@ class BaseView: 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/158] 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 716576ccb..dc6b581cc 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -214,7 +214,7 @@ class BaseView: 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/158] 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 7f21edd3a..f63926991 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/158] 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 80842f7fc..22c3d714c 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/158] 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 e02be7359..658ec4127 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -929,8 +929,11 @@ class InteractionResponse(Generic[ClientT]): 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 @@ class InteractionResponse(Generic[ClientT]): 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 @@ class InteractionMessage(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. + + .. 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/158] 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 409b68272..f311cf66c 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/158] 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 b3c9837ad..b2d713f5a 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -198,6 +198,7 @@ class ActionRow(Item[V]): 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 260577de9..84147d882 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -275,10 +275,16 @@ class Container(Item[V]): 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 4206274c3..47a31633b 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -81,6 +81,7 @@ class Item(Generic[V]): 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 @@ class Item(Generic[V]): 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 98784d50a..53d433c3e 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -141,6 +141,7 @@ class Section(Item[V]): 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/158] chore: Remove leftover code --- discord/http.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/discord/http.py b/discord/http.py index e0fca5958..4e12de8bd 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/158] 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 b2d713f5a..22df88f85 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -87,10 +87,42 @@ class _ActionRowCallback: 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 @@ class ActionRow(Item[V]): 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 84147d882..a90df10b9 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -58,8 +58,41 @@ class _ContainerCallback: 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 @@ class Container(Item[V]): 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 0f6875421..2e10ff989 100644 --- a/discord/ui/file.py +++ b/discord/ui/file.py @@ -42,8 +42,23 @@ __all__ = ('File',) 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 dc6b581cc..61abd9875 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -706,6 +706,10 @@ class View(BaseView): 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/158] 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 2e10ff989..341860cc7 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 3deca63c8..e3db92215 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -45,7 +45,9 @@ __all__ = ('MediaGallery',) 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 53d433c3e..11de2ec18 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -44,6 +44,8 @@ __all__ = ('Section',) 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 e212f4b4e..48908df9d 100644 --- a/discord/ui/separator.py +++ b/discord/ui/separator.py @@ -42,6 +42,8 @@ __all__ = ('Separator',) 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 f311cf66c..beff74c6e 100644 --- a/discord/ui/text_display.py +++ b/discord/ui/text_display.py @@ -42,6 +42,8 @@ __all__ = ('TextDisplay',) 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/158] 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 505552684..5ea20b558 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -95,7 +95,7 @@ if TYPE_CHECKING: ) 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 @@ class Messageable: 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 @@ class Messageable: 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 @@ class Messageable: 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 @@ class Messageable: 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 f17a74ca8..314c46fae 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -100,7 +100,7 @@ if TYPE_CHECKING: 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 @@ class ForumChannel(discord.abc.GuildChannel, Hashable): 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 931142b38..1e957feb4 100644 --- a/discord/ext/commands/context.py +++ b/discord/ext/commands/context.py @@ -48,7 +48,7 @@ if TYPE_CHECKING: 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 @@ class Context(discord.abc.Messageable, Generic[BotT]): 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 @@ class Context(discord.abc.Messageable, Generic[BotT]): 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 @@ class Context(discord.abc.Messageable, Generic[BotT]): 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 @@ class Context(discord.abc.Messageable, Generic[BotT]): 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 @@ class Context(discord.abc.Messageable, Generic[BotT]): 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 @@ class Context(discord.abc.Messageable, Generic[BotT]): 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 658ec4127..7b0b9c493 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -27,7 +27,7 @@ DEALINGS IN THE SOFTWARE. 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 @@ if TYPE_CHECKING: 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 @@ class Interaction(Generic[ClientT]): 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 @@ class InteractionResponse(Generic[ClientT]): ) 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 @@ class InteractionResponse(Generic[ClientT]): 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 @@ class InteractionMessage(Message): 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 e4f19a4dd..f3d364ec8 100644 --- a/discord/message.py +++ b/discord/message.py @@ -101,7 +101,7 @@ if TYPE_CHECKING: 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 @@ class MessageSnapshot: 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 @@ class PartialMessage(Hashable): 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 @@ class PartialMessage(Hashable): attachments: Sequence[Union[Attachment, File]] = ..., delete_after: Optional[float] = ..., allowed_mentions: Optional[AllowedMentions] = ..., - view: Optional[BaseView] = ..., + view: Optional[View] = ..., ) -> Message: ... @@ -1324,7 +1335,7 @@ class PartialMessage(Hashable): attachments: Sequence[Union[Attachment, File]] = ..., delete_after: Optional[float] = ..., allowed_mentions: Optional[AllowedMentions] = ..., - view: Optional[BaseView] = ..., + view: Optional[View] = ..., ) -> Message: ... @@ -1387,10 +1398,13 @@ class PartialMessage(Hashable): 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 @@ class PartialMessage(Hashable): 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 @@ class PartialMessage(Hashable): 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 @@ class PartialMessage(Hashable): 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 @@ class PartialMessage(Hashable): 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 @@ class PartialMessage(Hashable): 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 61abd9875..e1f53bd8e 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 c2c40ada5..897ddadd3 100644 --- a/discord/webhook/async_.py +++ b/discord/webhook/async_.py @@ -71,7 +71,7 @@ if TYPE_CHECKING: 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 @@ class Webhook(BaseWebhook): # 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 @@ class Webhook(BaseWebhook): 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 @@ class Webhook(BaseWebhook): 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 @@ class Webhook(BaseWebhook): ) 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 @@ class Webhook(BaseWebhook): 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 904597761..9c211898d 100644 --- a/discord/webhook/sync.py +++ b/discord/webhook/sync.py @@ -66,7 +66,7 @@ if TYPE_CHECKING: 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 @@ class SyncWebhook(BaseWebhook): # 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 @@ class SyncWebhook(BaseWebhook): silent: bool = MISSING, applied_tags: List[ForumTag] = MISSING, poll: Poll = MISSING, - view: BaseView = MISSING, + view: View = MISSING, ) -> SyncWebhookMessage: ... @@ -900,7 +938,7 @@ class SyncWebhook(BaseWebhook): 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/158] fix: g --- discord/message.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/message.py b/discord/message.py index f3d364ec8..f6fba87cb 100644 --- a/discord/message.py +++ b/discord/message.py @@ -1770,7 +1770,7 @@ class PartialMessage(Hashable): 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/158] 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 000000000..08d43c1af --- /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/158] 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 e1f53bd8e..32dbfa167 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/158] 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 32dbfa167..4fd528ee3 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -24,7 +24,6 @@ DEALINGS IN THE SOFTWARE. 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/158] 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 a90df10b9..4c8b254e1 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -23,6 +23,7 @@ DEALINGS IN THE SOFTWARE. """ 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 Container(Item[V]): 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 @@ class Container(Item[V]): 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 @@ class Container(Item[V]): 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 @@ class Container(Item[V]): 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/158] 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 4c8b254e1..c9222262b 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -24,6 +24,7 @@ DEALINGS IN THE SOFTWARE. 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 @@ class Container(Item[V]): 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 11de2ec18..5d922a51a 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/158] 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 c9222262b..a804d1a91 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -124,8 +124,7 @@ class Container(Item[V]): 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 e3db92215..b86bbd0b4 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 5d922a51a..ca4f1f2aa 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/158] . --- 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 a804d1a91..617450773 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -291,7 +291,7 @@ class Container(Item[V]): @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/158] 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 ca4f1f2aa..355beebc9 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -197,7 +197,7 @@ class Section(Item[V]): 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/158] 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 b86bbd0b4..badd495e0 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -184,6 +184,6 @@ class MediaGallery(Item[V]): @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/158] 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 4fd528ee3..a13141432 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 08d43c1af..70effc30c 100644 --- a/examples/views/layout.py +++ b/examples/views/layout.py @@ -28,11 +28,9 @@ class Layout(discord.ui.LayoutView): 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 @@ 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 + 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/158] 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 f6fba87cb..0057b06f8 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/158] =?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 a13141432..a3ec58928 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -47,6 +47,7 @@ import logging 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 @@ class BaseView: 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 @@ class View(BaseView): 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 @@ class LayoutView(BaseView): 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 @@ class LayoutView(BaseView): 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/158] 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 a3ec58928..2ece514d2 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -218,6 +218,7 @@ class BaseView: 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 @@ class BaseView: 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 @@ class BaseView: 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/158] 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 2ece514d2..cb82c8cfd 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/158] 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 47a31633b..87cfc3681 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -112,7 +112,9 @@ class Item(Generic[V]): 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/158] 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 22df88f85..946b6d3b9 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -30,6 +30,7 @@ from typing import ( ClassVar, Coroutine, Dict, + Generator, List, Literal, Optional, @@ -204,6 +205,19 @@ class ActionRow(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 617450773..0f362f898 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -25,7 +25,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 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 @@ class Container(Item[V]): 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 355beebc9..3f91514c7 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -23,7 +23,7 @@ DEALINGS IN THE SOFTWARE. """ 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 @@ class Section(Item[V]): 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 @@ class Section(Item[V]): 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 cb82c8cfd..cbb28e0c8 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -638,6 +638,11 @@ class View(BaseView): 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 @@ class LayoutView(BaseView): 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 @@ class LayoutView(BaseView): 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/158] 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 314c46fae..168eee1f4 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -2846,15 +2846,15 @@ class ForumChannel(discord.abc.GuildChannel, Hashable): 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 @@ class ForumChannel(discord.abc.GuildChannel, Hashable): 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 897ddadd3..df6055d9c 100644 --- a/discord/webhook/async_.py +++ b/discord/webhook/async_.py @@ -1983,7 +1983,10 @@ class Webhook(BaseWebhook): self, message_id: int, *, + attachments: Sequence[Union[Attachment, File]] = ..., view: LayoutView, + allowed_mentions: Optional[AllowedMentions] = ..., + thread: Snowflake = ..., ) -> WebhookMessage: ... @@ -1992,13 +1995,13 @@ class Webhook(BaseWebhook): 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 9c211898d..d5295c1fc 100644 --- a/discord/webhook/sync.py +++ b/discord/webhook/sync.py @@ -1035,7 +1035,7 @@ class SyncWebhook(BaseWebhook): .. 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 @@ class SyncWebhook(BaseWebhook): ) 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 @@ class SyncWebhook(BaseWebhook): 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 @@ class SyncWebhook(BaseWebhook): 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/158] 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 0f362f898..4aad65ca4 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -136,8 +136,6 @@ class Container(Item[V]): 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 cbb28e0c8..91f96e508 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -209,6 +209,7 @@ class BaseView: 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 @@ class BaseView: 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 @@ class BaseView: 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 @@ class BaseView: 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 @@ class BaseView: """ 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 @@ class LayoutView(BaseView): 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 @@ class LayoutView(BaseView): 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 @@ class LayoutView(BaseView): 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/158] 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 91f96e508..e7a17c2d7 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -209,7 +209,7 @@ class BaseView: 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 @@ class BaseView: 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 @@ class BaseView: 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/158] 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 3f91514c7..cefc4cc40 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -115,7 +115,7 @@ class Section(Item[V]): 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/158] 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 4aad65ca4..a367c96e5 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -295,7 +295,7 @@ class Container(Item[V]): 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 e7a17c2d7..2217474c5 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -30,6 +30,7 @@ from typing import ( ClassVar, Coroutine, Dict, + Generator, Iterator, List, Optional, @@ -585,7 +586,7 @@ class BaseView: """ 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/158] 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 a367c96e5..f87915e4e 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -25,7 +25,21 @@ from __future__ import annotations 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/158] 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 946b6d3b9..e73d731de 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -134,9 +134,19 @@ class ActionRow(Item[V]): __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 @@ class ActionRow(Item[V]): 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 @@ class ActionRow(Item[V]): 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/158] 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 e73d731de..1eeb75da4 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -126,6 +126,8 @@ class ActionRow(Item[V]): 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 f87915e4e..adfbb549a 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -111,9 +111,8 @@ class Container(Item[V]): 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 341860cc7..ccc7a0fdc 100644 --- a/discord/ui/file.py +++ b/discord/ui/file.py @@ -63,7 +63,7 @@ class File(Item[V]): 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 badd495e0..b7c92f047 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 cefc4cc40..7a5fd2afa 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/158] 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 1e957feb4..3a7204e9b 100644 --- a/discord/ext/commands/context.py +++ b/discord/ext/commands/context.py @@ -855,7 +855,33 @@ class Context(discord.abc.Messageable, Generic[BotT]): 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 @@ class Context(discord.abc.Messageable, Generic[BotT]): 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 @@ class Context(discord.abc.Messageable, Generic[BotT]): 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 @@ class Context(discord.abc.Messageable, Generic[BotT]): 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 @@ class Context(discord.abc.Messageable, Generic[BotT]): 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/158] 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 1eeb75da4..e54522ec6 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -128,6 +128,13 @@ class ActionRow(Item[V]): ---------- *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 @@ class ActionRow(Item[V]): def __init__( self, *children: Item[V], + row: Optional[int] = None, id: Optional[int] = None, ) -> None: super().__init__() @@ -151,6 +159,7 @@ class ActionRow(Item[V]): 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 adfbb549a..c5890722c 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -126,7 +126,7 @@ class Container(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/file.py b/discord/ui/file.py index ccc7a0fdc..5d9014e72 100644 --- a/discord/ui/file.py +++ b/discord/ui/file.py @@ -72,7 +72,7 @@ class File(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/item.py b/discord/ui/item.py index 87cfc3681..c73d8e762 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -80,7 +80,7 @@ class Item(Generic[V]): # 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 b7c92f047..bbecc9649 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 7a5fd2afa..2d4acdc3a 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 48908df9d..e7d75a998 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 beff74c6e..9ba7f294e 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 f63926991..67f8e4c76 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/158] 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 e54522ec6..a72d4db1e 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -268,6 +268,10 @@ class ActionRow(Item[V]): 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 @@ class ActionRow(Item[V]): 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 @@ class ActionRow(Item[V]): 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 c5890722c..d9eb4f35d 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -360,9 +360,17 @@ class Container(Item[V]): 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 @@ class Container(Item[V]): 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 @@ class Container(Item[V]): 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 2d4acdc3a..70c5a778c 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -162,6 +162,10 @@ class Section(Item[V]): 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 @@ class Section(Item[V]): 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 @@ class Section(Item[V]): 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 2217474c5..d8c21354c 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/158] 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 df6055d9c..104da78ca 100644 --- a/discord/webhook/async_.py +++ b/discord/webhook/async_.py @@ -1611,6 +1611,7 @@ class Webhook(BaseWebhook): *, 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 @@ class Webhook(BaseWebhook): *, 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/158] 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 d8c21354c..0f9a552a3 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -791,7 +791,8 @@ class LayoutView(BaseView): # 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/158] 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 0f9a552a3..4de1d0766 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -766,6 +766,8 @@ class LayoutView(BaseView): 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/158] 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 d9eb4f35d..dab8f58ee 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -340,13 +340,7 @@ class Container(Item[V]): ------ 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 4de1d0766..2b72b8948 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -337,7 +337,7 @@ class BaseView: 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 @@ class LayoutView(BaseView): 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/158] 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 a72d4db1e..47903a4be 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -23,6 +23,7 @@ DEALINGS IN THE SOFTWARE. """ from __future__ import annotations +import sys from typing import ( TYPE_CHECKING, Any, @@ -202,6 +203,9 @@ class ActionRow(Item[V]): 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 @@ class ActionRow(Item[V]): 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 dab8f58ee..174a2ec02 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -25,6 +25,7 @@ from __future__ import annotations import copy import os +import sys from typing import ( TYPE_CHECKING, Any, @@ -210,6 +211,9 @@ class Container(Item[V]): 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 @@ class Container(Item[V]): 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 70c5a778c..28688152f 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -23,6 +23,7 @@ DEALINGS IN THE SOFTWARE. """ 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 @@ class Section(Item[V]): 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 @@ class Section(Item[V]): 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/158] 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 174a2ec02..72892d3a0 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -112,7 +112,7 @@ class Container(Item[V]): 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 2b72b8948..7ecc9da1e 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -776,7 +776,9 @@ class LayoutView(BaseView): 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/158] 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 47903a4be..524644472 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -274,7 +274,7 @@ class ActionRow(Item[V]): 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 @@ class ActionRow(Item[V]): 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 @@ class ActionRow(Item[V]): 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 72892d3a0..439f49a85 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -366,10 +366,9 @@ class Container(Item[V]): 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 @@ class Container(Item[V]): 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 @@ class Container(Item[V]): """ 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 28688152f..2653e7469 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -168,7 +168,7 @@ class Section(Item[V]): 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 @@ class Section(Item[V]): 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 @@ class Section(Item[V]): 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 7ecc9da1e..dfa4a2388 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -198,7 +198,6 @@ class _ViewCallback: 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 @@ class BaseView: 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 @@ class BaseView: 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 @@ class BaseView: 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 @@ class BaseView: 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 @@ class LayoutView(BaseView): 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 @@ class LayoutView(BaseView): 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/158] 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 524644472..cd7b4c75e 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -24,6 +24,7 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations import sys +from itertools import groupby from typing import ( TYPE_CHECKING, Any, @@ -335,8 +336,8 @@ class ActionRow(Item[V]): 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 439f49a85..e9c7494d8 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -26,6 +26,7 @@ from __future__ import annotations import copy import os import sys +from itertools import groupby from typing import ( TYPE_CHECKING, Any, @@ -269,8 +270,8 @@ class Container(Item[V]): 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 2653e7469..c38040346 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -24,6 +24,7 @@ DEALINGS IN THE SOFTWARE. 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 @@ class Section(Item[V]): 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 dfa4a2388..65d54c7da 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -761,7 +761,7 @@ class LayoutView(BaseView): 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 @@ class LayoutView(BaseView): # 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/158] 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 168eee1f4..9a1218f31 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -2852,6 +2852,7 @@ class ForumChannel(discord.abc.GuildChannel, Hashable): 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/158] 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 b8aa78fdb..667848920 100644 --- a/discord/ui/dynamic.py +++ b/discord/ui/dynamic.py @@ -45,7 +45,7 @@ else: 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 65d54c7da..6f31a65fb 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -948,7 +948,8 @@ class ViewStore: 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/158] 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 6f31a65fb..795818841 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -793,9 +793,7 @@ class LayoutView(BaseView): # 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/158] 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 cfb02ee5d..d007e0173 100644 --- a/examples/views/dynamic_counter.py +++ b/examples/views/dynamic_counter.py @@ -17,7 +17,7 @@ import re # 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/158] __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 e9c7494d8..199835179 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -356,7 +356,7 @@ class Container(Item[V]): 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 @@ class Container(Item[V]): 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 @@ class Container(Item[V]): 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/158] 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 cd7b4c75e..764ce1dc0 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -277,6 +277,9 @@ class ActionRow(Item[V]): 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 199835179..633af8360 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -160,6 +160,15 @@ class Container(Item[V]): 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 @@ class Container(Item[V]): 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/158] 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 795818841..1e988d55b 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -179,9 +179,6 @@ class _ViewWeights: 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/158] 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 633af8360..28ae23039 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -175,23 +175,15 @@ class Container(Item[V]): 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/158] 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 28ae23039..0e721e623 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -184,8 +184,7 @@ class Container(Item[V]): 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/158] 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 1e988d55b..964eb82e1 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -766,14 +766,11 @@ class LayoutView(BaseView): 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/158] 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 0e721e623..fce21dd4b 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -26,7 +26,6 @@ from __future__ import annotations import copy import os import sys -from itertools import groupby from typing import ( TYPE_CHECKING, Any, @@ -107,7 +106,7 @@ class Container(Item[V]): # 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 @@ class Container(Item[V]): 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/158] 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 964eb82e1..1f2e9848d 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -785,9 +785,9 @@ class LayoutView(BaseView): # 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/158] 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 22c3d714c..4f69a80dc 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 5d9014e72..09d557a89 100644 --- a/discord/ui/file.py +++ b/discord/ui/file.py @@ -63,7 +63,7 @@ class File(Item[V]): 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 bbecc9649..e7346bf69 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -23,13 +23,14 @@ DEALINGS IN THE SOFTWARE. """ 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 @@ class MediaGallery(Item[V]): 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 @@ class MediaGallery(Item[V]): 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/158] 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 764ce1dc0..167d1664f 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -205,7 +205,7 @@ class ActionRow(Item[V]): 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 c38040346..dd04431e6 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -118,7 +118,7 @@ class Section(Item[V]): 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/158] 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 fce21dd4b..1837e3d71 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -212,7 +212,7 @@ class Container(Item[V]): 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/158] 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 1f2e9848d..cd9c81958 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -49,11 +49,15 @@ import sys 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 @@ from ..components import ( 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 @@ if TYPE_CHECKING: 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 @@ class BaseView: # 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/158] 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 bb241c9ac..a458a1d96 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -34,7 +34,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"] -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/158] 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 4f69a80dc..e9812c518 100644 --- a/discord/components.py +++ b/discord/components.py @@ -965,7 +965,7 @@ class UnfurledMediaItem(AssetMixin): 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/158] 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 e9812c518..bb3cbd625 100644 --- a/discord/components.py +++ b/discord/components.py @@ -971,7 +971,10 @@ class UnfurledMediaItem(AssetMixin): 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/158] 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 167d1664f..4f27de4f1 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -338,7 +338,13 @@ class ActionRow(Item[V]): 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 1837e3d71..c74104237 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -268,7 +268,13 @@ class Container(Item[V]): 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 dd04431e6..fcd2002e9 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -239,7 +239,15 @@ class Section(Item[V]): 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 cd9c81958..e877457d7 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -983,7 +983,14 @@ class LayoutView(BaseView): # 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/158] 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 e877457d7..e978ef388 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -69,7 +69,7 @@ from ..components import ( 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/158] 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 bb3cbd625..4767a1a3e 100644 --- a/discord/components.py +++ b/discord/components.py @@ -54,7 +54,6 @@ if TYPE_CHECKING: 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 @@ class Component: 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 e978ef388..1c0783e7c 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/158] 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 c74104237..8f6d425d1 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/158] 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 8f6d425d1..ed1ba80b0 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -151,7 +151,7 @@ class Container(Item[V]): 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/158] 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 a458a1d96..332701ef5 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -33,7 +33,7 @@ from .channel import ChannelType 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/158] 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 ed1ba80b0..cfce5ef2f 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -247,6 +247,9 @@ class Container(Item[V]): @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/158] 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 cfce5ef2f..77dee7fa4 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -47,11 +47,11 @@ 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 if TYPE_CHECKING: from typing_extensions import Self - from ..colour import Colour, Color from ..components import Container as ContainerComponent from ..interactions import Interaction