diff --git a/discord/abc.py b/discord/abc.py index 713398a7d..9f8066502 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -96,7 +96,7 @@ if TYPE_CHECKING: ) from .poll import Poll from .threads import Thread - from .ui.view import View + from .ui.view import BaseView, View, LayoutView from .types.channel import ( PermissionOverwrite as PermissionOverwritePayload, Channel as ChannelPayload, @@ -1386,6 +1386,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, @@ -1485,7 +1517,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, @@ -1558,10 +1590,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 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. @@ -1656,7 +1690,7 @@ class Messageable: data = await state.http.send_message(channel.id, params=params) ret = state.create_message(channel=channel, data=data) - if view and not view.is_finished(): + if view and not view.is_finished() and view.is_dispatchable(): state.store_view(view, ret.id) if poll: diff --git a/discord/channel.py b/discord/channel.py index a306707d6..58a9943d4 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, View, LayoutView from .types.channel import ( TextChannel as TextChannelPayload, NewsChannel as NewsChannelPayload, @@ -2841,6 +2841,47 @@ class ForumChannel(discord.abc.GuildChannel, Hashable): return result + @overload + async def create_thread( + self, + *, + name: str, + auto_archive_duration: ThreadArchiveDuration = ..., + slowmode_delay: Optional[int] = ..., + file: File = ..., + files: Sequence[File] = ..., + allowed_mentions: AllowedMentions = ..., + mention_author: bool = ..., + applied_tags: Sequence[ForumTag] = ..., + view: LayoutView, + suppress_embeds: bool = ..., + reason: Optional[str] = ..., + ) -> ThreadWithMessage: + ... + + @overload + async def create_thread( + self, + *, + name: str, + 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: + ... + async def create_thread( self, *, @@ -2857,7 +2898,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: @@ -2907,8 +2948,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 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` @@ -2983,7 +3027,7 @@ class ForumChannel(discord.abc.GuildChannel, Hashable): data = await state.http.start_thread_in_forum(self.id, params=params, reason=reason) thread = Thread(guild=self.guild, state=self._state, data=data) message = Message(state=self._state, channel=thread, data=data['message']) - if view and not view.is_finished(): + if view and not view.is_finished() and view.is_dispatchable(): self._state.store_view(view, message.id) return ThreadWithMessage(thread=thread, message=message) diff --git a/discord/client.py b/discord/client.py index 68422435b..da9a3eafd 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 @@ -3154,7 +3154,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 @@ -3164,8 +3164,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 @@ -3180,7 +3183,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(): @@ -3192,8 +3195,8 @@ class Client: self._connection.store_view(view, message_id) @property - def persistent_views(self) -> Sequence[View]: - """Sequence[:class:`.View`]: A sequence of persistent views added to the client. + def persistent_views(self) -> Sequence[BaseView]: + """Sequence[Union[:class:`.View`, :class:`.LayoutView`]]: A sequence of persistent views added to the client. .. versionadded:: 2.0 """ diff --git a/discord/components.py b/discord/components.py index b62ab6bf9..357aa5975 100644 --- a/discord/components.py +++ b/discord/components.py @@ -24,9 +24,30 @@ 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 .utils import get_slots, MISSING +from typing import ( + ClassVar, + List, + Literal, + Optional, + TYPE_CHECKING, + Tuple, + Union, +) + +from .asset import AssetMixin +from .enums import ( + try_enum, + ComponentType, + ButtonStyle, + TextStyle, + ChannelType, + SelectDefaultValueType, + SeparatorSpacing, + MediaItemLoadingState, +) +from .flags import AttachmentFlags +from .colour import Colour +from .utils import get_slots, MISSING, _get_as_snowflake from .partial_emoji import PartialEmoji, _EmojiTag if TYPE_CHECKING: @@ -39,13 +60,35 @@ if TYPE_CHECKING: SelectOption as SelectOptionPayload, ActionRow as ActionRowPayload, TextInput as TextInputPayload, - ActionRowChildComponent as ActionRowChildComponentPayload, SelectDefaultValues as SelectDefaultValuesPayload, + SectionComponent as SectionComponentPayload, + TextComponent as TextComponentPayload, + MediaGalleryComponent as MediaGalleryComponentPayload, + FileComponent as FileComponentPayload, + SeparatorComponent as SeparatorComponentPayload, + MediaGalleryItem as MediaGalleryItemPayload, + ThumbnailComponent as ThumbnailComponentPayload, + ContainerComponent as ContainerComponentPayload, + UnfurledMediaItem as UnfurledMediaItemPayload, ) + from .emoji import Emoji from .abc import Snowflake + from .state import ConnectionState ActionRowChildComponentType = Union['Button', 'SelectMenu', 'TextInput'] + SectionComponentType = Union['TextDisplay', 'Button'] + MessageComponentType = Union[ + ActionRowChildComponentType, + SectionComponentType, + 'ActionRow', + 'SectionComponent', + 'ThumbnailComponent', + 'MediaGalleryComponent', + 'FileComponent', + 'SectionComponent', + 'Component', + ] __all__ = ( @@ -56,18 +99,35 @@ __all__ = ( 'SelectOption', 'TextInput', 'SelectDefaultValue', + 'SectionComponent', + 'ThumbnailComponent', + 'UnfurledMediaItem', + 'MediaGalleryItem', + 'MediaGalleryComponent', + 'FileComponent', + 'SectionComponent', + 'Container', + 'TextDisplay', + 'SeparatorComponent', ) 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. @@ -116,20 +176,25 @@ 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', []): 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]: @@ -137,10 +202,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): @@ -174,6 +242,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, ...] = ( @@ -184,11 +256,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') @@ -217,6 +291,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) @@ -268,6 +345,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, ...] = ( @@ -280,6 +361,7 @@ class SelectMenu(Component): 'disabled', 'channel_types', 'default_values', + 'id', ) __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ @@ -296,6 +378,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 = { @@ -305,6 +388,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: @@ -312,7 +397,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 @@ -473,6 +558,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, ...] = ( @@ -484,6 +573,7 @@ class TextInput(Component): 'required', 'min_length', 'max_length', + 'id', ) __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ @@ -497,6 +587,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]: @@ -512,6 +603,9 @@ class TextInput(Component): 'required': self.required, } + if self.id is not None: + payload['id'] = self.id + if self.placeholder: payload['placeholder'] = self.placeholder @@ -645,17 +739,563 @@ 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:: 2.6 + + Attributes + ---------- + components: List[Union[:class:`TextDisplay`, :class:`Button`]] + 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__ + + 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) + if component is not None: + self.components.append(component) # type: ignore # should be the correct type here + + @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], + 'accessory': self.accessory.to_dict(), + } + + if self.id is not None: + payload['id'] = self.id + + return payload + + +class ThumbnailComponent(Component): + """Represents a Thumbnail from the Discord Bot UI Kit. + + This inherits from :class:`Component`. + + .. note:: + + 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` + 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. + id: Optional[:class:`int`] + The ID of this component. + """ + + __slots__ = ( + 'media', + 'spoiler', + 'description', + 'id', + ) + + __repr_info__ = __slots__ + + def __init__( + self, + data: ThumbnailComponentPayload, + state: Optional[ConnectionState], + ) -> None: + 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: + 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. + + 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 + ---------- + content: :class:`str` + The content that this display shows. + id: Optional[:class:`int`] + The ID of this component. + """ + + __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: + payload: TextComponentPayload = { + 'type': self.type.value, + 'content': self.content, + } + if self.id is not None: + payload['id'] = self.id + return payload + + +class UnfurledMediaItem(AssetMixin): + """Represents an unfurled media item. + + .. versionadded:: 2.6 + + Parameters + ---------- + url: :class:`str` + 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 + ---------- + 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:`.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. + attachment_id: Optional[:class:`int`] + The attachment id this media item points to, only available if the url points to a local file + uploaded within the component message. + """ + + __slots__ = ( + 'url', + 'proxy_url', + 'height', + 'width', + 'content_type', + '_flags', + 'placeholder', + 'loading_state', + 'attachment_id', + '_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.attachment_id: Optional[int] = 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.get('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') + + loading_state = data.get('loading_state') + if loading_state is not None: + self.loading_state = try_enum(MediaItemLoadingState, loading_state) + self.attachment_id = _get_as_snowflake(data, 'attachment_id') + self._state = state + + def __repr__(self) -> str: + return f'' + + def to_dict(self): + return { + 'url': self.url, + } + + +class MediaGalleryItem: + """Represents a :class:`MediaGalleryComponent` media item. + + .. versionadded:: 2.6 + + 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. + """ + + __slots__ = ( + 'media', + 'description', + 'spoiler', + '_state', + ) + + 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._state: Optional[ConnectionState] = None + + def __repr__(self) -> str: + return f'' + + @classmethod + def _from_data(cls, data: MediaGalleryItemPayload, state: Optional[ConnectionState]) -> MediaGalleryItem: + media = data['media'] + self = cls( + media=UnfurledMediaItem._from_data(media, state), + description=data.get('description'), + spoiler=data.get('spoiler', False), + ) + self._state = state + return self + + @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: + payload: MediaGalleryItemPayload = { + 'media': self.media.to_dict(), # type: ignore + 'spoiler': self.spoiler, + } + + if self.description: + payload['description'] = self.description + + return payload + + +class MediaGalleryComponent(Component): + """Represents a Media Gallery component from the Discord Bot UI Kit. + + 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. + id: Optional[:class:`int`] + The ID of this component. + """ + + __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: + 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): + """Represents a File component from the Discord Bot UI Kit. + + 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` + 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. + name: Optional[:class:`str`] + The displayed file name, only available when received from the API. + size: Optional[:class:`int`] + The file size in MiB, only available when received from the API. + """ + + __slots__ = ( + 'media', + 'spoiler', + 'id', + 'name', + 'size', + ) + + __repr_info__ = __slots__ + 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') + self.name: Optional[str] = data.get('name') + self.size: Optional[int] = data.get('size') -@overload -def _component_factory(data: ComponentPayload) -> Optional[Union[ActionRow, ActionRowChildComponentType]]: - ... + @property + def type(self) -> Literal[ComponentType.file]: + return ComponentType.file + + def to_dict(self) -> FileComponentPayload: + 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): + """Represents a Separator from the Discord Bot UI Kit. + + 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:`SeparatorSpacing` + 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__ + + def __init__( + self, + data: SeparatorComponentPayload, + ) -> None: + self.spacing: SeparatorSpacing = try_enum(SeparatorSpacing, 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: + 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): + """Represents a Container from the Discord Bot UI Kit. + + 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` + This container's children. + spoiler: :class:`bool` + Whether this container is flagged as a spoiler. + id: Optional[:class:`int`] + 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') + + for child in data['components']: + comp = _component_factory(child, state) + + if comp: + self.children.append(comp) + + self.spoiler: bool = data.get('spoiler', False) + + 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]: + """Optional[:class:`Colour`]: The container's accent colour.""" + return self._colour + + accent_color = accent_colour + + @property + def type(self) -> Literal[ComponentType.container]: + return ComponentType.container + + def to_dict(self) -> ContainerComponentPayload: + payload: ContainerComponentPayload = { + 'type': self.type.value, + 'spoiler': self.spoiler, + 'components': [c.to_dict() for c in self.children], # pyright: ignore[reportAssignmentType] + } + 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) -> Optional[Union[ActionRow, ActionRowChildComponentType]]: +def _component_factory(data: ComponentPayload, state: Optional[ConnectionState] = None) -> Optional[Component]: if data['type'] == 1: return ActionRow(data) elif data['type'] == 2: @@ -663,4 +1303,18 @@ def _component_factory(data: ComponentPayload) -> Optional[Union[ActionRow, Acti 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: + return TextDisplay(data) + elif data['type'] == 11: + return ThumbnailComponent(data, state) + elif data['type'] == 12: + return MediaGalleryComponent(data, state) + elif data['type'] == 13: + return FileComponent(data, state) + elif data['type'] == 14: + return SeparatorComponent(data) + elif data['type'] == 17: + return Container(data, state) diff --git a/discord/enums.py b/discord/enums.py index acc780120..ce68da251 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -78,6 +78,8 @@ __all__ = ( 'VoiceChannelEffectAnimationType', 'SubscriptionStatus', 'MessageReferenceType', + 'SeparatorSpacing', + 'MediaItemLoadingState', ) @@ -643,6 +645,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 @@ -912,6 +921,18 @@ class SubscriptionStatus(Enum): inactive = 2 +class SeparatorSpacing(Enum): + small = 1 + large = 2 + + +class MediaItemLoadingState(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/ext/commands/context.py b/discord/ext/commands/context.py index b5b96c15f..e70096136 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, 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, @@ -817,6 +851,40 @@ class Context(discord.abc.Messageable, Generic[BotT]): if self.interaction: await self.interaction.response.defer(ephemeral=ephemeral) + @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 = ..., + 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: + ... + @overload async def send( self, @@ -920,7 +988,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, @@ -986,10 +1054,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. diff --git a/discord/flags.py b/discord/flags.py index 59a4909b8..78901d856 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -500,6 +500,16 @@ 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``, ``embeds``, ``stickers``, or ``poll``. + + .. versionadded:: 2.6 + """ + return 32768 + @fill_with_flags() class PublicUserFlags(BaseFlags): diff --git a/discord/http.py b/discord/http.py index 02fd1e136..da8777825 100644 --- a/discord/http.py +++ b/discord/http.py @@ -57,16 +57,16 @@ from .file import File from .mentions import AllowedMentions from . import __version__, utils from .utils import MISSING +from .flags import MessageFlags _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 .flags import MessageFlags from .poll import Poll from .types import ( @@ -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, @@ -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/interactions.py b/discord/interactions.py index 82b35e392..9a8085af4 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 View + 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 @@ -475,6 +475,17 @@ 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, *, @@ -485,6 +496,19 @@ class Interaction(Generic[ClientT]): view: Optional[View] = MISSING, allowed_mentions: Optional[AllowedMentions] = None, poll: Poll = MISSING, + ) -> InteractionMessage: + ... + + 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[BaseView] = MISSING, + allowed_mentions: Optional[AllowedMentions] = None, + poll: Poll = MISSING, ) -> InteractionMessage: """|coro| @@ -516,9 +540,18 @@ class Interaction(Generic[ClientT]): allowed_mentions: :class:`AllowedMentions` Controls the mentions being processed in this message. See :meth:`.abc.Messageable.send` for more information. - view: Optional[:class:`~discord.ui.View`] + view: Optional[Union[:class:`~discord.ui.View`, :class:`~discord.ui.LayoutView`]] The updated view to update this message with. If ``None`` is passed then the view is removed. + + .. note:: + + To update the message to add a :class:`~discord.ui.LayoutView`, you + must explicitly set the ``content``, ``embed``, ``embeds``, and + ``attachments`` parameters to either ``None`` or an empty array, as appropriate. + + .. versionchanged:: 2.6 + This now accepts :class:`~discord.ui.LayoutView` instances. poll: :class:`Poll` The poll to create when editing the message. @@ -574,7 +607,7 @@ class Interaction(Generic[ClientT]): # The message channel types should always match state = _InteractionMessageState(self, self._state) message = InteractionMessage(state=state, channel=self.channel, data=data) # type: ignore - if view and not view.is_finished(): + if view and not view.is_finished() and view.is_dispatchable(): self._state.store_view(view, message.id, interaction_id=self.id) return message @@ -898,6 +931,22 @@ 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, @@ -914,6 +963,25 @@ class InteractionResponse(Generic[ClientT]): silent: bool = False, delete_after: Optional[float] = None, poll: Poll = MISSING, + ) -> InteractionCallbackResponse[ClientT]: + ... + + async def send_message( + self, + content: Optional[Any] = None, + *, + embed: Embed = MISSING, + embeds: Sequence[Embed] = MISSING, + file: File = MISSING, + files: Sequence[File] = MISSING, + view: BaseView = 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]: """|coro| @@ -938,8 +1006,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 @@ -1048,6 +1119,19 @@ 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, *, @@ -1059,6 +1143,20 @@ class InteractionResponse(Generic[ClientT]): allowed_mentions: Optional[AllowedMentions] = MISSING, delete_after: Optional[float] = None, suppress_embeds: bool = MISSING, + ) -> Optional[InteractionCallbackResponse[ClientT]]: + ... + + 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[BaseView] = MISSING, + allowed_mentions: Optional[AllowedMentions] = MISSING, + delete_after: Optional[float] = None, + suppress_embeds: bool = MISSING, ) -> Optional[InteractionCallbackResponse[ClientT]]: """|coro| @@ -1085,9 +1183,18 @@ 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. + + .. note:: + + To update the message to add a :class:`~discord.ui.LayoutView`, you + must explicitly set the ``content``, ``embed``, ``embeds``, and + ``attachments`` parameters to either ``None`` or an empty array, as appropriate. + + .. versionchanged:: 2.6 + This now accepts :class:`~discord.ui.LayoutView` instances. allowed_mentions: Optional[:class:`~discord.AllowedMentions`] Controls the mentions being processed in this message. See :meth:`.Message.edit` for more information. @@ -1169,7 +1276,7 @@ class InteractionResponse(Generic[ClientT]): params=params, ) - if view and not view.is_finished(): + if view and not view.is_finished() and view.is_dispatchable(): state.store_view(view, message_id, interaction_id=original_interaction_id) self._response_type = InteractionResponseType.message_update @@ -1382,6 +1489,18 @@ 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, *, @@ -1393,6 +1512,20 @@ class InteractionMessage(Message): allowed_mentions: Optional[AllowedMentions] = None, delete_after: Optional[float] = None, poll: Poll = MISSING, + ) -> InteractionMessage: + ... + + async def edit( + self, + *, + content: Optional[str] = MISSING, + embeds: Sequence[Embed] = MISSING, + embed: Optional[Embed] = MISSING, + attachments: Sequence[Union[Attachment, File]] = MISSING, + view: Optional[BaseView] = MISSING, + allowed_mentions: Optional[AllowedMentions] = None, + delete_after: Optional[float] = None, + poll: Poll = MISSING, ) -> InteractionMessage: """|coro| @@ -1418,9 +1551,18 @@ 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. + + .. note:: + + To update the message to add a :class:`~discord.ui.LayoutView`, you + must explicitly set the ``content``, ``embed``, ``embeds``, and + ``attachments`` parameters to either ``None`` or an empty array, as appropriate. + + .. versionchanged:: 2.6 + This now accepts :class:`~discord.ui.LayoutView` instances. 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, @@ -1458,7 +1600,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 d6a26c7d0..d9fee394f 100644 --- a/discord/message.py +++ b/discord/message.py @@ -96,15 +96,14 @@ 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 from .role import Role - from .ui.view import View + from .ui.view import BaseView, View, LayoutView EmojiInputType = Union[Emoji, PartialEmoji, str] - MessageComponentType = Union[ActionRow, ActionRowChildComponentType] __all__ = ( @@ -489,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. """ @@ -533,7 +532,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) # type: ignore if component is not None: self.components.append(component) @@ -1306,6 +1305,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, @@ -1341,7 +1351,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| @@ -1391,10 +1401,19 @@ 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. + .. note:: + + If you want to update the message to have a :class:`~discord.ui.LayoutView`, you must + explicitly set to ``None`` or empty array, as required, the ``content``, ``embed``, + ``embeds``, and ``attachments`` parameters. + + .. versionchanged:: 2.6 + This now accepts :class:`~discord.ui.LayoutView` instances. + Raises ------- HTTPException @@ -1433,8 +1452,8 @@ class PartialMessage(Hashable): data = await self._state.http.edit_message(self.channel.id, self.id, params=params) message = Message(state=self._state, channel=self.channel, data=data) - if view and not view.is_finished(): - interaction: Optional[MessageInteraction] = getattr(self, 'interaction', None) + if view and not view.is_finished() and view.is_dispatchable(): + interaction: Optional[MessageInteractionMetadata] = getattr(self, 'interaction_metadata', None) if interaction is not None: self._state.store_view(view, self.id, interaction_id=interaction.id) else: @@ -1756,6 +1775,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 = ..., + 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, @@ -2856,7 +2907,7 @@ class Message(PartialMessage, Hashable): suppress: bool = ..., delete_after: Optional[float] = ..., allowed_mentions: Optional[AllowedMentions] = ..., - view: Optional[View] = ..., + view: Optional[BaseView] = ..., ) -> Message: ... @@ -2870,7 +2921,7 @@ class Message(PartialMessage, Hashable): suppress: bool = ..., delete_after: Optional[float] = ..., allowed_mentions: Optional[AllowedMentions] = ..., - view: Optional[View] = ..., + view: Optional[BaseView] = ..., ) -> Message: ... @@ -2884,7 +2935,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| @@ -2942,10 +2993,19 @@ class Message(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. + .. note:: + + If you want to update the message to have a :class:`~discord.ui.LayoutView`, you must + explicitly set to ``None`` or empty array, as required, the ``content``, ``embed``, + ``embeds``, and ``attachments`` parameters. + + .. versionchanged:: 2.6 + This now accepts :class:`~discord.ui.LayoutView` instances. + Raises ------- HTTPException @@ -2991,7 +3051,7 @@ class Message(PartialMessage, Hashable): data = await self._state.http.edit_message(self.channel.id, self.id, params=params) message = Message(state=self._state, channel=self.channel, data=data) - if view and not view.is_finished(): + if view and not view.is_finished() and view.is_dispatchable(): self._state.store_view(view, self.id) if delete_after is not None: diff --git a/discord/state.py b/discord/state.py index 0fbeadea2..37bd138a7 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 diff --git a/discord/types/components.py b/discord/types/components.py index 3b1295c13..a93abd127 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -24,24 +24,31 @@ 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 -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'] +SeparatorSize = 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] @@ -60,7 +67,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 +106,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 +125,78 @@ class SelectMenu(SelectComponent): default_values: NotRequired[List[SelectDefaultValues]] +class SectionComponent(ComponentBase): + type: Literal[9] + components: List[Union[TextComponent, ButtonComponent]] + accessory: Component + + +class TextComponent(ComponentBase): + type: Literal[10] + 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 + attachment_id: NotRequired[int] + flags: NotRequired[int] + + +class ThumbnailComponent(ComponentBase): + type: Literal[11] + media: UnfurledMediaItem + description: NotRequired[Optional[str]] + spoiler: NotRequired[bool] + + +class MediaGalleryItem(TypedDict): + media: UnfurledMediaItem + description: NotRequired[str] + spoiler: NotRequired[bool] + + +class MediaGalleryComponent(ComponentBase): + type: Literal[12] + items: List[MediaGalleryItem] + + +class FileComponent(ComponentBase): + type: Literal[13] + file: UnfurledMediaItem + spoiler: NotRequired[bool] + name: NotRequired[str] + size: NotRequired[int] + + +class SeparatorComponent(ComponentBase): + type: Literal[14] + divider: NotRequired[bool] + spacing: NotRequired[SeparatorSize] + + +class ContainerComponent(ComponentBase): + type: Literal[17] + accent_color: NotRequired[int] + spoiler: NotRequired[bool] + components: List[ContainerChildComponent] + + ActionRowChildComponent = Union[ButtonComponent, SelectMenu, TextInput] -Component = Union[ActionRow, ActionRowChildComponent] +ContainerChildComponent = Union[ + ActionRow, + TextComponent, + MediaGalleryComponent, + FileComponent, + SectionComponent, + SectionComponent, + ContainerComponent, + SeparatorComponent, + ThumbnailComponent, +] +Component = Union[ActionRowChildComponent, ContainerChildComponent] diff --git a/discord/types/message.py b/discord/types/message.py index ae38db46f..6c260d44d 100644 --- a/discord/types/message.py +++ b/discord/types/message.py @@ -33,7 +33,7 @@ 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 @@ -189,7 +189,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 +221,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] diff --git a/discord/ui/__init__.py b/discord/ui/__init__.py index c5a51777c..4d613f14f 100644 --- a/discord/ui/__init__.py +++ b/discord/ui/__init__.py @@ -16,3 +16,11 @@ from .button import * 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 * +from .action_row import * diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py new file mode 100644 index 000000000..9fbec833a --- /dev/null +++ b/discord/ui/action_row.py @@ -0,0 +1,583 @@ +""" +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 sys +from typing import ( + TYPE_CHECKING, + Any, + Callable, + ClassVar, + Coroutine, + Dict, + Generator, + List, + Literal, + Optional, + Sequence, + Type, + TypeVar, + Union, + overload, +) + +from .item import Item, ItemCallbackType +from .button import Button, button as _button +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, get as _utils_get + +if TYPE_CHECKING: + from typing_extensions import Self + + from .view import LayoutView + from .select import ( + BaseSelectT, + ValidDefaultValues, + MentionableSelectT, + ChannelSelectT, + RoleSelectT, + UserSelectT, + SelectT, + SelectCallbackDecorator, + ) + 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], row: ActionRow, item: Item[Any]) -> None: + self.callback: ItemCallbackType[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]): + r"""Represents a UI action row. + + This is a top-level layout component that can only be used on :class:`LayoutView` + and can contain :class:`Button`\s and :class:`Select`\s in it. + + 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 + ---------- + *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. + """ + + __action_row_children_items__: ClassVar[List[ItemCallbackType[Any]]] = [] + __discord_ui_action_row__: ClassVar[bool] = True + __discord_ui_update_view__: ClassVar[bool] = True + __item_repr_attributes__ = ( + 'row', + 'id', + ) + + def __init__( + self, + *children: Item[V], + row: Optional[int] = None, + id: Optional[int] = None, + ) -> None: + super().__init__() + self._weight: int = 0 + self._children: List[Item[V]] = self._init_children() + self._children.extend(children) + self._weight += sum(i.width for i in children) + + if self._weight > 5: + raise ValueError('maximum number of children exceeded') + + self.id = id + self.row = row + + def __init_subclass__(cls) -> None: + super().__init_subclass__() + + 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__'): + 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 __repr__(self) -> str: + return f'{super().__repr__()[:-1]} children={len(self._children)}>' + + 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) # type: ignore + item._parent = getattr(func, '__discord_ui_parent__', self) + setattr(self, func.__name__, item) + self._weight += item.width + children.append(item) + return children + + def _update_children_view(self, view: LayoutView) -> None: + for child in self._children: + 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 + # 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 + + @property + def children(self) -> List[Item[V]]: + """List[:class:`Item`]: The list of children attached to this action row.""" + return self._children.copy() + + def walk_children(self) -> Generator[Item[V], Any, None]: + """An iterator that recursively walks through all the children of this view + and it's children, if applicable. + + Yields + ------ + :class:`Item` + An item in the action row. + """ + + for child in self.children: + yield child + + def add_item(self, item: Item[Any]) -> Self: + """Adds an item to this row. + + 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__}') + + 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: + """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 + else: + if self._view and getattr(self._view, '__discord_ui_layout_view__', False): + self._view._total_children -= 1 + + return self + + def get_item(self, id: int, /) -> 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:`int` + The ID of the component. + + Returns + ------- + Optional[:class:`Item`] + The item found, or ``None``. + """ + return _utils_get(self.walk_children(), id=id) + + def clear_items(self) -> Self: + """Removes all items from the row. + + 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 + + def to_component_dict(self) -> Dict[str, Any]: + components = [] + + def key(item: Item) -> int: + if item._rendered_row is not None: + return item._rendered_row + if item._row is not None: + return item._row + return sys.maxsize + + for component in sorted(self.children, key=key): + components.append(component.to_component_dict()) + + base = { + 'type': self.type.value, + 'components': components, + } + if self.id is not None: + base['id'] = self.id + return base + + 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[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[Button[V]]) -> ItemCallbackType[Button[V]]: + 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 + + @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[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[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[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[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[MentionableSelectT]: + ... + + def select( + self, + *, + 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[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.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 + 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`]] | + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------+ + + 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``. + """ + + def decorator(func: ItemCallbackType[BaseSelectT]) -> ItemCallbackType[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 + + @classmethod + def from_component(cls, component: ActionRowComponent) -> ActionRow: + from .view import _component_to_item + + self = cls(id=component.id) + for cmp in component.children: + self.add_item(_component_to_item(cmp, self)) + return self diff --git a/discord/ui/button.py b/discord/ui/button.py index 43bd3a8b0..93ec4fa4b 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -42,11 +42,11 @@ __all__ = ( if TYPE_CHECKING: from typing_extensions import Self - from .view import View + from .view import BaseView 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]): @@ -82,6 +82,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, ...] = ( @@ -92,6 +96,7 @@ class Button(Item[V]): 'emoji', 'row', 'sku_id', + 'id', ) def __init__( @@ -105,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): @@ -143,8 +149,10 @@ class Button(Item[V]): style=style, emoji=emoji, sku_id=sku_id, + id=id, ) self.row = row + self.id = id @property def style(self) -> ButtonStyle: @@ -242,6 +250,7 @@ class Button(Item[V]): emoji=button.emoji, row=None, sku_id=button.sku_id, + id=button.id, ) @property @@ -259,6 +268,9 @@ class Button(Item[V]): return self.url is not None return super().is_persistent() + def _can_be_dynamic(self) -> bool: + return True + def _refresh_component(self, button: ButtonComponent) -> None: self._underlying = button @@ -271,7 +283,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 @@ -308,9 +321,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') @@ -324,6 +341,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 new file mode 100644 index 000000000..3767c6eab --- /dev/null +++ b/discord/ui/container.py @@ -0,0 +1,401 @@ +""" +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 copy +import os +import sys +from typing import ( + TYPE_CHECKING, + Any, + ClassVar, + Coroutine, + Dict, + Generator, + List, + Literal, + Optional, + TypeVar, + Union, +) + +from .item import Item, ItemCallbackType +from .view import _component_to_item, LayoutView +from ..enums import ComponentType +from ..utils import MISSING, get as _utils_get +from ..colour import Colour, Color + +if TYPE_CHECKING: + from typing_extensions import Self + + from ..components import Container as ContainerComponent + from ..interactions import Interaction + +V = TypeVar('V', bound='LayoutView', covariant=True) + +__all__ = ('Container',) + + +class _ContainerCallback: + __slots__ = ('container', 'callback', 'item') + + 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 + + def __call__(self, interaction: Interaction) -> Coroutine[Any, Any, Any]: + return self.callback(self.container, interaction, self.item) + + +class Container(Item[V]): + r"""Represents a UI container. + + This is a top-level layout component that can only be used on :class:`LayoutView` + and can contain :class:`ActionRow`\s, :class:`TextDisplay`\s, :class:`Section`\s, + :class:`MediaGallery`\s, and :class:`File`\s in it. + + This can be inherited. + + + .. 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: :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`]] + The color of the container. Defaults to ``None``. + spoiler: :class:`bool` + Whether to flag this container as a spoiler. Defaults + to ``False``. + row: Optional[:class:`int`] + The relative row this container belongs to. By default + items are arranged automatically into those rows. If you'd + like to control the relative positioning of the row then + passing an index is advised. For example, row=1 will show + up before row=2. Defaults to ``None``, which is automatic + ordering. The row number must be between 0 and 39 (i.e. zero indexed) + id: Optional[:class:`int`] + The ID of this component. This must be unique across the view. + """ + + __container_children_items__: ClassVar[Dict[str, Union[ItemCallbackType[Any], Item[Any]]]] = {} + __discord_ui_update_view__: ClassVar[bool] = True + __discord_ui_container__: ClassVar[bool] = True + __item_repr_attributes__ = ( + 'accent_colour', + 'spoiler', + 'row', + 'id', + ) + + def __init__( + self, + *children: Item[V], + accent_colour: Optional[Union[Colour, int]] = None, + accent_color: Optional[Union[Color, int]] = None, + spoiler: bool = False, + row: Optional[int] = None, + id: Optional[int] = None, + ) -> None: + super().__init__() + self._children: List[Item[V]] = self._init_children() + + if children is not MISSING: + for child in children: + self.add_item(child) + + self.spoiler: bool = spoiler + self._colour = accent_colour if accent_colour is not None else accent_color + + self.row = row + self.id = id + + def __repr__(self) -> str: + return f'<{super().__repr__()[:-1]} children={len(self._children)}>' + + def _init_children(self) -> List[Item[Any]]: + children = [] + parents = {} + + for name, raw in self.__container_children_items__.items(): + if isinstance(raw, Item): + item = copy.deepcopy(raw) + item._parent = self + if getattr(item, '__discord_ui_action_row__', False) and item.is_dispatchable(): + if item.is_dispatchable(): + self.__dispatchable.extend(item._children) # type: ignore + if getattr(item, '__discord_ui_section__', False) and item.accessory.is_dispatchable(): # type: ignore + if item.accessory._provided_custom_id is False: # type: ignore + item.accessory.custom_id = os.urandom(16).hex() # type: ignore + + 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 + 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') + 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. + + return children + + def __init_subclass__(cls) -> None: + super().__init_subclass__() + + 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): + children[name] = member + if hasattr(member, '__discord_ui_model_type__') and getattr(member, '__discord_ui_parent__', None): + children[name] = copy.copy(member) + + cls.__container_children_items__ = children + + def _update_children_view(self, view) -> None: + for child in self._children: + child._view = view + if getattr(child, '__discord_ui_update_view__', False): + # if the item is an action row which child's view can be updated, then update it + child._update_children_view(view) # type: ignore + + @property + 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[V]]) -> None: + self._children = value + + @property + 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[Union[Colour, int]]) -> None: + if value is not None and not isinstance(value, (int, Colour)): + raise TypeError(f'expected an int, or Colour, not {value.__class__.__name__!r}') + + self._colour = value + + accent_color = accent_colour + + @property + def type(self) -> Literal[ComponentType.container]: + return ComponentType.container + + @property + def width(self): + return 5 + + def _is_v2(self) -> bool: + return True + + def to_components(self) -> List[Dict[str, Any]]: + components = [] + + def key(item: Item) -> int: + if item._rendered_row is not None: + return item._rendered_row + if item._row is not None: + return item._row + return sys.maxsize + + for i in sorted(self._children, key=key): + components.append(i.to_component_dict()) + return components + + 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': colour, + 'spoiler': self.spoiler, + 'components': components, + } + if self.id is not None: + base['id'] = self.id + return base + + @classmethod + def from_component(cls, component: ContainerComponent) -> Self: + self = cls( + accent_colour=component.accent_colour, + spoiler=component.spoiler, + id=component.id, + ) + self._children = [_component_to_item(cmp, self) for cmp in component.children] + return self + + def walk_children(self) -> Generator[Item[V], None, None]: + """An iterator that recursively walks through all the children of this container + 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. + + 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. + """ + if not isinstance(item, Item): + raise TypeError(f'expected Item not {item.__class__.__name__}') + + self._children.append(item) + is_layout_view = self._view and getattr(self._view, '__discord_ui_layout_view__', False) + + if getattr(item, '__discord_ui_update_view__', False): + item._update_children_view(self.view) # type: ignore + + if is_layout_view: + 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 + 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 + 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(self, id: int, /) -> 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:`int` + The ID of the component. + + Returns + ------- + Optional[:class:`Item`] + The item found, or ``None``. + """ + return _utils_get(self.walk_children(), id=id) + + def clear_items(self) -> Self: + """Removes all the items from the container. + + 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 -= sum(1 for _ in self.walk_children()) + self._children.clear() + return self diff --git a/discord/ui/dynamic.py b/discord/ui/dynamic.py index 0b65e90f3..1c0efae69 100644 --- a/discord/ui/dynamic.py +++ b/discord/ui/dynamic.py @@ -38,14 +38,12 @@ if TYPE_CHECKING: from ..interactions import Interaction from ..components import Component from ..enums import ComponentType - from .view import View - - V = TypeVar('V', bound='View', covariant=True, default=View) + from .view import View, LayoutView else: - V = TypeVar('V', bound='View', covariant=True) + View = LayoutView = Any -class DynamicItem(Generic[BaseT], Item['View']): +class DynamicItem(Generic[BaseT], Item[Union[View, LayoutView]]): """Represents an item with a dynamic ``custom_id`` that can be used to store state within that ``custom_id``. @@ -57,9 +55,10 @@ class DynamicItem(Generic[BaseT], Item['View']): 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 @@ -110,6 +109,9 @@ class DynamicItem(Generic[BaseT], Item['View']): if not self.item.is_dispatchable(): raise TypeError('item must be dispatchable, e.g. not a URL button') + if not self.item._can_be_dynamic(): + raise TypeError(f'{self.item.__class__.__name__} cannot be set as a dynamic item') + if not self.template.match(self.custom_id): raise ValueError(f'item custom_id {self.custom_id!r} must match the template {self.template.pattern!r}') diff --git a/discord/ui/file.py b/discord/ui/file.py new file mode 100644 index 000000000..630258cf4 --- /dev/null +++ b/discord/ui/file.py @@ -0,0 +1,152 @@ +""" +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 LayoutView + +V = TypeVar('V', bound='LayoutView', covariant=True) + +__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`] + 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://`` format. + spoiler: :class:`bool` + Whether to flag this file as a spoiler. Defaults to ``False``. + row: Optional[:class:`int`] + The relative row this file component belongs to. By default + items are arranged automatically into those rows. If you'd + like to control the relative positioning of the row then + passing an index is advised. For example, row=1 will show + up before row=2. Defaults to ``None``, which is automatic + ordering. The row number must be between 0 and 39 (i.e. zero indexed) + id: Optional[:class:`int`] + The ID of this component. This must be unique across the view. + """ + + __item_repr_attributes__ = ( + 'media', + 'spoiler', + 'row', + 'id', + ) + + def __init__( + self, + media: Union[str, UnfurledMediaItem], + *, + spoiler: bool = False, + row: Optional[int] = None, + id: Optional[int] = None, + ) -> None: + super().__init__() + self._underlying = FileComponent._raw_construct( + media=UnfurledMediaItem(media) if isinstance(media, str) else media, + spoiler=spoiler, + id=id, + ) + + self.row = row + self.id = id + + 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, + id=component.id, + ) diff --git a/discord/ui/item.py b/discord/ui/item.py index 1ee549283..3315c3667 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -37,12 +37,12 @@ __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) -ItemCallbackType = Callable[[V, Interaction[Any], I], Coroutine[Any, Any, Any]] +V = TypeVar('V', bound='BaseView', covariant=True) +ItemCallbackType = Callable[[Any, Interaction[Any], I], Coroutine[Any, Any, Any]] class Item(Generic[V]): @@ -53,11 +53,19 @@ 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 """ - __item_repr_attributes__: Tuple[str, ...] = ('row',) + __item_repr_attributes__: Tuple[str, ...] = ('row', 'id') def __init__(self): self._view: Optional[V] = None @@ -70,6 +78,9 @@ 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[int] = None + self._max_row: int = 5 if not self._is_v2() else 40 + self._parent: Optional[Item] = None def to_component_dict(self) -> Dict[str, Any]: raise NotImplementedError @@ -80,6 +91,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() @@ -92,7 +106,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__) @@ -106,10 +122,13 @@ 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(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: @@ -120,6 +139,30 @@ class Item(Generic[V]): """Optional[:class:`View`]: The underlying view for this item.""" return self._view + @property + def id(self) -> Optional[int]: + """Optional[:class:`int`]: The ID of this component.""" + return self._id + + @id.setter + 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 and self._parent: + can_run = await self._parent._run_checks(interaction) + + return can_run + + def _can_be_dynamic(self) -> bool: + # if an item can be dynamic then it must override this, this is mainly used + # by DynamicItem's so a user cannot set, for example, a Container with a dispatchable + # button as a dynamic item, and cause errors where Container can't be dispatched + # or lost interactions + return False + async def callback(self, interaction: Interaction[ClientT]) -> Any: """|coro| diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py new file mode 100644 index 000000000..166d99b22 --- /dev/null +++ b/discord/ui/media_gallery.py @@ -0,0 +1,263 @@ +""" +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, Union + +from .item import Item +from ..enums import ComponentType +from ..components import ( + MediaGalleryItem, + MediaGalleryComponent, + UnfurledMediaItem, +) + +if TYPE_CHECKING: + from typing_extensions import Self + + from .view import LayoutView + +V = TypeVar('V', bound='LayoutView', covariant=True) + +__all__ = ('MediaGallery',) + + +class MediaGallery(Item[V]): + r"""Represents a UI media gallery. + + 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 + + Parameters + ---------- + *items: :class:`.MediaGalleryItem` + The initial items of this gallery. + row: Optional[:class:`int`] + The relative row this media gallery belongs to. By default + items are arranged automatically into those rows. If you'd + like to control the relative positioning of the row then + passing an index is advised. For example, row=1 will show + up before row=2. Defaults to ``None``, which is automatic + ordering. The row number must be between 0 and 39 (i.e. zero indexed) + id: Optional[:class:`int`] + The ID of this component. This must be unique across the view. + """ + + __item_repr_attributes__ = ( + 'items', + 'row', + 'id', + ) + + def __init__( + self, + *items: MediaGalleryItem, + row: Optional[int] = None, + id: Optional[int] = None, + ) -> None: + super().__init__() + + self._underlying = MediaGalleryComponent._raw_construct( + items=list(items), + id=id, + ) + + self.row = row + self.id = id + + def __repr__(self) -> str: + return f'<{super().__repr__()[:-1]} items={len(self._underlying.items)}>' + + @property + def items(self) -> List[MediaGalleryItem]: + """List[:class:`.MediaGalleryItem`]: Returns a read-only list of this gallery's items.""" + 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, + *, + 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` + 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 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 + ---------- + 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). + """ + + 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 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 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( + *component.items, + id=component.id, + ) diff --git a/discord/ui/section.py b/discord/ui/section.py new file mode 100644 index 000000000..830efb88a --- /dev/null +++ b/discord/ui/section.py @@ -0,0 +1,261 @@ +""" +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 sys +from typing import TYPE_CHECKING, Any, Dict, Generator, List, Literal, Optional, TypeVar, Union, ClassVar + +from .item import Item +from .text_display import TextDisplay +from ..enums import ComponentType +from ..utils import MISSING, get as _utils_get + +if TYPE_CHECKING: + from typing_extensions import Self + + from .view import LayoutView + from ..components import SectionComponent + +V = TypeVar('V', bound='LayoutView', covariant=True) + +__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 + ---------- + *children: Union[:class:`str`, :class:`TextDisplay`] + The text displays of this section. Up to 3. + accessory: :class:`Item` + The section accessory. + row: Optional[:class:`int`] + The relative row this section belongs to. By default + items are arranged automatically into those rows. If you'd + like to control the relative positioning of the row then + passing an index is advised. For example, row=1 will show + up before row=2. Defaults to ``None``, which is automatic + ordering. The row number must be between 0 and 39 (i.e. zero indexed) + id: Optional[:class:`int`] + The ID of this component. This must be unique across the view. + """ + + __item_repr_attributes__ = ( + 'accessory', + 'row', + 'id', + ) + __discord_ui_section__: ClassVar[bool] = True + __discord_ui_update_view__: ClassVar[bool] = True + + __slots__ = ( + '_children', + 'accessory', + ) + + def __init__( + self, + *children: Union[Item[V], str], + accessory: Item[V], + row: Optional[int] = None, + id: Optional[int] = None, + ) -> None: + super().__init__() + 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[V] = accessory + self.row = row + self.id = id + + def __repr__(self) -> str: + return f'<{super().__repr__()[:-1]} children={len(self._children)}' + + @property + def type(self) -> Literal[ComponentType.section]: + return ComponentType.section + + @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 + + def _is_v2(self) -> bool: + return True + + def walk_children(self) -> Generator[Item[V], None, None]: + """An iterator that recursively walks through all the children of this section. + and it's children, if applicable. + + 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 + + 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 + chaining. + + Parameters + ---------- + item: Union[:class:`str`, :class:`Item`] + The item to append, if it is a string it automatically wrapped around + :class:`TextDisplay`. + + Raises + ------ + TypeError + An :class:`Item` or :class:`str` 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, (Item, str)): + raise TypeError(f'expected Item or str not {item.__class__.__name__}') + + item = item if isinstance(item, Item) else TextDisplay(item) + item._view = self.view + item._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: + """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 + else: + if self._view and getattr(self._view, '__discord_ui_layout_view__', False): + self._view._total_children -= 1 + + return self + + def get_item(self, id: int, /) -> 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:`int` + The ID of the component. + + Returns + ------- + Optional[:class:`Item`] + The item found, or ``None``. + """ + return _utils_get(self.walk_children(), id=id) + + def clear_items(self) -> Self: + """Removes all the items from the section. + + 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) # we don't count the accessory because it is required + + self._children.clear() + return self + + @classmethod + def from_component(cls, component: SectionComponent) -> Self: + from .view import _component_to_item + + # using MISSING as accessory so we can create the new one with the parent set + self = cls(id=component.id, accessory=MISSING) + self.accessory = _component_to_item(component.accessory, self) + self.id = component.id + self._children = [_component_to_item(c, self) for c in component.components] + + return self + + def to_components(self) -> List[Dict[str, Any]]: + components = [] + + def key(item: Item) -> int: + if item._rendered_row is not None: + return item._rendered_row + if item._row is not None: + return item._row + return sys.maxsize + + for component in sorted(self._children, key=key): + components.append(component.to_component_dict()) + return components + + def to_component_dict(self) -> Dict[str, Any]: + data = { + 'type': self.type.value, + 'components': self.to_components(), + '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 1ef085cc5..21aeb66f0 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 ..types.components import SelectMenu as SelectMenuPayload from ..types.interactions import SelectMessageComponentInteractionData from ..app_commands import AppCommandChannel, AppCommandThread @@ -101,14 +101,14 @@ 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]') 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, @@ -216,6 +216,7 @@ class BaseSelect(Item[V]): 'min_values', 'max_values', 'disabled', + 'id', ) __component_attributes__: Tuple[str, ...] = ( 'custom_id', @@ -223,6 +224,7 @@ class BaseSelect(Item[V]): 'min_values', 'max_values', 'disabled', + 'id', ) def __init__( @@ -238,6 +240,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 @@ -255,9 +258,11 @@ 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 + self.id = id self._values: List[PossibleValue] = [] @property @@ -357,6 +362,9 @@ class BaseSelect(Item[V]): kwrgs = {key: getattr(component, key) for key in constructor.__component_attributes__} return constructor(**kwrgs) + def _can_be_dynamic(self) -> bool: + return True + class Select(BaseSelect[V]): """Represents a UI select menu with a list of custom options. This is represented @@ -390,6 +398,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',) @@ -404,6 +416,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, @@ -414,6 +427,7 @@ class Select(BaseSelect[V]): disabled=disabled, options=options, row=row, + id=id, ) @property @@ -545,6 +559,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',) @@ -559,6 +577,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, @@ -569,6 +588,7 @@ class UserSelect(BaseSelect[V]): disabled=disabled, row=row, default_values=_handle_select_defaults(default_values, self.type), + id=id, ) @property @@ -637,6 +657,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',) @@ -651,6 +675,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, @@ -661,6 +686,7 @@ class RoleSelect(BaseSelect[V]): disabled=disabled, row=row, default_values=_handle_select_defaults(default_values, self.type), + id=id, ) @property @@ -725,6 +751,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',) @@ -739,6 +769,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, @@ -749,6 +780,7 @@ class MentionableSelect(BaseSelect[V]): disabled=disabled, row=row, default_values=_handle_select_defaults(default_values, self.type), + id=id, ) @property @@ -819,6 +851,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__ + ( @@ -837,6 +873,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, @@ -848,6 +885,7 @@ class ChannelSelect(BaseSelect[V]): row=row, channel_types=channel_types, default_values=_handle_select_defaults(default_values, self.type), + id=id, ) @property @@ -899,7 +937,8 @@ def select( max_values: int = ..., disabled: bool = ..., row: Optional[int] = ..., -) -> SelectCallbackDecorator[V, SelectT]: + id: Optional[int] = ..., +) -> SelectCallbackDecorator[SelectT]: ... @@ -916,7 +955,8 @@ def select( disabled: bool = ..., default_values: Sequence[ValidDefaultValues] = ..., row: Optional[int] = ..., -) -> SelectCallbackDecorator[V, UserSelectT]: + id: Optional[int] = ..., +) -> SelectCallbackDecorator[UserSelectT]: ... @@ -933,7 +973,8 @@ def select( disabled: bool = ..., default_values: Sequence[ValidDefaultValues] = ..., row: Optional[int] = ..., -) -> SelectCallbackDecorator[V, RoleSelectT]: + id: Optional[int] = ..., +) -> SelectCallbackDecorator[RoleSelectT]: ... @@ -950,7 +991,8 @@ def select( disabled: bool = ..., default_values: Sequence[ValidDefaultValues] = ..., row: Optional[int] = ..., -) -> SelectCallbackDecorator[V, ChannelSelectT]: + id: Optional[int] = ..., +) -> SelectCallbackDecorator[ChannelSelectT]: ... @@ -967,7 +1009,8 @@ def select( disabled: bool = ..., default_values: Sequence[ValidDefaultValues] = ..., row: Optional[int] = ..., -) -> SelectCallbackDecorator[V, MentionableSelectT]: + id: Optional[int] = ..., +) -> SelectCallbackDecorator[MentionableSelectT]: ... @@ -983,7 +1026,8 @@ def select( disabled: bool = False, default_values: Sequence[ValidDefaultValues] = MISSING, row: Optional[int] = None, -) -> SelectCallbackDecorator[V, BaseSelectT]: + id: Optional[int] = None, +) -> SelectCallbackDecorator[BaseSelectT]: """A decorator that attaches a select menu to a component. The function being decorated should have three parameters, ``self`` representing @@ -1062,9 +1106,13 @@ 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]: + 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) @@ -1080,6 +1128,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 new file mode 100644 index 000000000..f90fbaa4b --- /dev/null +++ b/discord/ui/separator.py @@ -0,0 +1,134 @@ +""" +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 SeparatorSpacing, ComponentType + +if TYPE_CHECKING: + from typing_extensions import Self + + from .view import LayoutView + +V = TypeVar('V', bound='LayoutView', covariant=True) + +__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 + ---------- + 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:`.SeparatorSpacing` + The spacing of this separator. + row: Optional[:class:`int`] + The relative row this separator belongs to. By default + items are arranged automatically into those rows. If you'd + like to control the relative positioning of the row then + passing an index is advised. For example, row=1 will show + up before row=2. Defaults to ``None``, which is automatic + ordering. The row number must be between 0 and 39 (i.e. zero indexed) + id: Optional[:class:`int`] + The ID of this component. This must be unique across the view. + """ + + __item_repr_attributes__ = ( + 'visible', + 'spacing', + 'row', + 'id', + ) + + def __init__( + self, + *, + visible: bool = True, + spacing: SeparatorSpacing = SeparatorSpacing.small, + row: Optional[int] = None, + id: Optional[int] = None, + ) -> None: + super().__init__() + self._underlying = SeparatorComponent._raw_construct( + spacing=spacing, + visible=visible, + id=id, + ) + + self.row = row + self.id = id + + 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) -> SeparatorSpacing: + """:class:`.SeparatorSpacing`: The spacing of this separator.""" + return self._underlying.spacing + + @spacing.setter + def spacing(self, value: SeparatorSpacing) -> 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() + + @classmethod + def from_component(cls, component: SeparatorComponent) -> Self: + return cls( + visible=component.visible, + spacing=component.spacing, + id=component.id, + ) diff --git a/discord/ui/text_display.py b/discord/ui/text_display.py new file mode 100644 index 000000000..9ba7f294e --- /dev/null +++ b/discord/ui/text_display.py @@ -0,0 +1,96 @@ +""" +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 TextDisplay as TextDisplayComponent +from ..enums import ComponentType + +if TYPE_CHECKING: + from typing_extensions import Self + + from .view import LayoutView + +V = TypeVar('V', bound='LayoutView', covariant=True) + +__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 + ---------- + content: :class:`str` + The content of this text display. Up to 4000 characters. + row: Optional[:class:`int`] + The relative row this text display belongs to. By default + items are arranged automatically into those rows. If you'd + like to control the relative positioning of the row then + passing an index is advised. For example, row=1 will show + up before row=2. Defaults to ``None``, which is automatic + ordering. The row number must be between 0 and 39 (i.e. zero indexed) + id: Optional[:class:`int`] + The ID of this component. This must be unique across the view. + """ + + def __init__(self, content: str, *, row: Optional[int] = None, id: Optional[int] = None) -> None: + super().__init__() + self.content: str = content + + self.row = row + self.id = id + + def to_component_dict(self): + base = { + 'type': self.type.value, + 'content': self.content, + } + if self.id is not None: + base['id'] = self.id + return base + + @property + def width(self): + return 5 + + @property + def type(self) -> Literal[ComponentType.text_display]: + return ComponentType.text_display + + def _is_v2(self) -> bool: + return True + + @classmethod + 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..218d7c4d0 100644 --- a/discord/ui/text_input.py +++ b/discord/ui/text_input.py @@ -92,12 +92,17 @@ 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, ...] = ( 'label', 'placeholder', 'required', + 'id', ) def __init__( @@ -112,6 +117,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 +135,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 +249,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 new file mode 100644 index 000000000..e0fbd3a64 --- /dev/null +++ b/discord/ui/thumbnail.py @@ -0,0 +1,124 @@ +""" +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, Union + +from .item import Item +from ..enums import ComponentType +from ..components import UnfurledMediaItem + +if TYPE_CHECKING: + from typing_extensions import Self + + from .view import LayoutView + from ..components import ThumbnailComponent + +V = TypeVar('V', bound='LayoutView', covariant=True) + +__all__ = ('Thumbnail',) + + +class Thumbnail(Item[V]): + """Represents a UI Thumbnail. + + .. versionadded:: 2.6 + + Parameters + ---------- + media: Union[:class:`str`, :class:`discord.UnfurledMediaItem`] + 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. Up to 256 characters. Defaults to ``None``. + spoiler: :class:`bool` + Whether to flag this thumbnail as a spoiler. Defaults to ``False``. + row: Optional[:class:`int`] + The relative row this thumbnail belongs to. By default + items are arranged automatically into those rows. If you'd + like to control the relative positioning of the row then + passing an index is advised. For example, row=1 will show + up before row=2. Defaults to ``None``, which is automatic + ordering. The row number must be between 0 and 39 (i.e. zero indexed) + id: Optional[:class:`int`] + The ID of this component. This must be unique across the view. + """ + + __item_repr_attributes__ = ( + 'media', + 'description', + 'spoiler', + 'row', + 'id', + ) + + def __init__( + self, + media: Union[str, UnfurledMediaItem], + *, + description: Optional[str] = None, + spoiler: bool = False, + row: Optional[int] = None, + id: Optional[int] = None, + ) -> None: + super().__init__() + + self.media: UnfurledMediaItem = UnfurledMediaItem(media) if isinstance(media, str) else media + self.description: Optional[str] = description + self.spoiler: bool = spoiler + + self.row = row + self.id = id + + @property + def width(self): + return 5 + + @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]: + 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: + return cls( + media=component.media.url, + description=component.description, + spoiler=component.spoiler, + id=component.id, + ) diff --git a/discord/ui/view.py b/discord/ui/view.py index f27b71eeb..504d88f13 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -23,7 +23,23 @@ 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, + Generator, + Iterator, + List, + Optional, + Sequence, + TYPE_CHECKING, + Tuple, + Type, + Union, +) from functools import partial from itertools import groupby @@ -32,20 +48,37 @@ import logging import sys import time import os +import copy + from .item import Item, ItemCallbackType from .select import Select from .dynamic import DynamicItem from ..components import ( Component, ActionRow as ActionRowComponent, + MediaGalleryItem, + SelectDefaultValue, + UnfurledMediaItem, _component_factory, Button as ButtonComponent, SelectMenu as SelectComponent, + SectionComponent, + TextDisplay as TextDisplayComponent, + MediaGalleryComponent, + FileComponent, + SeparatorComponent, + ThumbnailComponent, + SelectOption, + Container as ContainerComponent, ) +from ..utils import get as _utils_get, _get_as_snowflake, find as _utils_find +from ..enums import SeparatorSpacing, TextStyle, try_enum, ButtonStyle +from ..emoji import PartialEmoji # fmt: off __all__ = ( 'View', + 'LayoutView', ) # fmt: on @@ -56,11 +89,13 @@ 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, Component as ComponentPayload from ..types.interactions import ModalSubmitComponentInteractionData as ModalSubmitComponentInteractionDataPayload from ..state import ConnectionState from .modal import Modal + ItemLike = Union[ItemCallbackType[Any], Item[Any]] + _log = logging.getLogger(__name__) @@ -73,17 +108,184 @@ def _walk_all_components(components: List[Component]) -> Iterator[Component]: yield item -def _component_to_item(component: Component) -> Item: - if isinstance(component, ButtonComponent): +def _component_to_item(component: Component, parent: Optional[Item] = None) -> Item: + if isinstance(component, ActionRowComponent): + from .action_row import ActionRow + + item = ActionRow.from_component(component) + elif isinstance(component, ButtonComponent): from .button import Button - return Button.from_component(component) - if isinstance(component, SelectComponent): + item = Button.from_component(component) + elif isinstance(component, SelectComponent): from .select import BaseSelect - return BaseSelect.from_component(component) + item = BaseSelect.from_component(component) + elif isinstance(component, SectionComponent): + from .section import Section + + item = Section.from_component(component) + elif isinstance(component, TextDisplayComponent): + from .text_display import TextDisplay + + item = TextDisplay.from_component(component) + elif isinstance(component, MediaGalleryComponent): + from .media_gallery import MediaGallery + + item = MediaGallery.from_component(component) + elif isinstance(component, FileComponent): + from .file import File + + item = File.from_component(component) + elif isinstance(component, SeparatorComponent): + from .separator import Separator - return Item.from_component(component) + item = Separator.from_component(component) + elif isinstance(component, ThumbnailComponent): + from .thumbnail import Thumbnail + + item = Thumbnail.from_component(component) + elif isinstance(component, ContainerComponent): + from .container import Container + + item = Container.from_component(component) + else: + item = Item.from_component(component) + + item._parent = parent + return item + + +def _component_data_to_item(data: ComponentPayload, parent: Optional[Item] = None) -> Item: + if data['type'] == 1: + from .action_row import ActionRow + + item = ActionRow( + *(_component_data_to_item(c) for c in data['components']), + id=data.get('id'), + ) + elif data['type'] == 2: + from .button import Button + + emoji = data.get('emoji') + + item = Button( + style=try_enum(ButtonStyle, data['style']), + custom_id=data.get('custom_id'), + url=data.get('url'), + disabled=data.get('disabled', False), + emoji=PartialEmoji.from_dict(emoji) if emoji else None, + label=data.get('label'), + sku_id=_get_as_snowflake(data, 'sku_id'), + ) + elif data['type'] == 3: + from .select import Select + + item = Select( + custom_id=data['custom_id'], + placeholder=data.get('placeholder'), + min_values=data.get('min_values', 1), + max_values=data.get('max_values', 1), + disabled=data.get('disabled', False), + id=data.get('id'), + options=[SelectOption.from_dict(o) for o in data.get('options', [])], + ) + elif data['type'] == 4: + from .text_input import TextInput + + item = TextInput( + label=data['label'], + style=try_enum(TextStyle, data['style']), + custom_id=data['custom_id'], + placeholder=data.get('placeholder'), + default=data.get('value'), + required=data.get('required', True), + min_length=data.get('min_length'), + max_length=data.get('max_length'), + id=data.get('id'), + ) + elif data['type'] in (5, 6, 7, 8): + from .select import ( + UserSelect, + RoleSelect, + MentionableSelect, + ChannelSelect, + ) + + cls_map: Dict[int, Type[Union[UserSelect, RoleSelect, MentionableSelect, ChannelSelect]]] = { + 5: UserSelect, + 6: RoleSelect, + 7: MentionableSelect, + 8: ChannelSelect, + } + + item = cls_map[data['type']]( + custom_id=data['custom_id'], # type: ignore # will always be present in this point + placeholder=data.get('placeholder'), + min_values=data.get('min_values', 1), + max_values=data.get('max_values', 1), + disabled=data.get('disabled', False), + default_values=[SelectDefaultValue.from_dict(v) for v in data.get('default_values', [])], + id=data.get('id'), + ) + elif data['type'] == 9: + from .section import Section + + item = Section( + *(_component_data_to_item(c) for c in data['components']), + accessory=_component_data_to_item(data['accessory']), + id=data.get('id'), + ) + elif data['type'] == 10: + from .text_display import TextDisplay + + item = TextDisplay(data['content'], id=data.get('id')) + elif data['type'] == 11: + from .thumbnail import Thumbnail + + item = Thumbnail( + UnfurledMediaItem._from_data(data['media'], None), + description=data.get('description'), + spoiler=data.get('spoiler', False), + id=data.get('id'), + ) + elif data['type'] == 12: + from .media_gallery import MediaGallery + + item = MediaGallery( + *(MediaGalleryItem._from_data(m, None) for m in data['items']), + id=data.get('id'), + ) + elif data['type'] == 13: + from .file import File + + item = File( + UnfurledMediaItem._from_data(data['file'], None), + spoiler=data.get('spoiler', False), + id=data.get('id'), + ) + elif data['type'] == 14: + from .separator import Separator + + item = Separator( + visible=data.get('divider', True), + spacing=try_enum(SeparatorSpacing, data.get('spacing', 1)), + id=data.get('id'), + ) + elif data['type'] == 17: + from .container import Container + + item = Container( + *(_component_data_to_item(c) for c in data['components']), + accent_colour=data.get('accent_color'), + spoiler=data.get('spoiler', False), + id=data.get('type'), + ) + else: + raise ValueError(f'invalid item with type {data["type"]} provided') + + item._parent = parent + return item class _ViewWeights: @@ -133,73 +335,68 @@ class _ViewWeights: class _ViewCallback: __slots__ = ('view', 'callback', 'item') - def __init__(self, callback: ItemCallbackType[Any, Any], view: View, item: Item[View]) -> None: - self.callback: ItemCallbackType[Any, Any] = callback - self.view: View = view - self.item: Item[View] = item + 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 def __call__(self, interaction: Interaction) -> Coroutine[Any, Any, Any]: return self.callback(self.view, interaction, self.item) -class View: - """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 - __view_children_items__: ClassVar[List[ItemCallbackType[Any, Any]]] = [] + __view_children_items__: ClassVar[Dict[str, ItemLike]] = {} - 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 - if isinstance(item, Select): - item.options = [option.copy() for option in item.options] - setattr(self, func.__name__, item) - children.append(item) - return children - - 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() + self._total_children: int = sum(1 for _ in self.walk_children()) + + def _is_v2(self) -> bool: + return False def __repr__(self) -> str: return f'<{self.__class__.__name__} timeout={self.timeout} children={len(self._children)}>' + def _init_children(self) -> List[Item[Self]]: + children = [] + parents = {} + + for name, raw in self.__view_children_items__.items(): + if isinstance(raw, Item): + 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(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 + item._view = self + if isinstance(item, Select): + item.options = [option.copy() for option in item.options] + setattr(self, raw.__name__, item) + parent = getattr(raw, '__discord_ui_parent__', None) + if parent: + parents.get(parent, parent)._children.append(item) + continue + children.append(item) + + return children + async def __timeout_task_impl(self) -> None: while True: # Guard just in case someone changes the value of the timeout at runtime @@ -218,29 +415,16 @@ class View: await asyncio.sleep(self.__timeout_expiry - now) def is_dispatchable(self) -> bool: - # this is used by webhooks to check whether a view requires a state attached - # or not, this simply is, whether a view has a component other than a url button - return any(item.is_dispatchable() for item in self.children) + # checks whether any interactable items (buttons or selects) are present + # in this view, and check whether this requires a state attached in case + # of webhooks and if the view should be stored in the view store + return any(item.is_dispatchable() for item in self.walk_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) - 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 + def to_components(self) -> List[Dict[str, Any]]: + return NotImplemented def _refresh_timeout(self) -> None: if self.__timeout: @@ -271,13 +455,22 @@ class View: 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 and separate types from those in the ``discord.ui`` namespace. In order to modify and edit message components they must be - converted into a :class:`View` first. + converted into a :class:`View` or :class:`LayoutView` first. + + If the message has any v2 component, then you must use + :class:`LayoutView` in order for them to be converted into + their respective items. + + This method should be called on the respective class (or subclass), so + if you want to convert v2 items, you should call :meth:`LayoutView.from_message`, + or the same method from any subclass of it; and not :meth:`View.from_message`, or the + same method from any subclass of it. Parameters ----------- @@ -287,24 +480,36 @@ class View: The timeout of the converted view. Returns - -------- - :class:`View` - The converted view. This always returns a :class:`View` and not - one of its subclasses. + ------- + Union[:class:`View`, :class:`LayoutView`] + The converted view. This will always return one of :class:`View` or + :class:`LayoutView`, and not one of its subclasses. """ - view = View(timeout=timeout) + cls = cls._to_minimal_cls() + view = cls(timeout=timeout) row = 0 + for component in message.components: - if isinstance(component, ActionRowComponent): + if not view._is_v2() and isinstance(component, ActionRowComponent): for child in component.children: item = _component_to_item(child) item.row = row + # this error should never be raised, because ActionRows can only + # contain items that View accepts, but check anyways + if item._is_v2(): + raise RuntimeError(f'{item.__class__.__name__} cannot be added to {view.__class__.__name__}') view.add_item(item) - row += 1 - else: - item = _component_to_item(component) - item.row = row - view.add_item(item) + row += 1 + continue + + item = _component_to_item(component) + item.row = row + + if item._is_v2() and not view._is_v2(): + raise RuntimeError(f'{item.__class__.__name__} cannot be added to {view.__class__.__name__}') + + view.add_item(item) + row += 1 return view @@ -324,19 +529,26 @@ 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, 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: - raise ValueError('maximum number of children exceeded') - if not isinstance(item, Item): raise TypeError(f'expected Item not {item.__class__.__name__}') - - self.__weights.add_item(item) + if item._is_v2() and not self._is_v2(): + 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(tuple(item.walk_children())) # type: ignore + + if self._is_v2() and self._total_children + added > 40: + raise ValueError('maximum number of children exceeded') + self._total_children += added self._children.append(item) return self @@ -357,7 +569,15 @@ class View: except ValueError: pass else: - self.__weights.remove_item(item) + removed = 1 + 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 + else: + self._total_children -= removed + return self def clear_items(self) -> Self: @@ -367,9 +587,31 @@ class View: chaining. """ self._children.clear() - self.__weights.clear() + self._total_children = 0 return self + def get_item(self, id: int, /) -> Optional[Item[Self]]: + """Gets an item with :attr:`Item.id` set as ``id``, or ``None`` if + not found. + + .. warning:: + + This is **not the same** as ``custom_id``. + + .. versionadded:: 2.6 + + Parameters + ---------- + id: :class:`int` + The ID of the component. + + Returns + ------- + Optional[:class:`Item`] + The item found, or ``None``. + """ + return _utils_get(self.walk_children(), id=id) + async def interaction_check(self, interaction: Interaction, /) -> bool: """|coro| @@ -428,7 +670,7 @@ class View: 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 @@ -440,7 +682,7 @@ class View: 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() @@ -536,13 +778,253 @@ class View: """ return await self.__stopped + 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. + + 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 + + @classmethod + def _to_minimal_cls(cls) -> Type[Union[View, LayoutView]]: + if issubclass(cls, View): + return View + elif issubclass(cls, LayoutView): + return LayoutView + raise RuntimeError + + @classmethod + def from_dict(cls, data: List[ComponentPayload], *, timeout: Optional[float] = 180.0) -> Any: + r"""Converts a :class:`list` of :class:`dict`\s to a :class:`View` or :class:`LayoutView`, + provided as in the format that Discord expects it to be in. + + You can find out about this format in the :ddocs:`official Discord documentation `. + + This method should be called on the respective class (or subclass), so if you + want to convert v2 items, you should call :meth:`LayoutView.from_dict`, or the same + method from any subclass of it; and not :meth:`View.from_message`, or the same + method from any subclass of it. + + Parameters + ---------- + data: List[:class:`dict`] + The array of dictionaries to convert into a LayoutView + timeout: Optional[:class:`float`] + The timeout of the converted view. + + Returns + ------- + Union[:class:`View`, :class:`LayoutView`] + The converted view. This will always return one of :class:`View` or + :class:`LayoutView`, and not one of its subclasses. + """ + cls = cls._to_minimal_cls() + self = cls(timeout=timeout) + + for raw in data: + item = _component_data_to_item(raw) + + if item._is_v2() and not self._is_v2(): + continue + + self.add_item(item) + return self + + +class View(BaseView): + """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 + + if TYPE_CHECKING: + + @classmethod + def from_dict(cls, data: List[ComponentPayload], *, timeout: Optional[float] = 180.0) -> View: + ... + + @classmethod + def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> View: + ... + + def __init_subclass__(cls) -> None: + super().__init_subclass__() + + children: Dict[str, ItemLike] = {} + for base in reversed(cls.__mro__): + 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') + + cls.__view_children_items__ = children + + 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 + + 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. + + This object must be inherited to create a UI within Discord. + + You can find usage examples in the :resource:`repository ` + + .. 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. + """ + + __discord_ui_layout_view__: ClassVar[bool] = True + + if TYPE_CHECKING: + + @classmethod + def from_dict(cls, data: List[ComponentPayload], *, timeout: Optional[float] = 180.0) -> LayoutView: + ... + + @classmethod + def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> LayoutView: + ... + + def __init__(self, *, timeout: Optional[float] = 180.0) -> None: + super().__init__(timeout=timeout) + + if self._total_children > 40: + raise ValueError('maximum number of children exceeded (40)') + + def __init_subclass__(cls) -> None: + super().__init_subclass__() + + children: Dict[str, ItemLike] = {} + callback_children: Dict[str, ItemCallbackType[Any]] = {} + + for base in reversed(cls.__mro__): + for name, member in base.__dict__.items(): + if isinstance(member, Item): + member._rendered_row = member._row + children[name] = member + elif hasattr(member, '__discord_ui_model_type__') and getattr(member, '__discord_ui_parent__', None): + callback_children[name] = member + + children.update(callback_children) + cls.__view_children_items__ = 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. + + 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 + + def add_item(self, item: Item[Any]) -> Self: + if self._total_children >= 40: + raise ValueError('maximum number of children exceeded (40)') + super().add_item(item) + return self + 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] = {} + self._synced_message_views: Dict[int, BaseView] = {} # custom_id: Modal self._modals: Dict[str, Modal] = {} # component_type is the key @@ -550,7 +1032,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 @@ -571,7 +1053,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 @@ -579,7 +1061,7 @@ class ViewStore: dispatch_info = self._views.setdefault(message_id, {}) is_fully_dynamic = True - for item in view._children: + for item in view.walk_children(): if isinstance(item, DynamicItem): pattern = item.__discord_ui_compiled_template__ self._dynamic_items[pattern] = item.__class__ @@ -621,15 +1103,16 @@ 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( - (index, child) - for index, child in enumerate(view._children) - if child.type.value == component_type and getattr(child, 'custom_id', None) == custom_id - ) - except StopIteration: + base_item = _utils_find( + lambda i: i.type.value == component_type and getattr(i, 'custom_id', None) == custom_id, + view.walk_children(), + ) + + # if the item is not found then return + if not base_item: return try: @@ -638,8 +1121,25 @@ class ViewStore: _log.exception('Ignoring exception in dynamic item creation for %r', factory) return - # Swap the item in the view with our new dynamic item - view._children[base_item_index] = item + # Swap the item in the view or parent with our new dynamic item + # Prioritize the item parent: + parent = base_item._parent or view + + try: + child_index = parent._children.index(base_item) # type: ignore + except ValueError: + # handle cases in which the item is a section accessory + if getattr(base_item._parent, '__discord_ui_section__', False): + if ( + base_item._parent.accessory.type.value == component_type # type: ignore + and getattr(base_item._parent.accessory, 'custom_id', None) == custom_id # type: ignore + ): + base_item._parent.accessory = item # type: ignore + else: + return + else: + parent._children[child_index] = item # type: ignore + item._view = view item._rendered_row = base_item._rendered_row item._refresh_state(interaction, interaction.data) # type: ignore @@ -681,7 +1181,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) @@ -733,14 +1233,14 @@ 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[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) + component = _component_factory(component_data, self._state) # type: ignore if component is not None: components.append(component) diff --git a/discord/webhook/async_.py b/discord/webhook/async_.py index 3b62b10fa..a539918b9 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, View, LayoutView from ..poll import Poll import datetime from ..types.webhook import ( @@ -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, @@ -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'] = [] @@ -802,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| @@ -1598,6 +1605,46 @@ 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, + ephemeral: bool = 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, + ephemeral: bool = 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, @@ -1661,7 +1708,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, @@ -1727,12 +1774,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. @@ -1872,7 +1921,7 @@ class Webhook(BaseWebhook): if wait: msg = self._create_message(data, thread=thread) - if view is not MISSING and not view.is_finished(): + if view is not MISSING and not view.is_finished() and view.is_dispatchable(): message_id = None if msg is None else msg.id self._state.store_view(view, message_id) @@ -1931,6 +1980,33 @@ class Webhook(BaseWebhook): ) return self._create_message(data, thread=thread) + @overload + async def edit_message( + self, + message_id: int, + *, + attachments: Sequence[Union[Attachment, File]] = ..., + view: LayoutView, + allowed_mentions: Optional[AllowedMentions] = ..., + thread: Snowflake = ..., + ) -> WebhookMessage: + ... + + @overload + async 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 = ..., + ) -> WebhookMessage: + ... + async def edit_message( self, message_id: int, @@ -1939,7 +2015,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: @@ -1978,12 +2054,20 @@ 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`. + .. note:: + + To update the message to add a :class:`~discord.ui.LayoutView`, you + must explicitly set the ``content``, ``embed``, ``embeds``, and + ``attachments`` parameters to either ``None`` or an empty array, as appropriate. + .. versionadded:: 2.0 + .. versionchanged:: 2.6 + This now accepts :class:`~discord.ui.LayoutView` instances. thread: :class:`~discord.abc.Snowflake` The thread the webhook message belongs to. @@ -2046,7 +2130,7 @@ class Webhook(BaseWebhook): ) message = self._create_message(data, thread=thread) - if view and not view.is_finished(): + if view and not view.is_finished() and view.is_dispatchable(): self._state.store_view(view, message_id) return message diff --git a/discord/webhook/sync.py b/discord/webhook/sync.py index 171931b12..d5295c1fc 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, 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,6 +914,7 @@ class SyncWebhook(BaseWebhook): silent: bool = MISSING, applied_tags: List[ForumTag] = MISSING, poll: Poll = MISSING, + view: View = MISSING, ) -> SyncWebhookMessage: ... @@ -899,6 +938,7 @@ class SyncWebhook(BaseWebhook): silent: bool = MISSING, applied_tags: List[ForumTag] = MISSING, poll: Poll = MISSING, + view: View = MISSING, ) -> None: ... @@ -921,7 +961,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. @@ -994,13 +1034,15 @@ class SyncWebhook(BaseWebhook): When sending a Poll via webhook, you cannot manually end it. .. versionadded:: 2.4 - view: :class:`~discord.ui.View` - The view to send with the message. This can only have URL buttons, which donnot + view: Union[:class:`~discord.ui.View`, :class:`~discord.ui.LayoutView`] + The view to send with the message. This can only have non-interactible items, which donnot 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 -------- @@ -1143,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, @@ -1151,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: @@ -1177,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. diff --git a/docs/api.rst b/docs/api.rst index c7d9e351f..e712cc8c8 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -3881,6 +3881,27 @@ of :class:`enum.Enum`. An alias for :attr:`.default`. + +.. 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 @@ -5463,8 +5484,6 @@ PollAnswer .. autoclass:: PollAnswer() :members: -.. _discord_api_data: - MessageSnapshot ~~~~~~~~~~~~~~~~~ @@ -5481,6 +5500,16 @@ ClientStatus .. autoclass:: ClientStatus() :members: +CallMessage +~~~~~~~~~~~~~~~~~~~ + +.. attributetable:: CallMessage + +.. autoclass:: CallMessage() + :members: + +.. _discord_api_data: + Data Classes -------------- @@ -5792,12 +5821,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 294a3b13a..40741768d 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 @@ -333,6 +404,48 @@ Enumerations Represents a channel select component. + .. attribute:: section + + Represents a component which holds different components in a section. + + .. versionadded:: 2.6 + + .. attribute:: text_display + + Represents a text display component. + + .. versionadded:: 2.6 + + .. attribute:: thumbnail + + Represents a thumbnail component. + + .. versionadded:: 2.6 + + .. attribute:: media_gallery + + Represents a media gallery component. + + .. versionadded:: 2.6 + + .. attribute:: file + + Represents a file component. + + .. versionadded:: 2.6 + + .. attribute:: separator + + Represents a separator component. + + .. versionadded:: 2.6 + + .. attribute:: container + + Represents a component which holds different components in a container. + + .. versionadded:: 2.6 + .. class:: ButtonStyle Represents the style of the button component. @@ -467,6 +580,19 @@ Enumerations The permission is for a user. +.. class:: SeparatorSpacing + + The separator's size type. + + .. versionadded:: 2.6 + + .. attribute:: small + + A small separator. + .. attribute:: large + + A large separator. + .. _discord_ui_kit: Bot UI Kit @@ -482,6 +608,16 @@ View .. autoclass:: discord.ui.View :members: + :inherited-members: + +LayoutView +~~~~~~~~~~ + +.. attributetable:: discord.ui.LayoutView + +.. autoclass:: discord.ui.LayoutView + :members: + :inherited-members: Modal ~~~~~~ @@ -586,6 +722,86 @@ 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: + + +ActionRow +~~~~~~~~~ + +.. attributetable:: discord.ui.ActionRow + +.. autoclass:: discord.ui.ActionRow + :members: + :inherited-members: + .. _discord_app_commands: Application Commands diff --git a/examples/views/layout.py b/examples/views/layout.py new file mode 100644 index 000000000..70effc30c --- /dev/null +++ b/examples/views/layout.py @@ -0,0 +1,47 @@ +# 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 content, embed(s), stickers, or poll + + +bot.run('token')