diff --git a/discord/components.py b/discord/components.py index c80eb5a57c..85ae05bdd4 100644 --- a/discord/components.py +++ b/discord/components.py @@ -27,18 +27,38 @@ from typing import TYPE_CHECKING, Any, ClassVar, TypeVar -from .enums import ButtonStyle, ChannelType, ComponentType, InputTextStyle, try_enum +from .colour import Colour +from .enums import ( + ButtonStyle, + ChannelType, + ComponentType, + InputTextStyle, + MediaItemLoadingState, + SeparatorSpacingSize, + try_enum, +) +from .flags import AttachmentFlags from .partial_emoji import PartialEmoji, _EmojiTag from .utils import MISSING, get_slots if TYPE_CHECKING: from .emoji import AppEmoji, GuildEmoji from .types.components import ActionRow as ActionRowPayload + from .types.components import BaseComponent as BaseComponentPayload from .types.components import ButtonComponent as ButtonComponentPayload from .types.components import Component as ComponentPayload + from .types.components import ContainerComponent as ContainerComponentPayload + from .types.components import FileComponent as FileComponentPayload from .types.components import InputText as InputTextComponentPayload + from .types.components import MediaGalleryComponent as MediaGalleryComponentPayload + from .types.components import MediaGalleryItem as MediaGalleryItemPayload + from .types.components import SectionComponent as SectionComponentPayload from .types.components import SelectMenu as SelectMenuPayload from .types.components import SelectOption as SelectOptionPayload + from .types.components import SeparatorComponent as SeparatorComponentPayload + from .types.components import TextDisplayComponent as TextDisplayComponentPayload + from .types.components import ThumbnailComponent as ThumbnailComponentPayload + from .types.components import UnfurledMediaItem as UnfurledMediaItemPayload __all__ = ( "Component", @@ -47,6 +67,8 @@ "SelectMenu", "SelectOption", "InputText", + "Section", + "TextDisplay", ) C = TypeVar("C", bound="Component") @@ -69,12 +91,15 @@ class Component: ---------- type: :class:`ComponentType` The type of component. + id: :class:`str` + The component's ID. """ - __slots__: tuple[str, ...] = ("type",) + __slots__: tuple[str, ...] = ("type", "id") __repr_info__: ClassVar[tuple[str, ...]] type: ComponentType + id: str def __repr__(self) -> str: attrs = " ".join(f"{key}={getattr(self, key)!r}" for key in self.__repr_info__) @@ -494,16 +519,343 @@ def to_dict(self) -> SelectOptionPayload: return payload +class Section(Component): + """Represents a Section from Components V2. + + This is a component that contains other components such as :class:`TextDisplay` and :class:`Thumbnail`. + + This inherits from :class:`Component`. + + .. versionadded:: 2.7 + + Attributes + ---------- + components: List[:class:`Component`] + The components contained in this section. + accessory: Optional[:class:`Component`] + The accessory attached to this Section. + """ + + __slots__: tuple[str, ...] = ("components", "accessory") + + __repr_info__: ClassVar[tuple[str, ...]] = __slots__ + + def __init__(self, data: SectionComponentPayload): + self.type: ComponentType = try_enum(ComponentType, data["type"]) + self.id: str = data.get("id") + self.components: list[Component] = [ + _component_factory(d) for d in data.get("components", []) + ] + self.accessory: Component | None = None + if _accessory := data.get("accessory"): + self.accessory = _component_factory(_accessory) + + def to_dict(self) -> SectionComponentPayload: + payload = { + "type": int(self.type), + "id": self.id, + "components": [c.to_dict() for c in self.components], + } + if self.accessory: + payload["accessory"] = self.accessory.to_dict() + return payload + + +class TextDisplay(Component): + """Represents a Text Display from Components V2. + + This is a component that displays text. + + This inherits from :class:`Component`. + + .. versionadded:: 2.7 + + Attributes + ---------- + content: :class:`str` + The component's text content. + """ + + __slots__: tuple[str, ...] = ("content",) + + __repr_info__: ClassVar[tuple[str, ...]] = __slots__ + + def __init__(self, data: TextDisplayComponentPayload): + self.type: ComponentType = try_enum(ComponentType, data["type"]) + self.id: str = data.get("id") + self.content: str = data.get("content") + + def to_dict(self) -> TextDisplayComponentPayload: + return {"type": int(self.type), "id": self.id, "content": self.content} + + +class UnfurledMediaItem: + + def __init__(self, data: UnfurledMediaItemPayload): + self.url = data.get("url") + self.proxy_url: str = data.get("proxy_url") + self.height: int | None = data.get("height") + self.width: int | None = data.get("width") + self.content_type: str | None = data.get("content_type") + self.flags: AttachmentFlags = AttachmentFlags._from_value(data.get("flags", 0)) + self.placeholder: str = data.get("placeholder") + self.placeholder_version: int = data.get("placeholder_version") + self.loading_state: MediaItemLoadingState = try_enum( + MediaItemLoadingState, data.get("loading_state") + ) + self.src_is_animated: bool = data.get("src_is_animated") + + def to_dict(self): + return {"url": self.url} + + +class Thumbnail(Component): + """Represents a Thumbnail from Components V2. + + This is a component that displays media such as images and videos. + + This inherits from :class:`Component`. + + .. versionadded:: 2.7 + + Attributes + ---------- + media: :class:`UnfurledMediaItem` + The component's media URL. + description: Optional[:class:`str`] + The thumbnail's description, up to 1024 characters. + spoiler: Optional[:class:`bool`] + Whether the thumbnail is a spoiler. + """ + + __slots__: tuple[str, ...] = ( + "media", + "description", + "spoiler", + ) + + __repr_info__: ClassVar[tuple[str, ...]] = __slots__ + + def __init__(self, data: ThumbnailComponentPayload): + self.type: ComponentType = try_enum(ComponentType, data["type"]) + self.id: str = data.get("id") + self.media: UnfurledMediaItem = ( + umi := data.get("media") + ) and UnfurledMediaItem(umi) + self.description: str | None = data.get("description") + self.spoiler: bool | None = data.get("spoiler") + + def to_dict(self) -> ThumbnailComponentPayload: + payload = {"type": int(self.type), "id": self.id, "media": self.media.to_dict()} + if self.description: + payload["description"] = self.description + if self.spoiler is not None: + payload["spoiler"] = self.spoiler + return payload + + +class MediaGalleryItem: + + def __init__(self, data: MediaGalleryItemPayload): + self.media: UnfurledMediaItem = ( + umi := data.get("media") + ) and UnfurledMediaItem(umi) + self.description: str | None = data.get("description") + self.spoiler: bool | None = data.get("spoiler") + + def to_dict(self): + payload = {"media": self.media.to_dict()} + if self.description: + payload["description"] = self.description + if self.spoiler is not None: + payload["spoiler"] = self.spoiler + return payload + + +class MediaGallery(Component): + """Represents a Media Gallery from Components V2. + + This is a component that displays up to 10 different :class:`MediaGalleryItem`s. + + This inherits from :class:`Component`. + + .. versionadded:: 2.7 + + Attributes + ---------- + items: List[:class:`MediaGalleryItem`] + The media this gallery contains. + """ + + __slots__: tuple[str, ...] = ("items",) + + __repr_info__: ClassVar[tuple[str, ...]] = __slots__ + + def __init__(self, data: MediaGalleryComponentPayload): + self.type: ComponentType = try_enum(ComponentType, data["type"]) + self.id: str = data.get("id") + self.items: list[MediaGalleryItem] = [ + MediaGalleryItem(d) for d in data.get("items", []) + ] + + def to_dict(self) -> MediaGalleryComponentPayload: + return { + "type": int(self.type), + "id": self.id, + "items": [i.to_dict() for i in self.items], + } + + +class FileComponent(Component): + """Represents a File from Components V2. + + This is a component that displays some file (elaborate?). + + This inherits from :class:`Component`. + + .. versionadded:: 2.7 + + Attributes + ---------- + file: :class:`UnfurledMediaItem` + The file's media URL. + spoiler: Optional[:class:`bool`] + Whether the file is a spoiler. + """ + + __slots__: tuple[str, ...] = ( + "file", + "spoiler", + ) + + __repr_info__: ClassVar[tuple[str, ...]] = __slots__ + + def __init__(self, data: FileComponentPayload): + self.type: ComponentType = try_enum(ComponentType, data["type"]) + self.id: str = data.get("id") + self.file: UnfurledMediaItem = (umi := data.get("media")) and UnfurledMediaItem( + umi + ) + self.spoiler: bool | None = data.get("spoiler") + + def to_dict(self) -> FileComponentPayload: + payload = {"type": int(self.type), "id": self.id, "file": self.file.to_dict()} + if self.spoiler is not None: + payload["spoiler"] = self.spoiler + return payload + + +class Separator(Component): + """Represents a Separator from Components V2. + + This is a component that separates components. + + This inherits from :class:`Component`. + + .. versionadded:: 2.7 + + Attributes + ---------- + divider: :class:`bool` + Whether the separator is a divider (provide example?) + spacing: Optional[:class:`SeparatorSpacingSize`] + The separator's spacing size. + """ + + __slots__: tuple[str, ...] = ( + "divider", + "spacing", + ) + + __repr_info__: ClassVar[tuple[str, ...]] = __slots__ + + def __init__(self, data: SeparatorComponentPayload): + self.type: ComponentType = try_enum(ComponentType, data["type"]) + self.divider: bool = data.get("divider") + self.spacing: SeparatorSpacingSize = try_enum( + SeparatorSpacingSize, data.get("spacing", 1) + ) + + def to_dict(self) -> SeparatorComponentPayload: + return { + "type": int(self.type), + "id": self.id, + "divider": self.divider, + "spacing": int(self.spacing), + } + + +class Container(Component): + """Represents a Container from Components V2. + + This is a component that contains up to 10 different :class:`Component`s. + It may only contain :class:`ActionRow`, :class:`TextDisplay`, :class:`Section`, :class:`MediaGallery`, :class:`Separator`, and :class:`FileComponent`. + + This inherits from :class:`Component`. + + .. versionadded:: 2.7 + + Attributes + ---------- + items: List[:class:`MediaGalleryItem`] + The media this gallery contains. + """ + + __slots__: tuple[str, ...] = ( + "accent_color", + "spoiler", + "components", + ) + + __repr_info__: ClassVar[tuple[str, ...]] = __slots__ + + def __init__(self, data: ContainerComponentPayload): + self.type: ComponentType = try_enum(ComponentType, data["type"]) + self.id: str = data.get("id") + self.accent_color: Colour | None = (c := data.get("accent_color")) and Colour( + c + ) # at this point, not adding alternative spelling + self.spoiler: bool | None = data.get("spoiler") + self.components: list[Component] = [ + _component_factory(d) for d in data.get("components", []) + ] + + def to_dict(self) -> ContainerComponentPayload: + payload = { + "type": int(self.type), + "id": self.id, + "components": [c.to_dict() for c in self.components], + } + if self.accent_color: + payload["accent_color"] = self.accent_color.value + if self.spoiler is not None: + payload["spoiler"] = self.spoiler + return payload + + +COMPONENT_MAPPINGS = { + 1: ActionRow, + 2: Button, + 3: SelectMenu, + 4: InputText, + 5: SelectMenu, + 6: SelectMenu, + 7: SelectMenu, + 8: SelectMenu, + 9: Section, + 10: TextDisplay, + 11: Thumbnail, + 12: MediaGallery, + 13: FileComponent, + 14: Separator, + 17: Container, +} + + def _component_factory(data: ComponentPayload) -> Component: component_type = data["type"] - if component_type == 1: - return ActionRow(data) - elif component_type == 2: - return Button(data) # type: ignore - elif component_type == 4: - return InputText(data) # type: ignore - elif component_type in (3, 5, 6, 7, 8): - return SelectMenu(data) # type: ignore + if cls := COMPONENT_MAPPINGS.get(component_type): + return cls(data) else: as_enum = try_enum(ComponentType, component_type) return Component._raw_construct(type=as_enum) diff --git a/discord/enums.py b/discord/enums.py index e1086651e9..d8cd1f3aec 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -712,6 +712,14 @@ 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 + content_inventory_entry = 16 + container = 17 def __int__(self): return self.value @@ -1063,6 +1071,22 @@ class SubscriptionStatus(Enum): inactive = 2 +class SeparatorSpacingSize(Enum): + """A separator component's spacing size.""" + + small = 1 + large = 2 + + +class MediaItemLoadingState(Enum): + """An :class:`~discord.UnfurledMediaItem`'s ``loading_state``.""" + + unknown = 0 + loading = 1 + loaded_success = 2 + loaded_not_found = 3 + + T = TypeVar("T") diff --git a/discord/flags.py b/discord/flags.py index 7073a56e35..1406bdcea0 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -333,22 +333,22 @@ class MessageFlags(BaseFlags): @flag_value def crossposted(self): """:class:`bool`: Returns ``True`` if the message is the original crossposted message.""" - return 1 + return 1 << 0 @flag_value def is_crossposted(self): """:class:`bool`: Returns ``True`` if the message was crossposted from another channel.""" - return 2 + return 1 << 1 @flag_value def suppress_embeds(self): """:class:`bool`: Returns ``True`` if the message's embeds have been suppressed.""" - return 4 + return 1 << 2 @flag_value def source_message_deleted(self): """:class:`bool`: Returns ``True`` if the source message for this crosspost has been deleted.""" - return 8 + return 1 << 3 @flag_value def urgent(self): @@ -356,7 +356,7 @@ def urgent(self): An urgent message is one sent by Discord Trust and Safety. """ - return 16 + return 1 << 4 @flag_value def has_thread(self): @@ -364,7 +364,7 @@ def has_thread(self): .. versionadded:: 2.0 """ - return 32 + return 1 << 5 @flag_value def ephemeral(self): @@ -372,7 +372,7 @@ def ephemeral(self): .. versionadded:: 2.0 """ - return 64 + return 1 << 6 @flag_value def loading(self): @@ -382,7 +382,7 @@ def loading(self): .. versionadded:: 2.0 """ - return 128 + return 1 << 7 @flag_value def failed_to_mention_some_roles_in_thread(self): @@ -390,7 +390,7 @@ def failed_to_mention_some_roles_in_thread(self): .. versionadded:: 2.0 """ - return 256 + return 1 << 8 @flag_value def suppress_notifications(self): @@ -401,7 +401,7 @@ def suppress_notifications(self): .. versionadded:: 2.4 """ - return 4096 + return 1 << 12 @flag_value def is_voice_message(self): @@ -409,7 +409,15 @@ def is_voice_message(self): .. versionadded:: 2.5 """ - return 8192 + return 1 << 13 + + @flag_value + def is_components_v2(self): + """:class:`bool`: Returns ``True`` if this message has v2 components. This flag disables sending `content`, `embed`, and `embeds`. + + .. versionadded:: 2.7 + """ + return 1 << 15 @fill_with_flags() diff --git a/discord/types/components.py b/discord/types/components.py index 7b05f8bf08..67af33f48f 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -33,17 +33,24 @@ from .emoji import PartialEmoji from .snowflake import Snowflake -ComponentType = Literal[1, 2, 3, 4] +ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 17] ButtonStyle = Literal[1, 2, 3, 4, 5, 6] InputTextStyle = Literal[1, 2] +SeparatorSpacingSize = Literal[1, 2] +LoadingState = Literal[0, 1, 2, 3] -class ActionRow(TypedDict): +class BaseComponent(TypedDict): + type: ComponentType + id: NotRequired[int] + + +class ActionRow(BaseComponent): type: Literal[1] components: list[Component] -class ButtonComponent(TypedDict): +class ButtonComponent(BaseComponent): custom_id: NotRequired[str] url: NotRequired[str] disabled: NotRequired[bool] @@ -54,7 +61,7 @@ class ButtonComponent(TypedDict): sku_id: Snowflake -class InputText(TypedDict): +class InputText(BaseComponent): min_length: NotRequired[int] max_length: NotRequired[int] required: NotRequired[bool] @@ -74,7 +81,7 @@ class SelectOption(TypedDict): default: bool -class SelectMenu(TypedDict): +class SelectMenu(BaseComponent): placeholder: NotRequired[str] min_values: NotRequired[int] max_values: NotRequired[int] @@ -85,4 +92,75 @@ class SelectMenu(TypedDict): custom_id: str +class TextDisplayComponent(BaseComponent): + type: Literal[10] + content: str + + +class SectionComponent(BaseComponent): + type: Literal[9] + components: list[TextDisplayComponent, ButtonComponent] + accessory: NotRequired[Component] + + +class UnfurledMediaItem(TypedDict): + url: str + proxy_url: str + height: NotRequired[int | None] + width: NotRequired[int | None] + content_type: NotRequired[str] + src_is_animated: NotRequired[bool] + placeholder: str + placeholder_version: int + loading_state: LoadingState + flags: NotRequired[int] + + +class ThumbnailComponent(BaseComponent): + type: Literal[11] + media: UnfurledMediaItem + description: NotRequired[str] + spoiler: NotRequired[bool] + + +class MediaGalleryItem(TypedDict): + media: UnfurledMediaItem + description: NotRequired[str] + spoiler: NotRequired[bool] + + +class MediaGalleryComponent(BaseComponent): + type: Literal[12] + items: list[MediaGalleryItem] + + +class FileComponent(BaseComponent): + type: Literal[13] + file: UnfurledMediaItem + spoiler: NotRequired[bool] + + +class SeparatorComponent(BaseComponent): + type: Literal[14] + divider: NotRequired[bool] + spacing: NotRequired[SeparatorSpacingSize] + + +class ContainerComponent(BaseComponent): + type: Literal[17] + accent_color: NotRequired[int] + spoiler: NotRequired[bool] + components: list[AllowedContainerComponents] + + Component = Union[ActionRow, ButtonComponent, SelectMenu, InputText] + + +AllowedContainerComponents = Union[ + ActionRow, + TextDisplayComponent, + MediaGalleryComponent, + FileComponent, + SeparatorComponent, + SectionComponent, +] diff --git a/discord/ui/section.py b/discord/ui/section.py new file mode 100644 index 0000000000..49b3de3f45 --- /dev/null +++ b/discord/ui/section.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ..components import Section as SectionComponent +from ..enums import ComponentType +from .item import Item + +__all__ = ("Section",) + +if TYPE_CHECKING: + from ..types.components import SectionComponent as SectionComponentPayload + + +class Section: + """Represents a UI section. + + .. versionadded:: 2.7 + + Parameters + ---------- + *items: :class:`Item` + The initial items contained in this section, up to 3. Currently only supports :class:`~discord.ui.TextDisplay` and :class:`~discord.ui.Button`. + accessory: Optional[:class:`Item`] + This section's accessory. This is displayed in the top right of the section. Currently only supports :class:`~discord.ui.TextDisplay` and :class:`~discord.ui.Button`. + """ + + def __init__(self, *items: Item, accessory: Item = None): + super().__init__() + + self.items = items + self.accessory = accessory + components = [] + + self._underlying = SectionComponent._raw_construct( + type=ComponentType.section, + components=components, + accessory=accessory, + ) + + def add_item(self, item: Item) -> None: + """Adds an item to the section. + + Parameters + ---------- + item: :class:`Item` + The item to add to the section. + + Raises + ------ + TypeError + An :class:`Item` was not passed. + ValueError + Maximum number of items has been exceeded (10). + """ + + if len(self.items) >= 3: + raise ValueError("maximum number of children exceeded") + + if not isinstance(item, Item): + raise TypeError(f"expected Item not {item.__class__!r}") + + self.items.append(item) + + def add_text(self, content: str) -> None: + """Adds a :class:`TextDisplay` to the section. + + Parameters + ---------- + content: :class:`str` + The content of the TextDisplay + + Raises + ------ + TypeError + An :class:`Item` was not passed. + ValueError + Maximum number of items has been exceeded (3). + """ + + if len(self.items) >= 3: + raise ValueError("maximum number of children exceeded") + + text = ... + + self.items.append(text) + + def add_button( + self, + label: str, + ) -> None: + """finish""" + + @property + def type(self) -> ComponentType: + return self._underlying.type + + def to_component_dict(self) -> SectionComponentPayload: + return self._underlying.to_dict()