diff --git a/discord/attachment.py b/discord/attachment.py index 45dab6c74..195ce30b5 100644 --- a/discord/attachment.py +++ b/discord/attachment.py @@ -27,6 +27,7 @@ import io from os import PathLike from typing import TYPE_CHECKING, Any, Optional, Union +from .errors import ClientException from .mixins import Hashable from .file import File from .flags import AttachmentFlags @@ -67,9 +68,9 @@ class AttachmentBase: '_state', ) - def __init__(self, data: AttachmentBasePayload, state: ConnectionState) -> None: - self._state: ConnectionState = state - self._http: HTTPClient = state.http + def __init__(self, data: AttachmentBasePayload, state: Optional[ConnectionState]) -> None: + self._state: Optional[ConnectionState] = state + self._http: Optional[HTTPClient] = state.http if state else None self.url: str = data['url'] self.proxy_url: str = data['proxy_url'] self.description: Optional[str] = data.get('description') @@ -162,12 +163,19 @@ class AttachmentBase: You do not have permissions to access this attachment NotFound The attachment was deleted. + ClientException + Cannot read a stateless attachment. Returns ------- :class:`bytes` The contents of the attachment. """ + if not self._http: + raise ClientException( + 'Cannot read a stateless attachment' + ) + url = self.proxy_url if use_cached else self.url data = await self._http.get_from_cdn(url) return data @@ -240,8 +248,8 @@ class AttachmentBase: spoiler=spoiler, ) - def to_dict(self): - base = { + def to_dict(self) -> AttachmentBasePayload: + base: AttachmentBasePayload = { 'url': self.url, 'proxy_url': self.proxy_url, 'spoiler': self.spoiler, @@ -415,9 +423,12 @@ class UnfurledAttachment(AttachmentBase): 'loading_state', ) - def __init__(self, data: UnfurledAttachmentPayload, state: ConnectionState) -> None: - self.loading_state: MediaLoadingState = try_enum(MediaLoadingState, data['loading_state']) + def __init__(self, data: UnfurledAttachmentPayload, state: Optional[ConnectionState]) -> None: + self.loading_state: MediaLoadingState = try_enum(MediaLoadingState, data.get('loading_state', 0)) super().__init__(data, state) def __repr__(self) -> str: return f'' + + def to_object_dict(self): + return {'url': self.url} diff --git a/discord/components.py b/discord/components.py index 1a40d3d0b..09f6d54ab 100644 --- a/discord/components.py +++ b/discord/components.py @@ -33,6 +33,8 @@ from typing import ( Tuple, Union, ) + +from .attachment import UnfurledAttachment from .enums import ( try_enum, ComponentType, @@ -40,8 +42,9 @@ from .enums import ( TextStyle, ChannelType, SelectDefaultValueType, - DividerSize, + SeparatorSize, ) +from .colour import Colour from .utils import get_slots, MISSING from .partial_emoji import PartialEmoji, _EmojiTag @@ -59,16 +62,20 @@ if TYPE_CHECKING: SelectDefaultValues as SelectDefaultValuesPayload, SectionComponent as SectionComponentPayload, TextComponent as TextComponentPayload, - ThumbnailComponent as ThumbnailComponentPayload, MediaGalleryComponent as MediaGalleryComponentPayload, FileComponent as FileComponentPayload, - DividerComponent as DividerComponentPayload, - ComponentContainer as ComponentContainerPayload, + SeparatorComponent as SeparatorComponentPayload, + MediaGalleryItem as MediaGalleryItemPayload, + ThumbnailComponent as ThumbnailComponentPayload, + ContainerComponent as ContainerComponentPayload, ) + from .emoji import Emoji from .abc import Snowflake + from .state import ConnectionState ActionRowChildComponentType = Union['Button', 'SelectMenu', 'TextInput'] + SectionComponentType = Union['TextDisplay', 'Button'] __all__ = ( @@ -80,12 +87,10 @@ __all__ = ( 'TextInput', 'SelectDefaultValue', 'SectionComponent', - 'TextComponent', 'ThumbnailComponent', 'MediaGalleryComponent', 'FileComponent', - 'DividerComponent', - 'ComponentContainer', + 'SectionComponent', ) @@ -159,7 +164,7 @@ class ActionRow(Component): component = _component_factory(component_data) if component is not None: - self.children.append(component) + self.children.append(component) # type: ignore # should be the correct type here @property def type(self) -> Literal[ComponentType.action_row]: @@ -701,12 +706,12 @@ class SectionComponent(Component): """ def __init__(self, data: SectionComponentPayload) -> None: - self.components: List[Union[TextDisplay, Button]] = [] + self.components: List[SectionComponentType] = [] for component_data in data['components']: component = _component_factory(component_data) if component is not None: - self.components.append(component) + self.components.append(component) # type: ignore # should be the correct type here try: self.accessory: Optional[Component] = _component_factory(data['accessory']) @@ -727,6 +732,43 @@ class SectionComponent(Component): return payload +class ThumbnailComponent(Component): + """Represents a Thumbnail from the Discord Bot UI Kit. + + This inherits from :class:`Component`. + + Attributes + ---------- + media: :class:`UnfurledAttachment` + The media for this thumbnail. + description: Optional[:class:`str`] + The description shown within this thumbnail. + spoiler: :class:`bool` + Whether this thumbnail is flagged as a spoiler. + """ + + def __init__( + self, + data: ThumbnailComponentPayload, + state: ConnectionState, + ) -> None: + self.media: UnfurledAttachment = UnfurledAttachment(data['media'], state) + self.description: Optional[str] = data.get('description') + self.spoiler: bool = data.get('spoiler', False) + + @property + def type(self) -> Literal[ComponentType.thumbnail]: + return ComponentType.thumbnail + + def to_dict(self) -> ThumbnailComponentPayload: + return { + 'media': self.media.to_dict(), # type: ignroe + 'description': self.description, + 'spoiler': self.spoiler, + 'type': self.type.value, + } + + class TextDisplay(Component): """Represents a text display from the Discord Bot UI Kit. @@ -734,51 +776,231 @@ class TextDisplay(Component): .. versionadded:: 2.6 - Parameters + Attributes ---------- content: :class:`str` The content that this display shows. """ - def __init__(self, content: str) -> None: - self.content: str = content + def __init__(self, data: TextComponentPayload) -> None: + self.content: str = data['content'] @property def type(self) -> Literal[ComponentType.text_display]: return ComponentType.text_display + def to_dict(self) -> TextComponentPayload: + return { + 'type': self.type.value, + 'content': self.content, + } + + +class MediaGalleryItem: + """Represents a :class:`MediaGalleryComponent` media item. + + Parameters + ---------- + url: :class:`str` + The url of the media item. This can be a local file uploaded + as an attachment in the message, that can be accessed using + the ``attachment://file-name.extension`` format. + description: Optional[:class:`str`] + The description to show within this item. + spoiler: :class:`bool` + Whether this item should be flagged as a spoiler. + """ + + __slots__ = ( + 'url', + 'description', + 'spoiler', + '_state', + ) + + def __init__( + self, + url: str, + *, + description: Optional[str] = None, + spoiler: bool = False, + ) -> None: + self.url: str = url + self.description: Optional[str] = description + self.spoiler: bool = spoiler + self._state: Optional[ConnectionState] = None + @classmethod - def _from_data(cls, data: TextComponentPayload) -> TextDisplay: - return cls( - content=data['content'], + def _from_data( + cls, data: MediaGalleryItemPayload, state: Optional[ConnectionState] + ) -> MediaGalleryItem: + media = data['media'] + self = cls( + url=media['url'], + description=data.get('description'), + spoiler=data.get('spoiler', False), ) + self._state = state + return self - def to_dict(self) -> TextComponentPayload: + @classmethod + def _from_gallery( + cls, + items: List[MediaGalleryItemPayload], + state: Optional[ConnectionState], + ) -> List[MediaGalleryItem]: + return [cls._from_data(item, state) for item in items] + + def to_dict(self) -> MediaGalleryItemPayload: + return { # type: ignore + 'media': {'url': self.url}, + 'description': self.description, + 'spoiler': self.spoiler, + } + + +class MediaGalleryComponent(Component): + """Represents a Media Gallery component from the Discord Bot UI Kit. + + This inherits from :class:`Component`. + + Attributes + ---------- + items: List[:class:`MediaGalleryItem`] + The items this gallery has. + """ + + __slots__ = ('items', 'id') + + def __init__(self, data: MediaGalleryComponentPayload, state: Optional[ConnectionState]) -> None: + self.items: List[MediaGalleryItem] = MediaGalleryItem._from_gallery(data['items'], state) + + @property + def type(self) -> Literal[ComponentType.media_gallery]: + return ComponentType.media_gallery + + def to_dict(self) -> MediaGalleryComponentPayload: return { + 'id': self.id, 'type': self.type.value, - 'content': self.content, + 'items': [item.to_dict() for item in self.items], } -class ThumbnailComponent(Component): - """Represents a thumbnail display from the Discord Bot UI Kit. +class FileComponent(Component): + """Represents a File component from the Discord Bot UI Kit. This inherits from :class:`Component`. - .. note:: + Attributes + ---------- + media: :class:`UnfurledAttachment` + The unfurled attachment contents of the file. + spoiler: :class:`bool` + Whether this file is flagged as a spoiler. + """ + + __slots__ = ( + 'media', + 'spoiler', + ) - The user constructuble and usable type to create a thumbnail - component is :class:`discord.ui.Thumbnail` not this one. + def __init__(self, data: FileComponentPayload, state: Optional[ConnectionState]) -> None: + self.media: UnfurledAttachment = UnfurledAttachment( + data['file'], state, + ) + self.spoiler: bool = data.get('spoiler', False) - .. versionadded:: 2.6 + @property + def type(self) -> Literal[ComponentType.file]: + return ComponentType.file + + def to_dict(self) -> FileComponentPayload: + return { # type: ignore + 'file': {'url': self.url}, + 'spoiler': self.spoiler, + 'type': self.type.value, + } + + +class SeparatorComponent(Component): + """Represents a Separator from the Discord Bot UI Kit. + + This inherits from :class:`Component`. Attributes ---------- - media: :class:`ComponentMedia` + spacing: :class:`SeparatorSize` + The spacing size of the separator. + divider: :class:`bool` + Whether this separator is a divider. """ + __slots__ = ( + 'spacing', + 'divider', + ) + + def __init__( + self, + data: SeparatorComponentPayload, + ) -> None: + self.spacing: SeparatorSize = try_enum(SeparatorSize, data.get('spacing', 1)) + self.divider: bool = data.get('divider', True) + + @property + def type(self) -> Literal[ComponentType.separator]: + return ComponentType.separator + + def to_dict(self) -> SeparatorComponentPayload: + return { + 'type': self.type.value, + 'divider': self.divider, + 'spacing': self.spacing.value, + } + + +class Container(Component): + """Represents a Container from the Discord Bot UI Kit. + + This inherits from :class:`Component`. + + Attributes + ---------- + children: :class:`Component` + This container's children. + spoiler: :class:`bool` + Whether this container is flagged as a spoiler. + """ + + def __init__(self, data: ContainerComponentPayload, state: Optional[ConnectionState]) -> None: + self.children: List[Component] = [] + + for child in data['components']: + comp = _component_factory(child, state) + + if comp: + self.children.append(comp) + + self.spoiler: bool = data.get('spoiler', False) + self._colour: Optional[Colour] + try: + self._colour = Colour(data['accent_color']) + except KeyError: + self._colour = None + + @property + def accent_colour(self) -> Optional[Colour]: + """Optional[:class:`Colour`]: The container's accent colour.""" + return self._colour + + accent_color = accent_colour + """Optional[:class:`Color`]: The container's accent color.""" + -def _component_factory(data: ComponentPayload) -> Optional[Component]: +def _component_factory( + data: ComponentPayload, state: Optional[ConnectionState] = None +) -> Optional[Component]: if data['type'] == 1: return ActionRow(data) elif data['type'] == 2: @@ -790,14 +1012,12 @@ def _component_factory(data: ComponentPayload) -> Optional[Component]: elif data['type'] == 9: return SectionComponent(data) elif data['type'] == 10: - return TextDisplay._from_data(data) - elif data['type'] == 11: - return ThumbnailComponent(data) + return TextDisplay(data) elif data['type'] == 12: - return MediaGalleryComponent(data) + return MediaGalleryComponent(data, state) elif data['type'] == 13: - return FileComponent(data) + return FileComponent(data, state) elif data['type'] == 14: - return DividerComponent(data) + return SeparatorComponent(data) elif data['type'] == 17: - return ComponentContainer(data) + return Container(data, state) diff --git a/discord/enums.py b/discord/enums.py index 082a1a708..025f0bf14 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -77,7 +77,7 @@ __all__ = ( 'VoiceChannelEffectAnimationType', 'SubscriptionStatus', 'MessageReferenceType', - 'DividerSize', + 'SeparatorSize', 'MediaLoadingState', ) @@ -872,7 +872,7 @@ class SubscriptionStatus(Enum): inactive = 2 -class DividerSize(Enum): +class SeparatorSize(Enum): small = 1 large = 2 diff --git a/discord/message.py b/discord/message.py index 8a916083e..000747e78 100644 --- a/discord/message.py +++ b/discord/message.py @@ -238,7 +238,7 @@ class MessageSnapshot: self.components: List[MessageComponentType] = [] for component_data in data.get('components', []): - component = _component_factory(component_data) + component = _component_factory(component_data, state) if component is not None: self.components.append(component) diff --git a/discord/types/attachment.py b/discord/types/attachment.py index 20fcd8e1b..0084c334c 100644 --- a/discord/types/attachment.py +++ b/discord/types/attachment.py @@ -25,7 +25,7 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations from typing import Literal, Optional, TypedDict -from typing_extensions import NotRequired +from typing_extensions import NotRequired, Required from .snowflake import Snowflake @@ -52,5 +52,5 @@ class Attachment(AttachmentBase): title: NotRequired[str] -class UnfurledAttachment(AttachmentBase): - loading_state: LoadingState +class UnfurledAttachment(AttachmentBase, total=False): + loading_state: Required[LoadingState] diff --git a/discord/types/components.py b/discord/types/components.py index c169a5286..cffb67ead 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -24,14 +24,14 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations -from typing import List, Literal, TypedDict, Union +from typing import List, Literal, Optional, TypedDict, Union from typing_extensions import NotRequired from .emoji import PartialEmoji from .channel import ChannelType from .attachment import UnfurledAttachment -ComponentType = Literal[1, 2, 3, 4] +ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 17] ButtonStyle = Literal[1, 2, 3, 4, 5, 6] TextStyle = Literal[1, 2] DefaultValueType = Literal["user", "role", "channel"] @@ -137,13 +137,22 @@ class TextComponent(ComponentBase): content: str -class ThumbnailComponent(ComponentBase, UnfurledAttachment): +class ThumbnailComponent(ComponentBase): type: Literal[11] + media: UnfurledAttachment + description: NotRequired[Optional[str]] + spoiler: NotRequired[bool] + + +class MediaGalleryItem(TypedDict): + media: UnfurledAttachment + description: NotRequired[Optional[str]] + spoiler: NotRequired[bool] class MediaGalleryComponent(ComponentBase): type: Literal[12] - items: List[UnfurledAttachment] + items: List[MediaGalleryItem] class FileComponent(ComponentBase): @@ -152,26 +161,28 @@ class FileComponent(ComponentBase): spoiler: NotRequired[bool] -class DividerComponent(ComponentBase): +class SeparatorComponent(ComponentBase): type: Literal[14] divider: NotRequired[bool] spacing: NotRequired[DividerSize] -class ComponentContainer(ComponentBase): +class ContainerComponent(ComponentBase): type: Literal[17] accent_color: NotRequired[int] spoiler: NotRequired[bool] - components: List[ContainerComponent] + components: List[ContainerChildComponent] ActionRowChildComponent = Union[ButtonComponent, SelectMenu, TextInput] -ContainerComponent = Union[ +ContainerChildComponent = Union[ ActionRow, TextComponent, MediaGalleryComponent, FileComponent, SectionComponent, SectionComponent, + ContainerComponent, + SeparatorComponent, ] -Component = Union[ActionRowChildComponent, ContainerComponent] +Component = Union[ActionRowChildComponent, ContainerChildComponent] diff --git a/discord/ui/__init__.py b/discord/ui/__init__.py index c5a51777c..029717cb5 100644 --- a/discord/ui/__init__.py +++ b/discord/ui/__init__.py @@ -16,3 +16,4 @@ from .button import * from .select import * from .text_input import * from .dynamic import * +from .container import * diff --git a/discord/ui/container.py b/discord/ui/container.py new file mode 100644 index 000000000..6792c188f --- /dev/null +++ b/discord/ui/container.py @@ -0,0 +1,86 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING, List, Optional + +if TYPE_CHECKING: + from ..components import Component + from ..colour import Colour, Color + +__all__ = ('Container',) + + +class Container: + """Represents a Components V2 Container. + + .. versionadded:: 2.6 + + Parameters + ---------- + children: List[:class:`Item`] + The initial children of this container. + accent_colour: Optional[:class:`~discord.Colour`] + The colour of the container. Defaults to ``None``. + accent_color: Optional[:class:`~discord.Color`] + The color of the container. Defaults to ``None``. + spoiler: :class:`bool` + Whether to flag this container as a spoiler. Defaults + to ``False``. + """ + + __discord_ui_container__ = True + + def __init__( + self, + children: List[Component], + *, + accent_colour: Optional[Colour] = None, + accent_color: Optional[Color] = None, + spoiler: bool = False, + ) -> None: + self._children: List[Component] = children + self.spoiler: bool = spoiler + self._colour = accent_colour or accent_color + + @property + def children(self) -> List[Component]: + """List[:class:`~discord.Component`]: The children of this container.""" + return self._children.copy() + + @children.setter + def children(self, value: List[Component]) -> None: + self._children = value + + @property + def accent_colour(self) -> Optional[Colour]: + """Optional[:class:`~discord.Colour`]: The colour of the container, or ``None``.""" + return self._colour + + @accent_colour.setter + def accent_colour(self, value: Optional[Colour]) -> None: + self._colour = value + + accent_color = accent_colour + """Optional[:class:`~discord.Color`]: The color of the container, or ``None``.""" diff --git a/discord/ui/section.py b/discord/ui/section.py deleted file mode 100644 index fc8a9e142..000000000 --- a/discord/ui/section.py +++ /dev/null @@ -1,55 +0,0 @@ -""" -The MIT License (MIT) - -Copyright (c) 2015-present Rapptz - -Permission is hereby granted, free of charge, to any person obtaining a -copy of this software and associated documentation files (the "Software"), -to deal in the Software without restriction, including without limitation -the rights to use, copy, modify, merge, publish, distribute, sublicense, -and/or sell copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. -""" -from __future__ import annotations - -from typing import List, Optional - -from .item import Item -from ..components import SectionComponent - -__all__ = ('Section',) - - -class Section(Item): - """Represents a UI section. - - .. versionadded:: tbd - - Parameters - ---------- - accessory: Optional[:class:`Item`] - The accessory to show within this section, displayed on the top right of this section. - """ - - __slots__ = ( - 'accessory', - '_children', - ) - - def __init__(self, *, accessory: Optional[Item]) -> None: - self.accessory: Optional[Item] = accessory - self._children: List[Item] = [] - self._underlying = SectionComponent._raw_construct( - accessory=accessory, - ) diff --git a/discord/ui/view.py b/discord/ui/view.py index dd44944ec..b6262cf22 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -40,6 +40,11 @@ from ..components import ( _component_factory, Button as ButtonComponent, SelectMenu as SelectComponent, + SectionComponent, + TextDisplay, + MediaGalleryComponent, + FileComponent, + SeparatorComponent, ) # fmt: off @@ -62,6 +67,7 @@ if TYPE_CHECKING: _log = logging.getLogger(__name__) +V2_COMPONENTS = (SectionComponent, TextDisplay, MediaGalleryComponent, FileComponent, SeparatorComponent) def _walk_all_components(components: List[Component]) -> Iterator[Component]: @@ -81,6 +87,8 @@ def _component_to_item(component: Component) -> Item: from .select import BaseSelect return BaseSelect.from_component(component) + if isinstance(component, V2_COMPONENTS): + return component return Item.from_component(component) @@ -157,6 +165,7 @@ class View: __discord_ui_view__: ClassVar[bool] = True __discord_ui_modal__: ClassVar[bool] = False + __discord_ui_container__: ClassVar[bool] = False __view_children_items__: ClassVar[List[ItemCallbackType[Any, Any]]] = [] def __init_subclass__(cls) -> None: @@ -737,7 +746,7 @@ class ViewStore: components: List[Component] = [] for component_data in data: - component = _component_factory(component_data) + component = _component_factory(component_data, self._state) if component is not None: components.append(component)