Add support for components V2

Co-authored-by: Michael H <michael@michaelhall.tech>
Co-authored-by: Soheab <33902984+Soheab@users.noreply.github.com>
Co-authored-by: owocado <24418520+owocado@users.noreply.github.com>
Co-authored-by: Jay3332 <40323796+jay3332@users.noreply.github.com>
Co-authored-by: Danny <1695103+Rapptz@users.noreply.github.com>
This commit is contained in:
DA344
2025-08-14 02:37:23 +02:00
committed by GitHub
parent 6ec2e5329b
commit 50caa3c82c
33 changed files with 4214 additions and 298 deletions

View File

@ -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']
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,577 @@ 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
----------
children: List[:class:`TextDisplay`]
The components on this section.
accessory: :class:`Component`
The section accessory.
id: Optional[:class:`int`]
The ID of this component.
"""
__slots__ = (
'children',
'accessory',
'id',
)
__repr_info__ = __slots__
def __init__(self, data: SectionComponentPayload, state: Optional[ConnectionState]) -> None:
self.children: List[SectionComponentType] = []
self.accessory: Component = _component_factory(data['accessory'], state) # type: ignore
self.id: Optional[int] = data.get('id')
for component_data in data['components']:
component = _component_factory(component_data, state)
if component is not None:
self.children.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.children],
'accessory': self.accessory.to_dict(),
}
if self.id is not None:
payload['id'] = self.id
return payload
@overload
def _component_factory(data: ComponentPayload) -> Optional[Union[ActionRow, ActionRowChildComponentType]]:
...
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
def _component_factory(data: ComponentPayload) -> Optional[Union[ActionRow, ActionRowChildComponentType]]:
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://<filename>`` 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 <https://en.wikipedia.org/wiki/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'<UnfurledMediaItem url={self.url}>'
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://<filename>`` 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'<MediaGalleryItem media={self.media!r}>'
@property
def media(self) -> UnfurledMediaItem:
""":class:`UnfurledMediaItem`: This item's media data."""
return self._media
@media.setter
def media(self, value: Union[str, UnfurledMediaItem]) -> None:
if isinstance(value, str):
self._media = UnfurledMediaItem(value)
elif isinstance(value, UnfurledMediaItem):
self._media = value
else:
raise TypeError(f'Expected a str or UnfurledMediaItem, not {value.__class__.__name__}')
@classmethod
def _from_data(cls, data: MediaGalleryItemPayload, state: Optional[ConnectionState]) -> MediaGalleryItem:
media = data['media']
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')
@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, state: Optional[ConnectionState] = None) -> Optional[Component]:
if data['type'] == 1:
return ActionRow(data)
elif data['type'] == 2:
@ -663,4 +1317,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)