DA344 1 week ago
committed by GitHub
parent
commit
0806747662
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 40
      discord/abc.py
  2. 50
      discord/channel.py
  3. 15
      discord/client.py
  4. 664
      discord/components.py
  5. 21
      discord/enums.py
  6. 76
      discord/ext/commands/context.py
  7. 10
      discord/flags.py
  8. 12
      discord/http.py
  9. 133
      discord/interactions.py
  10. 65
      discord/message.py
  11. 8
      discord/state.py
  12. 91
      discord/types/components.py
  13. 6
      discord/types/message.py
  14. 8
      discord/ui/__init__.py
  15. 599
      discord/ui/action_row.py
  16. 24
      discord/ui/button.py
  17. 449
      discord/ui/container.py
  18. 19
      discord/ui/dynamic.py
  19. 145
      discord/ui/file.py
  20. 54
      discord/ui/item.py
  21. 254
      discord/ui/media_gallery.py
  22. 262
      discord/ui/section.py
  23. 67
      discord/ui/select.py
  24. 127
      discord/ui/separator.py
  25. 96
      discord/ui/text_display.py
  26. 8
      discord/ui/text_input.py
  27. 116
      discord/ui/thumbnail.py
  28. 752
      discord/ui/view.py
  29. 92
      discord/webhook/async_.py
  30. 85
      discord/webhook/sync.py
  31. 50
      docs/api.rst
  32. 208
      docs/interactions/api.rst
  33. 2
      examples/views/dynamic_counter.py
  34. 47
      examples/views/layout.py

40
discord/abc.py

@ -95,7 +95,7 @@ if TYPE_CHECKING:
) )
from .poll import Poll from .poll import Poll
from .threads import Thread from .threads import Thread
from .ui.view import View from .ui.view import BaseView, View, LayoutView
from .types.channel import ( from .types.channel import (
PermissionOverwrite as PermissionOverwritePayload, PermissionOverwrite as PermissionOverwritePayload,
Channel as ChannelPayload, Channel as ChannelPayload,
@ -1374,6 +1374,38 @@ class Messageable:
async def _get_channel(self) -> MessageableChannel: async def _get_channel(self) -> MessageableChannel:
raise NotImplementedError 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 @overload
async def send( async def send(
self, self,
@ -1473,7 +1505,7 @@ class Messageable:
allowed_mentions: Optional[AllowedMentions] = None, allowed_mentions: Optional[AllowedMentions] = None,
reference: Optional[Union[Message, MessageReference, PartialMessage]] = None, reference: Optional[Union[Message, MessageReference, PartialMessage]] = None,
mention_author: Optional[bool] = None, mention_author: Optional[bool] = None,
view: Optional[View] = None, view: Optional[BaseView] = None,
suppress_embeds: bool = False, suppress_embeds: bool = False,
silent: bool = False, silent: bool = False,
poll: Optional[Poll] = None, poll: Optional[Poll] = None,
@ -1546,10 +1578,12 @@ class Messageable:
If set, overrides the :attr:`~discord.AllowedMentions.replied_user` attribute of ``allowed_mentions``. If set, overrides the :attr:`~discord.AllowedMentions.replied_user` attribute of ``allowed_mentions``.
.. versionadded:: 1.6 .. 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. A Discord UI View to add to the message.
.. versionadded:: 2.0 .. versionadded:: 2.0
.. versionchanged:: 2.6
This now accepts :class:`discord.ui.LayoutView` instances.
stickers: Sequence[Union[:class:`~discord.GuildSticker`, :class:`~discord.StickerItem`]] stickers: Sequence[Union[:class:`~discord.GuildSticker`, :class:`~discord.StickerItem`]]
A list of stickers to upload. Must be a maximum of 3. A list of stickers to upload. Must be a maximum of 3.

50
discord/channel.py

@ -100,7 +100,7 @@ if TYPE_CHECKING:
from .file import File from .file import File
from .user import ClientUser, User, BaseUser from .user import ClientUser, User, BaseUser
from .guild import Guild, GuildChannel as GuildChannelType from .guild import Guild, GuildChannel as GuildChannelType
from .ui.view import View from .ui.view import BaseView, View, LayoutView
from .types.channel import ( from .types.channel import (
TextChannel as TextChannelPayload, TextChannel as TextChannelPayload,
NewsChannel as NewsChannelPayload, NewsChannel as NewsChannelPayload,
@ -2841,6 +2841,47 @@ class ForumChannel(discord.abc.GuildChannel, Hashable):
return result 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( async def create_thread(
self, self,
*, *,
@ -2857,7 +2898,7 @@ class ForumChannel(discord.abc.GuildChannel, Hashable):
allowed_mentions: AllowedMentions = MISSING, allowed_mentions: AllowedMentions = MISSING,
mention_author: bool = MISSING, mention_author: bool = MISSING,
applied_tags: Sequence[ForumTag] = MISSING, applied_tags: Sequence[ForumTag] = MISSING,
view: View = MISSING, view: BaseView = MISSING,
suppress_embeds: bool = False, suppress_embeds: bool = False,
reason: Optional[str] = None, reason: Optional[str] = None,
) -> ThreadWithMessage: ) -> ThreadWithMessage:
@ -2907,8 +2948,11 @@ class ForumChannel(discord.abc.GuildChannel, Hashable):
If set, overrides the :attr:`~discord.AllowedMentions.replied_user` attribute of ``allowed_mentions``. If set, overrides the :attr:`~discord.AllowedMentions.replied_user` attribute of ``allowed_mentions``.
applied_tags: List[:class:`discord.ForumTag`] applied_tags: List[:class:`discord.ForumTag`]
A list of tags to apply to the thread. 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. 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`]] stickers: Sequence[Union[:class:`~discord.GuildSticker`, :class:`~discord.StickerItem`]]
A list of stickers to upload. Must be a maximum of 3. A list of stickers to upload. Must be a maximum of 3.
suppress_embeds: :class:`bool` suppress_embeds: :class:`bool`

15
discord/client.py

@ -72,7 +72,7 @@ from .object import Object
from .backoff import ExponentialBackoff from .backoff import ExponentialBackoff
from .webhook import Webhook from .webhook import Webhook
from .appinfo import AppInfo from .appinfo import AppInfo
from .ui.view import View from .ui.view import BaseView
from .ui.dynamic import DynamicItem from .ui.dynamic import DynamicItem
from .stage_instance import StageInstance from .stage_instance import StageInstance
from .threads import Thread from .threads import Thread
@ -3149,7 +3149,7 @@ class Client:
self._connection.remove_dynamic_items(*items) 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. """Registers a :class:`~discord.ui.View` for persistent listening.
This method should be used for when a view is comprised of components This method should be used for when a view is comprised of components
@ -3159,8 +3159,11 @@ class Client:
Parameters Parameters
------------ ------------
view: :class:`discord.ui.View` view: Union[:class:`discord.ui.View`, :class:`discord.ui.LayoutView`]
The view to register for dispatching. The view to register for dispatching.
.. versionchanged:: 2.6
This now accepts :class:`discord.ui.LayoutView` instances.
message_id: Optional[:class:`int`] message_id: Optional[:class:`int`]
The message ID that the view is attached to. This is currently used to 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 refresh the view's state during message update events. If not given
@ -3175,7 +3178,7 @@ class Client:
and all their components have an explicitly provided custom_id. 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__}') raise TypeError(f'expected an instance of View not {view.__class__.__name__}')
if not view.is_persistent(): if not view.is_persistent():
@ -3187,8 +3190,8 @@ class Client:
self._connection.store_view(view, message_id) self._connection.store_view(view, message_id)
@property @property
def persistent_views(self) -> Sequence[View]: def persistent_views(self) -> Sequence[BaseView]:
"""Sequence[:class:`.View`]: A sequence of persistent views added to the client. """Sequence[Union[:class:`.View`, :class:`.LayoutView`]]: A sequence of persistent views added to the client.
.. versionadded:: 2.0 .. versionadded:: 2.0
""" """

664
discord/components.py

@ -24,8 +24,29 @@ DEALINGS IN THE SOFTWARE.
from __future__ import annotations from __future__ import annotations
from typing import ClassVar, List, Literal, Optional, TYPE_CHECKING, Tuple, Union, overload from typing import (
from .enums import try_enum, ComponentType, ButtonStyle, TextStyle, ChannelType, SelectDefaultValueType ClassVar,
List,
Literal,
Optional,
TYPE_CHECKING,
Tuple,
Union,
)
from .asset import AssetMixin
from .enums import (
try_enum,
ComponentType,
ButtonStyle,
TextStyle,
ChannelType,
SelectDefaultValueType,
SeparatorSize,
MediaItemLoadingState,
)
from .flags import AttachmentFlags
from .colour import Colour
from .utils import get_slots, MISSING from .utils import get_slots, MISSING
from .partial_emoji import PartialEmoji, _EmojiTag from .partial_emoji import PartialEmoji, _EmojiTag
@ -39,13 +60,35 @@ if TYPE_CHECKING:
SelectOption as SelectOptionPayload, SelectOption as SelectOptionPayload,
ActionRow as ActionRowPayload, ActionRow as ActionRowPayload,
TextInput as TextInputPayload, TextInput as TextInputPayload,
ActionRowChildComponent as ActionRowChildComponentPayload,
SelectDefaultValues as SelectDefaultValuesPayload, 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 .emoji import Emoji
from .abc import Snowflake from .abc import Snowflake
from .state import ConnectionState
ActionRowChildComponentType = Union['Button', 'SelectMenu', 'TextInput'] ActionRowChildComponentType = Union['Button', 'SelectMenu', 'TextInput']
SectionComponentType = Union['TextDisplay', 'Button']
MessageComponentType = Union[
ActionRowChildComponentType,
SectionComponentType,
'ActionRow',
'SectionComponent',
'ThumbnailComponent',
'MediaGalleryComponent',
'FileComponent',
'SectionComponent',
'Component',
]
__all__ = ( __all__ = (
@ -56,18 +99,35 @@ __all__ = (
'SelectOption', 'SelectOption',
'TextInput', 'TextInput',
'SelectDefaultValue', 'SelectDefaultValue',
'SectionComponent',
'ThumbnailComponent',
'UnfurledMediaItem',
'MediaGalleryItem',
'MediaGalleryComponent',
'FileComponent',
'SectionComponent',
'Container',
'TextDisplay',
'SeparatorComponent',
) )
class Component: class Component:
"""Represents a Discord Bot UI Kit 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:`ActionRow`
- :class:`Button` - :class:`Button`
- :class:`SelectMenu` - :class:`SelectMenu`
- :class:`TextInput` - :class:`TextInput`
- :class:`SectionComponent`
- :class:`TextDisplay`
- :class:`ThumbnailComponent`
- :class:`MediaGalleryComponent`
- :class:`FileComponent`
- :class:`SeparatorComponent`
- :class:`Container`
This class is abstract and cannot be instantiated. This class is abstract and cannot be instantiated.
@ -116,20 +176,25 @@ class ActionRow(Component):
------------ ------------
children: List[Union[:class:`Button`, :class:`SelectMenu`, :class:`TextInput`]] children: List[Union[:class:`Button`, :class:`SelectMenu`, :class:`TextInput`]]
The children components that this holds, if any. 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__ __repr_info__: ClassVar[Tuple[str, ...]] = __slots__
def __init__(self, data: ActionRowPayload, /) -> None: def __init__(self, data: ActionRowPayload, /) -> None:
self.id: Optional[int] = data.get('id')
self.children: List[ActionRowChildComponentType] = [] self.children: List[ActionRowChildComponentType] = []
for component_data in data.get('components', []): for component_data in data.get('components', []):
component = _component_factory(component_data) component = _component_factory(component_data)
if component is not None: if component is not None:
self.children.append(component) self.children.append(component) # type: ignore # should be the correct type here
@property @property
def type(self) -> Literal[ComponentType.action_row]: def type(self) -> Literal[ComponentType.action_row]:
@ -137,10 +202,13 @@ class ActionRow(Component):
return ComponentType.action_row return ComponentType.action_row
def to_dict(self) -> ActionRowPayload: def to_dict(self) -> ActionRowPayload:
return { payload: ActionRowPayload = {
'type': self.type.value, 'type': self.type.value,
'components': [child.to_dict() for child in self.children], 'components': [child.to_dict() for child in self.children],
} }
if self.id is not None:
payload['id'] = self.id
return payload
class Button(Component): class Button(Component):
@ -174,6 +242,10 @@ class Button(Component):
The SKU ID this button sends you to, if available. The SKU ID this button sends you to, if available.
.. versionadded:: 2.4 .. versionadded:: 2.4
id: Optional[:class:`int`]
The ID of this component.
.. versionadded:: 2.6
""" """
__slots__: Tuple[str, ...] = ( __slots__: Tuple[str, ...] = (
@ -184,11 +256,13 @@ class Button(Component):
'label', 'label',
'emoji', 'emoji',
'sku_id', 'sku_id',
'id',
) )
__repr_info__: ClassVar[Tuple[str, ...]] = __slots__ __repr_info__: ClassVar[Tuple[str, ...]] = __slots__
def __init__(self, data: ButtonComponentPayload, /) -> None: def __init__(self, data: ButtonComponentPayload, /) -> None:
self.id: Optional[int] = data.get('id')
self.style: ButtonStyle = try_enum(ButtonStyle, data['style']) self.style: ButtonStyle = try_enum(ButtonStyle, data['style'])
self.custom_id: Optional[str] = data.get('custom_id') self.custom_id: Optional[str] = data.get('custom_id')
self.url: Optional[str] = data.get('url') self.url: Optional[str] = data.get('url')
@ -217,6 +291,9 @@ class Button(Component):
'disabled': self.disabled, 'disabled': self.disabled,
} }
if self.id is not None:
payload['id'] = self.id
if self.sku_id: if self.sku_id:
payload['sku_id'] = str(self.sku_id) payload['sku_id'] = str(self.sku_id)
@ -268,6 +345,10 @@ class SelectMenu(Component):
Whether the select is disabled or not. Whether the select is disabled or not.
channel_types: List[:class:`.ChannelType`] channel_types: List[:class:`.ChannelType`]
A list of channel types that are allowed to be chosen in this select menu. 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, ...] = ( __slots__: Tuple[str, ...] = (
@ -280,6 +361,7 @@ class SelectMenu(Component):
'disabled', 'disabled',
'channel_types', 'channel_types',
'default_values', 'default_values',
'id',
) )
__repr_info__: ClassVar[Tuple[str, ...]] = __slots__ __repr_info__: ClassVar[Tuple[str, ...]] = __slots__
@ -296,6 +378,7 @@ class SelectMenu(Component):
self.default_values: List[SelectDefaultValue] = [ self.default_values: List[SelectDefaultValue] = [
SelectDefaultValue.from_dict(d) for d in data.get('default_values', []) SelectDefaultValue.from_dict(d) for d in data.get('default_values', [])
] ]
self.id: Optional[int] = data.get('id')
def to_dict(self) -> SelectMenuPayload: def to_dict(self) -> SelectMenuPayload:
payload: SelectMenuPayload = { payload: SelectMenuPayload = {
@ -305,6 +388,8 @@ class SelectMenu(Component):
'max_values': self.max_values, 'max_values': self.max_values,
'disabled': self.disabled, 'disabled': self.disabled,
} }
if self.id is not None:
payload['id'] = self.id
if self.placeholder: if self.placeholder:
payload['placeholder'] = self.placeholder payload['placeholder'] = self.placeholder
if self.options: if self.options:
@ -312,7 +397,7 @@ class SelectMenu(Component):
if self.channel_types: if self.channel_types:
payload['channel_types'] = [t.value for t in self.channel_types] payload['channel_types'] = [t.value for t in self.channel_types]
if self.default_values: 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 return payload
@ -470,6 +555,10 @@ class TextInput(Component):
The minimum length of the text input. The minimum length of the text input.
max_length: Optional[:class:`int`] max_length: Optional[:class:`int`]
The maximum length of the text input. The maximum length of the text input.
id: Optional[:class:`int`]
The ID of this component.
.. versionadded:: 2.6
""" """
__slots__: Tuple[str, ...] = ( __slots__: Tuple[str, ...] = (
@ -481,6 +570,7 @@ class TextInput(Component):
'required', 'required',
'min_length', 'min_length',
'max_length', 'max_length',
'id',
) )
__repr_info__: ClassVar[Tuple[str, ...]] = __slots__ __repr_info__: ClassVar[Tuple[str, ...]] = __slots__
@ -494,6 +584,7 @@ class TextInput(Component):
self.required: bool = data.get('required', True) self.required: bool = data.get('required', True)
self.min_length: Optional[int] = data.get('min_length') self.min_length: Optional[int] = data.get('min_length')
self.max_length: Optional[int] = data.get('max_length') self.max_length: Optional[int] = data.get('max_length')
self.id: Optional[int] = data.get('id')
@property @property
def type(self) -> Literal[ComponentType.text_input]: def type(self) -> Literal[ComponentType.text_input]:
@ -509,6 +600,9 @@ class TextInput(Component):
'required': self.required, 'required': self.required,
} }
if self.id is not None:
payload['id'] = self.id
if self.placeholder: if self.placeholder:
payload['placeholder'] = self.placeholder payload['placeholder'] = self.placeholder
@ -642,17 +736,541 @@ class SelectDefaultValue:
) )
@overload class SectionComponent(Component):
def _component_factory(data: ActionRowChildComponentPayload) -> Optional[ActionRowChildComponentType]: """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
@overload class TextDisplay(Component):
def _component_factory(data: ComponentPayload) -> Optional[Union[ActionRow, ActionRowChildComponentType]]: """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.
"""
__slots__ = (
'url',
'proxy_url',
'height',
'width',
'content_type',
'_flags',
'placeholder',
'loading_state',
'_state',
)
def __init__(self, url: str) -> None:
self.url: str = url
self.proxy_url: Optional[str] = None
self.height: Optional[int] = None
self.width: Optional[int] = None
self.content_type: Optional[str] = None
self._flags: int = 0
self.placeholder: Optional[str] = None
self.loading_state: Optional[MediaItemLoadingState] = None
self._state: Optional[ConnectionState] = None
@property
def flags(self) -> AttachmentFlags:
""":class:`AttachmentFlags`: This media item's flags."""
return AttachmentFlags._from_value(self._flags)
@classmethod
def _from_data(cls, data: UnfurledMediaItemPayload, state: Optional[ConnectionState]):
self = cls(data['url'])
self._update(data, state)
return self
def _update(self, data: UnfurledMediaItemPayload, state: Optional[ConnectionState]) -> None:
self.proxy_url = data.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._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}>'
@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:
return {
'media': self.media.to_dict(), # type: ignore
'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`.
.. 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.
"""
__slots__ = (
'media',
'spoiler',
'id',
)
__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')
@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:`SeparatorSize`
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: SeparatorSize = try_enum(SeparatorSize, data.get('spacing', 1))
self.visible: bool = data.get('divider', True)
self.id: Optional[int] = data.get('id')
@property
def type(self) -> Literal[ComponentType.separator]:
return ComponentType.separator
def to_dict(self) -> SeparatorComponentPayload:
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
def to_dict(self) -> ContainerComponentPayload:
payload: ContainerComponentPayload = {
'type': self.type.value, # type: ignore
'spoiler': self.spoiler,
'components': [c.to_dict() for c in self.children],
}
if self.id is not None:
payload['id'] = self.id
if self._colour:
payload['accent_color'] = self._colour.value
return payload
def _component_factory(data: ComponentPayload) -> Optional[Union[ActionRow, ActionRowChildComponentType]]: def _component_factory(data: ComponentPayload, state: Optional[ConnectionState] = None) -> Optional[Component]:
if data['type'] == 1: if data['type'] == 1:
return ActionRow(data) return ActionRow(data)
elif data['type'] == 2: elif data['type'] == 2:
@ -660,4 +1278,18 @@ def _component_factory(data: ComponentPayload) -> Optional[Union[ActionRow, Acti
elif data['type'] == 4: elif data['type'] == 4:
return TextInput(data) return TextInput(data)
elif data['type'] in (3, 5, 6, 7, 8): 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)

21
discord/enums.py

@ -77,6 +77,8 @@ __all__ = (
'VoiceChannelEffectAnimationType', 'VoiceChannelEffectAnimationType',
'SubscriptionStatus', 'SubscriptionStatus',
'MessageReferenceType', 'MessageReferenceType',
'SeparatorSize',
'MediaItemLoadingState',
) )
@ -641,6 +643,13 @@ class ComponentType(Enum):
role_select = 6 role_select = 6
mentionable_select = 7 mentionable_select = 7
channel_select = 8 channel_select = 8
section = 9
text_display = 10
thumbnail = 11
media_gallery = 12
file = 13
separator = 14
container = 17
def __int__(self) -> int: def __int__(self) -> int:
return self.value return self.value
@ -863,6 +872,18 @@ class SubscriptionStatus(Enum):
inactive = 2 inactive = 2
class SeparatorSize(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: def create_unknown_value(cls: Type[E], val: Any) -> E:
value_cls = cls._enum_value_cls_ # type: ignore # This is narrowed below value_cls = cls._enum_value_cls_ # type: ignore # This is narrowed below
name = f'unknown_{val}' name = f'unknown_{val}'

76
discord/ext/commands/context.py

@ -48,7 +48,7 @@ if TYPE_CHECKING:
from discord.mentions import AllowedMentions from discord.mentions import AllowedMentions
from discord.sticker import GuildSticker, StickerItem from discord.sticker import GuildSticker, StickerItem
from discord.message import MessageReference, PartialMessage 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.types.interactions import ApplicationCommandInteractionData
from discord.poll import Poll from discord.poll import Poll
@ -628,6 +628,40 @@ class Context(discord.abc.Messageable, Generic[BotT]):
except CommandError as e: except CommandError as e:
await cmd.on_help_command_error(self, 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 @overload
async def reply( async def reply(
self, self,
@ -817,6 +851,40 @@ class Context(discord.abc.Messageable, Generic[BotT]):
if self.interaction: if self.interaction:
await self.interaction.response.defer(ephemeral=ephemeral) 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 @overload
async def send( async def send(
self, self,
@ -920,7 +988,7 @@ class Context(discord.abc.Messageable, Generic[BotT]):
allowed_mentions: Optional[AllowedMentions] = None, allowed_mentions: Optional[AllowedMentions] = None,
reference: Optional[Union[Message, MessageReference, PartialMessage]] = None, reference: Optional[Union[Message, MessageReference, PartialMessage]] = None,
mention_author: Optional[bool] = None, mention_author: Optional[bool] = None,
view: Optional[View] = None, view: Optional[BaseView] = None,
suppress_embeds: bool = False, suppress_embeds: bool = False,
ephemeral: bool = False, ephemeral: bool = False,
silent: bool = False, silent: bool = False,
@ -986,10 +1054,12 @@ class Context(discord.abc.Messageable, Generic[BotT]):
This is ignored for interaction based contexts. This is ignored for interaction based contexts.
.. versionadded:: 1.6 .. 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. A Discord UI View to add to the message.
.. versionadded:: 2.0 .. versionadded:: 2.0
.. versionchanged:: 2.6
This now accepts :class:`discord.ui.LayoutView` instances.
embeds: List[:class:`~discord.Embed`] embeds: List[:class:`~discord.Embed`]
A list of embeds to upload. Must be a maximum of 10. A list of embeds to upload. Must be a maximum of 10.

10
discord/flags.py

@ -499,6 +499,16 @@ class MessageFlags(BaseFlags):
""" """
return 16384 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() @fill_with_flags()
class PublicUserFlags(BaseFlags): class PublicUserFlags(BaseFlags):

12
discord/http.py

@ -57,16 +57,16 @@ from .file import File
from .mentions import AllowedMentions from .mentions import AllowedMentions
from . import __version__, utils from . import __version__, utils
from .utils import MISSING from .utils import MISSING
from .flags import MessageFlags
_log = logging.getLogger(__name__) _log = logging.getLogger(__name__)
if TYPE_CHECKING: if TYPE_CHECKING:
from typing_extensions import Self from typing_extensions import Self
from .ui.view import View from .ui.view import BaseView
from .embeds import Embed from .embeds import Embed
from .message import Attachment from .message import Attachment
from .flags import MessageFlags
from .poll import Poll from .poll import Poll
from .types import ( from .types import (
@ -150,7 +150,7 @@ def handle_message_parameters(
embed: Optional[Embed] = MISSING, embed: Optional[Embed] = MISSING,
embeds: Sequence[Embed] = MISSING, embeds: Sequence[Embed] = MISSING,
attachments: Sequence[Union[Attachment, File]] = MISSING, attachments: Sequence[Union[Attachment, File]] = MISSING,
view: Optional[View] = MISSING, view: Optional[BaseView] = MISSING,
allowed_mentions: Optional[AllowedMentions] = MISSING, allowed_mentions: Optional[AllowedMentions] = MISSING,
message_reference: Optional[message.MessageReference] = MISSING, message_reference: Optional[message.MessageReference] = MISSING,
stickers: Optional[SnowflakeList] = MISSING, stickers: Optional[SnowflakeList] = MISSING,
@ -193,6 +193,12 @@ def handle_message_parameters(
if view is not MISSING: if view is not MISSING:
if view is not None: if view is not None:
payload['components'] = view.to_components() 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: else:
payload['components'] = [] payload['components'] = []

133
discord/interactions.py

@ -27,7 +27,7 @@ DEALINGS IN THE SOFTWARE.
from __future__ import annotations from __future__ import annotations
import logging 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 asyncio
import datetime import datetime
@ -76,7 +76,7 @@ if TYPE_CHECKING:
from .mentions import AllowedMentions from .mentions import AllowedMentions
from aiohttp import ClientSession from aiohttp import ClientSession
from .embeds import Embed from .embeds import Embed
from .ui.view import View from .ui.view import BaseView, View, LayoutView
from .app_commands.models import Choice, ChoiceT from .app_commands.models import Choice, ChoiceT
from .ui.modal import Modal from .ui.modal import Modal
from .channel import VoiceChannel, StageChannel, TextChannel, ForumChannel, CategoryChannel, DMChannel, GroupChannel from .channel import VoiceChannel, StageChannel, TextChannel, ForumChannel, CategoryChannel, DMChannel, GroupChannel
@ -469,6 +469,17 @@ class Interaction(Generic[ClientT]):
self._original_response = message self._original_response = message
return 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( async def edit_original_response(
self, self,
*, *,
@ -479,6 +490,19 @@ class Interaction(Generic[ClientT]):
view: Optional[View] = MISSING, view: Optional[View] = MISSING,
allowed_mentions: Optional[AllowedMentions] = None, allowed_mentions: Optional[AllowedMentions] = None,
poll: Poll = MISSING, 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: ) -> InteractionMessage:
"""|coro| """|coro|
@ -889,6 +913,22 @@ class InteractionResponse(Generic[ClientT]):
) )
self._response_type = InteractionResponseType.pong 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( async def send_message(
self, self,
content: Optional[Any] = None, content: Optional[Any] = None,
@ -905,6 +945,25 @@ class InteractionResponse(Generic[ClientT]):
silent: bool = False, silent: bool = False,
delete_after: Optional[float] = None, delete_after: Optional[float] = None,
poll: Poll = MISSING, 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]: ) -> InteractionCallbackResponse[ClientT]:
"""|coro| """|coro|
@ -929,8 +988,11 @@ class InteractionResponse(Generic[ClientT]):
A list of files to upload. Must be a maximum of 10. A list of files to upload. Must be a maximum of 10.
tts: :class:`bool` tts: :class:`bool`
Indicates if the message should be sent using text-to-speech. 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. The view to send with the message.
.. versionchanged:: 2.6
This now accepts :class:`discord.ui.LayoutView` instances.
ephemeral: :class:`bool` ephemeral: :class:`bool`
Indicates if the message should only be visible to the user who started the interaction. 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 If a view is sent with an ephemeral message and it has no timeout set then the timeout
@ -1039,6 +1101,19 @@ class InteractionResponse(Generic[ClientT]):
type=self._response_type, 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( async def edit_message(
self, self,
*, *,
@ -1050,6 +1125,20 @@ class InteractionResponse(Generic[ClientT]):
allowed_mentions: Optional[AllowedMentions] = MISSING, allowed_mentions: Optional[AllowedMentions] = MISSING,
delete_after: Optional[float] = None, delete_after: Optional[float] = None,
suppress_embeds: bool = MISSING, 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]]: ) -> Optional[InteractionCallbackResponse[ClientT]]:
"""|coro| """|coro|
@ -1076,9 +1165,12 @@ class InteractionResponse(Generic[ClientT]):
New files will always appear after current attachments. 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 updated view to update this message with. If ``None`` is passed then
the view is removed. the view is removed.
.. versionchanged:: 2.6
This now accepts :class:`~discord.ui.LayoutView` instances.
allowed_mentions: Optional[:class:`~discord.AllowedMentions`] allowed_mentions: Optional[:class:`~discord.AllowedMentions`]
Controls the mentions being processed in this message. See :meth:`.Message.edit` Controls the mentions being processed in this message. See :meth:`.Message.edit`
for more information. for more information.
@ -1327,6 +1419,18 @@ class InteractionMessage(Message):
__slots__ = () __slots__ = ()
_state: _InteractionMessageState _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( async def edit(
self, self,
*, *,
@ -1338,6 +1442,20 @@ class InteractionMessage(Message):
allowed_mentions: Optional[AllowedMentions] = None, allowed_mentions: Optional[AllowedMentions] = None,
delete_after: Optional[float] = None, delete_after: Optional[float] = None,
poll: Poll = MISSING, 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: ) -> InteractionMessage:
"""|coro| """|coro|
@ -1363,9 +1481,12 @@ class InteractionMessage(Message):
allowed_mentions: :class:`AllowedMentions` allowed_mentions: :class:`AllowedMentions`
Controls the mentions being processed in this message. Controls the mentions being processed in this message.
See :meth:`.abc.Messageable.send` for more information. 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 updated view to update this message with. If ``None`` is passed then
the view is removed. the view is removed.
.. versionchanged:: 2.6
This now accepts :class:`~discord.ui.LayoutView` instances.
delete_after: Optional[:class:`float`] delete_after: Optional[:class:`float`]
If provided, the number of seconds to wait in the background If provided, the number of seconds to wait in the background
before deleting the message we just sent. If the deletion fails, before deleting the message we just sent. If the deletion fails,
@ -1403,7 +1524,7 @@ class InteractionMessage(Message):
embeds=embeds, embeds=embeds,
embed=embed, embed=embed,
attachments=attachments, attachments=attachments,
view=view, view=view, # type: ignore
allowed_mentions=allowed_mentions, allowed_mentions=allowed_mentions,
poll=poll, poll=poll,
) )

65
discord/message.py

@ -96,15 +96,14 @@ if TYPE_CHECKING:
from .types.gateway import MessageReactionRemoveEvent, MessageUpdateEvent from .types.gateway import MessageReactionRemoveEvent, MessageUpdateEvent
from .abc import Snowflake from .abc import Snowflake
from .abc import GuildChannel, MessageableChannel from .abc import GuildChannel, MessageableChannel
from .components import ActionRow, ActionRowChildComponentType from .components import MessageComponentType
from .state import ConnectionState from .state import ConnectionState
from .mentions import AllowedMentions from .mentions import AllowedMentions
from .user import User from .user import User
from .role import Role from .role import Role
from .ui.view import View from .ui.view import BaseView, View, LayoutView
EmojiInputType = Union[Emoji, PartialEmoji, str] EmojiInputType = Union[Emoji, PartialEmoji, str]
MessageComponentType = Union[ActionRow, ActionRowChildComponentType]
__all__ = ( __all__ = (
@ -489,7 +488,7 @@ class MessageSnapshot:
Extra features of the the message snapshot. Extra features of the the message snapshot.
stickers: List[:class:`StickerItem`] stickers: List[:class:`StickerItem`]
A list of sticker items given to the message. 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. A list of components in the message.
""" """
@ -533,7 +532,7 @@ class MessageSnapshot:
self.components: List[MessageComponentType] = [] self.components: List[MessageComponentType] = []
for component_data in data.get('components', []): 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: if component is not None:
self.components.append(component) self.components.append(component)
@ -1303,6 +1302,17 @@ class PartialMessage(Hashable):
else: else:
await self._state.http.delete_message(self.channel.id, self.id) 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 @overload
async def edit( async def edit(
self, self,
@ -1338,7 +1348,7 @@ class PartialMessage(Hashable):
attachments: Sequence[Union[Attachment, File]] = MISSING, attachments: Sequence[Union[Attachment, File]] = MISSING,
delete_after: Optional[float] = None, delete_after: Optional[float] = None,
allowed_mentions: Optional[AllowedMentions] = MISSING, allowed_mentions: Optional[AllowedMentions] = MISSING,
view: Optional[View] = MISSING, view: Optional[BaseView] = MISSING,
) -> Message: ) -> Message:
"""|coro| """|coro|
@ -1388,10 +1398,13 @@ class PartialMessage(Hashable):
are used instead. are used instead.
.. versionadded:: 1.4 .. 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 updated view to update this message with. If ``None`` is passed then
the view is removed. the view is removed.
.. versionchanged:: 2.6
This now accepts :class:`~discord.ui.LayoutView` instances.
Raises Raises
------- -------
HTTPException HTTPException
@ -1753,6 +1766,38 @@ class PartialMessage(Hashable):
return await self.guild.fetch_channel(self.id) # type: ignore # Can only be Thread in this case 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 @overload
async def reply( async def reply(
self, self,
@ -2853,7 +2898,7 @@ class Message(PartialMessage, Hashable):
suppress: bool = ..., suppress: bool = ...,
delete_after: Optional[float] = ..., delete_after: Optional[float] = ...,
allowed_mentions: Optional[AllowedMentions] = ..., allowed_mentions: Optional[AllowedMentions] = ...,
view: Optional[View] = ..., view: Optional[BaseView] = ...,
) -> Message: ) -> Message:
... ...
@ -2867,7 +2912,7 @@ class Message(PartialMessage, Hashable):
suppress: bool = ..., suppress: bool = ...,
delete_after: Optional[float] = ..., delete_after: Optional[float] = ...,
allowed_mentions: Optional[AllowedMentions] = ..., allowed_mentions: Optional[AllowedMentions] = ...,
view: Optional[View] = ..., view: Optional[BaseView] = ...,
) -> Message: ) -> Message:
... ...
@ -2881,7 +2926,7 @@ class Message(PartialMessage, Hashable):
suppress: bool = False, suppress: bool = False,
delete_after: Optional[float] = None, delete_after: Optional[float] = None,
allowed_mentions: Optional[AllowedMentions] = MISSING, allowed_mentions: Optional[AllowedMentions] = MISSING,
view: Optional[View] = MISSING, view: Optional[BaseView] = MISSING,
) -> Message: ) -> Message:
"""|coro| """|coro|

8
discord/state.py

@ -71,7 +71,7 @@ from .flags import ApplicationFlags, Intents, MemberCacheFlags
from .invite import Invite from .invite import Invite
from .integrations import _integration_factory from .integrations import _integration_factory
from .interactions import Interaction from .interactions import Interaction
from .ui.view import ViewStore, View from .ui.view import ViewStore, BaseView
from .scheduled_event import ScheduledEvent from .scheduled_event import ScheduledEvent
from .stage_instance import StageInstance from .stage_instance import StageInstance
from .threads import Thread, ThreadMember from .threads import Thread, ThreadMember
@ -412,12 +412,12 @@ class ConnectionState(Generic[ClientT]):
self._stickers[sticker_id] = sticker = GuildSticker(state=self, data=data) self._stickers[sticker_id] = sticker = GuildSticker(state=self, data=data)
return sticker 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: if interaction_id is not None:
self._view_store.remove_interaction_mapping(interaction_id) self._view_store.remove_interaction_mapping(interaction_id)
self._view_store.add_view(view, message_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) return self._view_store.remove_message_tracking(message_id)
def store_dynamic_items(self, *items: Type[DynamicItem[Item[Any]]]) -> None: 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) self._view_store.remove_dynamic_items(*items)
@property @property
def persistent_views(self) -> Sequence[View]: def persistent_views(self) -> Sequence[BaseView]:
return self._view_store.persistent_views return self._view_store.persistent_views
@property @property

91
discord/types/components.py

@ -24,24 +24,31 @@ DEALINGS IN THE SOFTWARE.
from __future__ import annotations 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 typing_extensions import NotRequired
from .emoji import PartialEmoji from .emoji import PartialEmoji
from .channel import ChannelType 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] ButtonStyle = Literal[1, 2, 3, 4, 5, 6]
TextStyle = Literal[1, 2] TextStyle = Literal[1, 2]
DefaultValueType = Literal['user', 'role', 'channel'] 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] type: Literal[1]
components: List[ActionRowChildComponent] components: List[ActionRowChildComponent]
class ButtonComponent(TypedDict): class ButtonComponent(ComponentBase):
type: Literal[2] type: Literal[2]
style: ButtonStyle style: ButtonStyle
custom_id: NotRequired[str] custom_id: NotRequired[str]
@ -60,7 +67,7 @@ class SelectOption(TypedDict):
emoji: NotRequired[PartialEmoji] emoji: NotRequired[PartialEmoji]
class SelectComponent(TypedDict): class SelectComponent(ComponentBase):
custom_id: str custom_id: str
placeholder: NotRequired[str] placeholder: NotRequired[str]
min_values: NotRequired[int] min_values: NotRequired[int]
@ -99,7 +106,7 @@ class ChannelSelectComponent(SelectComponent):
default_values: NotRequired[List[SelectDefaultValues]] default_values: NotRequired[List[SelectDefaultValues]]
class TextInput(TypedDict): class TextInput(ComponentBase):
type: Literal[4] type: Literal[4]
custom_id: str custom_id: str
style: TextStyle style: TextStyle
@ -118,5 +125,75 @@ class SelectMenu(SelectComponent):
default_values: NotRequired[List[SelectDefaultValues]] 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
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[Optional[str]]
spoiler: NotRequired[bool]
class MediaGalleryComponent(ComponentBase):
type: Literal[12]
items: List[MediaGalleryItem]
class FileComponent(ComponentBase):
type: Literal[13]
file: UnfurledMediaItem
spoiler: NotRequired[bool]
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] ActionRowChildComponent = Union[ButtonComponent, SelectMenu, TextInput]
Component = Union[ActionRow, ActionRowChildComponent] ContainerChildComponent = Union[
ActionRow,
TextComponent,
MediaGalleryComponent,
FileComponent,
SectionComponent,
SectionComponent,
ContainerComponent,
SeparatorComponent,
ThumbnailComponent,
]
Component = Union[ActionRowChildComponent, ContainerChildComponent]

6
discord/types/message.py

@ -33,7 +33,7 @@ from .user import User
from .emoji import PartialEmoji from .emoji import PartialEmoji
from .embed import Embed from .embed import Embed
from .channel import ChannelType from .channel import ChannelType
from .components import Component from .components import ComponentBase
from .interactions import MessageInteraction, MessageInteractionMetadata from .interactions import MessageInteraction, MessageInteractionMetadata
from .sticker import StickerItem from .sticker import StickerItem
from .threads import Thread from .threads import Thread
@ -189,7 +189,7 @@ class MessageSnapshot(TypedDict):
mentions: List[UserWithMember] mentions: List[UserWithMember]
mention_roles: SnowflakeList mention_roles: SnowflakeList
sticker_items: NotRequired[List[StickerItem]] sticker_items: NotRequired[List[StickerItem]]
components: NotRequired[List[Component]] components: NotRequired[List[ComponentBase]]
class Message(PartialMessage): class Message(PartialMessage):
@ -221,7 +221,7 @@ class Message(PartialMessage):
referenced_message: NotRequired[Optional[Message]] referenced_message: NotRequired[Optional[Message]]
interaction: NotRequired[MessageInteraction] # deprecated, use interaction_metadata interaction: NotRequired[MessageInteraction] # deprecated, use interaction_metadata
interaction_metadata: NotRequired[MessageInteractionMetadata] interaction_metadata: NotRequired[MessageInteractionMetadata]
components: NotRequired[List[Component]] components: NotRequired[List[ComponentBase]]
position: NotRequired[int] position: NotRequired[int]
role_subscription_data: NotRequired[RoleSubscriptionData] role_subscription_data: NotRequired[RoleSubscriptionData]
thread: NotRequired[Thread] thread: NotRequired[Thread]

8
discord/ui/__init__.py

@ -16,3 +16,11 @@ from .button import *
from .select import * from .select import *
from .text_input import * from .text_input import *
from .dynamic 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 *

599
discord/ui/action_row.py

@ -0,0 +1,599 @@
"""
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 itertools import groupby
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 .dynamic import DynamicItem
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]):
"""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
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 _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_store_data(self, dispatch_info: Dict, dynamic_items: Dict) -> bool:
is_fully_dynamic = True
for item in self._children:
if isinstance(item, DynamicItem):
pattern = item.__discord_ui_compiled_template__
dynamic_items[pattern] = item.__class__
elif item.is_dispatchable():
dispatch_info[(item.type.value, item.custom_id)] = item
is_fully_dynamic = False
return is_fully_dynamic
def is_dispatchable(self) -> bool:
return any(c.is_dispatchable() for c in self.children)
def is_persistent(self) -> bool:
return all(c.is_persistent() for c in self.children)
def _update_children_view(self, view: LayoutView) -> None:
for child in self._children:
child._view = view # pyright: ignore[reportAttributeAccessIssue]
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
if item.is_dispatchable() and self._parent and getattr(self._parent, '__discord_ui_container__', False):
self._parent._add_dispatchable(item) # type: ignore
return self
def remove_item(self, item: Item[Any]) -> Self:
"""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_by_id(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._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 _, cmps in groupby(self._children, key=key):
components.extend(c.to_component_dict() for c in cmps)
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()
for cmp in component.children:
self.add_item(_component_to_item(cmp))
return self

24
discord/ui/button.py

@ -42,11 +42,12 @@ __all__ = (
if TYPE_CHECKING: if TYPE_CHECKING:
from typing_extensions import Self from typing_extensions import Self
from .view import View from .view import BaseView
from .action_row import ActionRow
from ..emoji import Emoji from ..emoji import Emoji
from ..types.components import ButtonComponent as ButtonComponentPayload 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]): class Button(Item[V]):
@ -82,6 +83,10 @@ class Button(Item[V]):
nor ``custom_id``. nor ``custom_id``.
.. versionadded:: 2.4 .. 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, ...] = ( __item_repr_attributes__: Tuple[str, ...] = (
@ -105,6 +110,7 @@ class Button(Item[V]):
emoji: Optional[Union[str, Emoji, PartialEmoji]] = None, emoji: Optional[Union[str, Emoji, PartialEmoji]] = None,
row: Optional[int] = None, row: Optional[int] = None,
sku_id: Optional[int] = None, sku_id: Optional[int] = None,
id: Optional[int] = None,
): ):
super().__init__() super().__init__()
if custom_id is not None and (url is not None or sku_id is not None): if custom_id is not None and (url is not None or sku_id is not None):
@ -143,8 +149,11 @@ class Button(Item[V]):
style=style, style=style,
emoji=emoji, emoji=emoji,
sku_id=sku_id, sku_id=sku_id,
id=id,
) )
self._parent: Optional[ActionRow] = None
self.row = row self.row = row
self.id = id
@property @property
def style(self) -> ButtonStyle: def style(self) -> ButtonStyle:
@ -242,6 +251,7 @@ class Button(Item[V]):
emoji=button.emoji, emoji=button.emoji,
row=None, row=None,
sku_id=button.sku_id, sku_id=button.sku_id,
id=button.id,
) )
@property @property
@ -271,7 +281,8 @@ def button(
style: ButtonStyle = ButtonStyle.secondary, style: ButtonStyle = ButtonStyle.secondary,
emoji: Optional[Union[str, Emoji, PartialEmoji]] = None, emoji: Optional[Union[str, Emoji, PartialEmoji]] = None,
row: Optional[int] = 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. """A decorator that attaches a button to a component.
The function being decorated should have three parameters, ``self`` representing The function being decorated should have three parameters, ``self`` representing
@ -308,9 +319,13 @@ def button(
like to control the relative positioning of the row then passing an index is advised. 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 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). 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): if not inspect.iscoroutinefunction(func):
raise TypeError('button function must be a coroutine function') raise TypeError('button function must be a coroutine function')
@ -324,6 +339,7 @@ def button(
'emoji': emoji, 'emoji': emoji,
'row': row, 'row': row,
'sku_id': None, 'sku_id': None,
'id': id,
} }
return func return func

449
discord/ui/container.py

@ -0,0 +1,449 @@
"""
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,
Tuple,
Type,
TypeVar,
Union,
)
from .item import Item, ItemCallbackType
from .view import _component_to_item, LayoutView
from .dynamic import DynamicItem
from ..enums import ComponentType
from ..utils import MISSING, get as _utils_get
from ..colour import Colour, Color
if TYPE_CHECKING:
from typing_extensions import Self
from ..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]):
"""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
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.__dispatchable: List[Item[V]] = []
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 _add_dispatchable(self, item: Item[Any]) -> None:
self.__dispatchable.append(item)
def _remove_dispatchable(self, item: Item[Any]) -> None:
try:
self.__dispatchable.remove(item)
except ValueError:
pass
def _init_children(self) -> List[Item[Any]]:
children = []
parents = {}
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):
if item.is_dispatchable():
self.__dispatchable.extend(item._children) # type: ignore
if getattr(item, '__discord_ui_section__', False):
if item.accessory.is_dispatchable(): # type: ignore
if item.accessory._provided_custom_id is False: # type: ignore
item.accessory.custom_id = os.urandom(16).hex() # type: ignore
self.__dispatchable.append(item.accessory) # type: ignore
setattr(self, name, item)
children.append(item)
parents[raw] = item
else:
# action rows can be created inside containers, and then callbacks can exist here
# so we create items based off them
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.
self.__dispatchable.append(item)
return children
def is_dispatchable(self) -> bool:
return bool(self.__dispatchable)
def is_persistent(self) -> bool:
return all(c.is_persistent() for c in self.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 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
def _update_store_data(
self,
dispatch_info: Dict[Tuple[int, str], Item[Any]],
dynamic_items: Dict[Any, Type[DynamicItem]],
) -> bool:
is_fully_dynamic = True
for item in self.__dispatchable:
if isinstance(item, DynamicItem):
pattern = item.__discord_ui_compiled_template__
dynamic_items[pattern] = item.__class__
elif item.is_dispatchable():
dispatch_info[(item.type.value, item.custom_id)] = item
is_fully_dynamic = False
return is_fully_dynamic
@classmethod
def from_component(cls, component: ContainerComponent) -> Self:
return cls(
*[_component_to_item(c) for c in component.children],
accent_colour=component.accent_colour,
spoiler=component.spoiler,
id=component.id,
)
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)
if item.is_dispatchable():
if getattr(item, '__discord_ui_section__', False):
self.__dispatchable.append(item.accessory) # type: ignore
elif getattr(item, '__discord_ui_action_row__', False):
self.__dispatchable.extend([i for i in item._children if i.is_dispatchable()]) # type: ignore
else:
self.__dispatchable.append(item)
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 item.is_dispatchable():
if getattr(item, '__discord_ui_section__', False):
self._remove_dispatchable(item.accessory) # type: ignore
elif getattr(item, '__discord_ui_action_row__', False):
for c in item._children: # type: ignore
if not c.is_dispatchable():
continue
self._remove_dispatchable(c)
else:
self._remove_dispatchable(item)
if self._view and getattr(self._view, '__discord_ui_layout_view__', False):
if getattr(item, '__discord_ui_update_view__', False):
self._view._total_children -= len(tuple(item.walk_children())) # type: ignore
else:
self._view._total_children -= 1
return self
def get_item_by_id(self, id: int, /) -> Optional[Item[V]]:
"""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._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()
self.__dispatchable.clear()
return self

19
discord/ui/dynamic.py

@ -38,14 +38,14 @@ if TYPE_CHECKING:
from ..interactions import Interaction from ..interactions import Interaction
from ..components import Component from ..components import Component
from ..enums import ComponentType from ..enums import ComponentType
from .view import View from .view import BaseView
V = TypeVar('V', bound='View', covariant=True, default=View) V = TypeVar('V', bound='BaseView', covariant=True, default=BaseView)
else: else:
V = TypeVar('V', bound='View', covariant=True) V = TypeVar('V', bound='BaseView', covariant=True)
class DynamicItem(Generic[BaseT], Item['View']): class DynamicItem(Generic[BaseT, V], Item[V]):
"""Represents an item with a dynamic ``custom_id`` that can be used to store state within """Represents an item with a dynamic ``custom_id`` that can be used to store state within
that ``custom_id``. that ``custom_id``.
@ -57,9 +57,10 @@ class DynamicItem(Generic[BaseT], Item['View']):
and should not be used long term. Their only purpose is to act as a "template" and should not be used long term. Their only purpose is to act as a "template"
for the actual dispatched item. for the actual dispatched item.
When this item is generated, :attr:`view` is set to a regular :class:`View` instance 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 but to a :class:`LayoutView` if the component was sent with one, this is obtained from
subclasses cannot be accessed from this item. the original message given from the interaction. This means that custom view subclasses
cannot be accessed from this item.
.. versionadded:: 2.4 .. versionadded:: 2.4
@ -144,7 +145,7 @@ class DynamicItem(Generic[BaseT], Item['View']):
@property @property
def custom_id(self) -> str: def custom_id(self) -> str:
""":class:`str`: The ID of the dynamic item that gets received during an interaction.""" """:class:`str`: The ID of the dynamic item that gets received during an interaction."""
return self.item.custom_id # type: ignore # This attribute exists for dispatchable items return self.item.custom_id
@custom_id.setter @custom_id.setter
def custom_id(self, value: str) -> None: def custom_id(self, value: str) -> None:
@ -154,7 +155,7 @@ class DynamicItem(Generic[BaseT], Item['View']):
if not self.template.match(value): if not self.template.match(value):
raise ValueError(f'custom_id must match the template {self.template.pattern!r}') raise ValueError(f'custom_id must match the template {self.template.pattern!r}')
self.item.custom_id = value # type: ignore # This attribute exists for dispatchable items self.item.custom_id = value
self._provided_custom_id = True self._provided_custom_id = True
@property @property

145
discord/ui/file.py

@ -0,0 +1,145 @@
"""
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://<filename>`` 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.
"""
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,
)

54
discord/ui/item.py

@ -24,6 +24,7 @@ DEALINGS IN THE SOFTWARE.
from __future__ import annotations from __future__ import annotations
import os
from typing import Any, Callable, Coroutine, Dict, Generic, Optional, TYPE_CHECKING, Tuple, Type, TypeVar from typing import Any, Callable, Coroutine, Dict, Generic, Optional, TYPE_CHECKING, Tuple, Type, TypeVar
from ..interactions import Interaction from ..interactions import Interaction
@ -37,12 +38,12 @@ __all__ = (
if TYPE_CHECKING: if TYPE_CHECKING:
from ..enums import ComponentType from ..enums import ComponentType
from .view import View from .view import BaseView
from ..components import Component from ..components import Component
I = TypeVar('I', bound='Item[Any]') I = TypeVar('I', bound='Item[Any]')
V = TypeVar('V', bound='View', covariant=True) V = TypeVar('V', bound='BaseView', covariant=True)
ItemCallbackType = Callable[[V, Interaction[Any], I], Coroutine[Any, Any, Any]] ItemCallbackType = Callable[[Any, Interaction[Any], I], Coroutine[Any, Any, Any]]
class Item(Generic[V]): class Item(Generic[V]):
@ -53,6 +54,14 @@ class Item(Generic[V]):
- :class:`discord.ui.Button` - :class:`discord.ui.Button`
- :class:`discord.ui.Select` - :class:`discord.ui.Select`
- :class:`discord.ui.TextInput` - :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 .. versionadded:: 2.0
""" """
@ -70,6 +79,14 @@ class Item(Generic[V]):
# actually affect the intended purpose of this check because from_component is # 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. # only called upon edit and we're mainly interested during initial creation time.
self._provided_custom_id: bool = False 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
if self._is_v2():
# this is done so v2 components can be stored on ViewStore._views
# and does not break v1 components custom_id property
self.custom_id: str = os.urandom(16).hex()
def to_component_dict(self) -> Dict[str, Any]: def to_component_dict(self) -> Dict[str, Any]:
raise NotImplementedError raise NotImplementedError
@ -80,6 +97,9 @@ class Item(Generic[V]):
def _refresh_state(self, interaction: Interaction, data: Dict[str, Any]) -> None: def _refresh_state(self, interaction: Interaction, data: Dict[str, Any]) -> None:
return None return None
def _is_v2(self) -> bool:
return False
@classmethod @classmethod
def from_component(cls: Type[I], component: Component) -> I: def from_component(cls: Type[I], component: Component) -> I:
return cls() return cls()
@ -92,7 +112,9 @@ class Item(Generic[V]):
return False return False
def is_persistent(self) -> bool: 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: def __repr__(self) -> str:
attrs = ' '.join(f'{key}={getattr(self, key)!r}' for key in self.__item_repr_attributes__) attrs = ' '.join(f'{key}={getattr(self, key)!r}' for key in self.__item_repr_attributes__)
@ -106,10 +128,13 @@ class Item(Generic[V]):
def row(self, value: Optional[int]) -> None: def row(self, value: Optional[int]) -> None:
if value is None: if value is None:
self._row = None self._row = None
elif 5 > value >= 0: elif self._max_row > value >= 0:
self._row = value self._row = value
else: 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 @property
def width(self) -> int: def width(self) -> int:
@ -120,6 +145,23 @@ class Item(Generic[V]):
"""Optional[:class:`View`]: The underlying view for this item.""" """Optional[:class:`View`]: The underlying view for this item."""
return self._view 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
async def callback(self, interaction: Interaction[ClientT]) -> Any: async def callback(self, interaction: Interaction[ClientT]) -> Any:
"""|coro| """|coro|

254
discord/ui/media_gallery.py

@ -0,0 +1,254 @@
"""
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]):
"""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.
"""
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
@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://<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. 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://<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. 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,
)

262
discord/ui/section.py

@ -0,0 +1,262 @@
"""
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 itertools import groupby
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.
"""
__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
@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
# Accessory can be a button, and thus it can have a callback so, maybe
# allow for section to be dispatchable and make the callback func
# be accessory component callback, only called if accessory is
# dispatchable?
def is_dispatchable(self) -> bool:
return self.accessory.is_dispatchable()
def is_persistent(self) -> bool:
return self.accessory.is_persistent()
def walk_children(self) -> Generator[Item[V], None, None]:
"""An iterator that recursively walks through all the children of this section.
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_by_id(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._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 # >circular import<
return cls(
*[_component_to_item(c) for c in component.components],
accessory=_component_to_item(component.accessory),
id=component.id,
)
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 _, comps in groupby(self._children, key=key):
components.extend(c.to_component_dict() for c in comps)
return components
def to_component_dict(self) -> Dict[str, Any]:
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

67
discord/ui/select.py

@ -72,7 +72,8 @@ __all__ = (
if TYPE_CHECKING: if TYPE_CHECKING:
from typing_extensions import TypeAlias, TypeGuard from typing_extensions import TypeAlias, TypeGuard
from .view import View from .view import BaseView
from .action_row import ActionRow
from ..types.components import SelectMenu as SelectMenuPayload from ..types.components import SelectMenu as SelectMenuPayload
from ..types.interactions import SelectMessageComponentInteractionData from ..types.interactions import SelectMessageComponentInteractionData
from ..app_commands import AppCommandChannel, AppCommandThread from ..app_commands import AppCommandChannel, AppCommandThread
@ -101,14 +102,14 @@ if TYPE_CHECKING:
Thread, Thread,
] ]
V = TypeVar('V', bound='View', covariant=True) V = TypeVar('V', bound='BaseView', covariant=True)
BaseSelectT = TypeVar('BaseSelectT', bound='BaseSelect[Any]') BaseSelectT = TypeVar('BaseSelectT', bound='BaseSelect[Any]')
SelectT = TypeVar('SelectT', bound='Select[Any]') SelectT = TypeVar('SelectT', bound='Select[Any]')
UserSelectT = TypeVar('UserSelectT', bound='UserSelect[Any]') UserSelectT = TypeVar('UserSelectT', bound='UserSelect[Any]')
RoleSelectT = TypeVar('RoleSelectT', bound='RoleSelect[Any]') RoleSelectT = TypeVar('RoleSelectT', bound='RoleSelect[Any]')
ChannelSelectT = TypeVar('ChannelSelectT', bound='ChannelSelect[Any]') ChannelSelectT = TypeVar('ChannelSelectT', bound='ChannelSelect[Any]')
MentionableSelectT = TypeVar('MentionableSelectT', bound='MentionableSelect[Any]') MentionableSelectT = TypeVar('MentionableSelectT', bound='MentionableSelect[Any]')
SelectCallbackDecorator: TypeAlias = Callable[[ItemCallbackType[V, BaseSelectT]], BaseSelectT] SelectCallbackDecorator: TypeAlias = Callable[[ItemCallbackType[BaseSelectT]], BaseSelectT]
DefaultSelectComponentTypes = Literal[ DefaultSelectComponentTypes = Literal[
ComponentType.user_select, ComponentType.user_select,
ComponentType.role_select, ComponentType.role_select,
@ -223,6 +224,7 @@ class BaseSelect(Item[V]):
'min_values', 'min_values',
'max_values', 'max_values',
'disabled', 'disabled',
'id',
) )
def __init__( def __init__(
@ -238,6 +240,7 @@ class BaseSelect(Item[V]):
options: List[SelectOption] = MISSING, options: List[SelectOption] = MISSING,
channel_types: List[ChannelType] = MISSING, channel_types: List[ChannelType] = MISSING,
default_values: Sequence[SelectDefaultValue] = MISSING, default_values: Sequence[SelectDefaultValue] = MISSING,
id: Optional[int] = None,
) -> None: ) -> None:
super().__init__() super().__init__()
self._provided_custom_id = custom_id is not MISSING self._provided_custom_id = custom_id is not MISSING
@ -255,9 +258,12 @@ class BaseSelect(Item[V]):
channel_types=[] if channel_types is MISSING else channel_types, channel_types=[] if channel_types is MISSING else channel_types,
options=[] if options is MISSING else options, options=[] if options is MISSING else options,
default_values=[] if default_values is MISSING else default_values, default_values=[] if default_values is MISSING else default_values,
id=id,
) )
self.row = row self.row = row
self.id = id
self._parent: Optional[ActionRow] = None
self._values: List[PossibleValue] = [] self._values: List[PossibleValue] = []
@property @property
@ -390,6 +396,10 @@ class Select(BaseSelect[V]):
like to control the relative positioning of the row then passing an index is advised. 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 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). 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',) __component_attributes__ = BaseSelect.__component_attributes__ + ('options',)
@ -404,6 +414,7 @@ class Select(BaseSelect[V]):
options: List[SelectOption] = MISSING, options: List[SelectOption] = MISSING,
disabled: bool = False, disabled: bool = False,
row: Optional[int] = None, row: Optional[int] = None,
id: Optional[int] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
self.type, self.type,
@ -414,6 +425,7 @@ class Select(BaseSelect[V]):
disabled=disabled, disabled=disabled,
options=options, options=options,
row=row, row=row,
id=id,
) )
@property @property
@ -545,6 +557,10 @@ class UserSelect(BaseSelect[V]):
like to control the relative positioning of the row then passing an index is advised. 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 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). 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',) __component_attributes__ = BaseSelect.__component_attributes__ + ('default_values',)
@ -559,6 +575,7 @@ class UserSelect(BaseSelect[V]):
disabled: bool = False, disabled: bool = False,
row: Optional[int] = None, row: Optional[int] = None,
default_values: Sequence[ValidDefaultValues] = MISSING, default_values: Sequence[ValidDefaultValues] = MISSING,
id: Optional[int] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
self.type, self.type,
@ -569,6 +586,7 @@ class UserSelect(BaseSelect[V]):
disabled=disabled, disabled=disabled,
row=row, row=row,
default_values=_handle_select_defaults(default_values, self.type), default_values=_handle_select_defaults(default_values, self.type),
id=id,
) )
@property @property
@ -637,6 +655,10 @@ class RoleSelect(BaseSelect[V]):
like to control the relative positioning of the row then passing an index is advised. 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 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). 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',) __component_attributes__ = BaseSelect.__component_attributes__ + ('default_values',)
@ -651,6 +673,7 @@ class RoleSelect(BaseSelect[V]):
disabled: bool = False, disabled: bool = False,
row: Optional[int] = None, row: Optional[int] = None,
default_values: Sequence[ValidDefaultValues] = MISSING, default_values: Sequence[ValidDefaultValues] = MISSING,
id: Optional[int] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
self.type, self.type,
@ -661,6 +684,7 @@ class RoleSelect(BaseSelect[V]):
disabled=disabled, disabled=disabled,
row=row, row=row,
default_values=_handle_select_defaults(default_values, self.type), default_values=_handle_select_defaults(default_values, self.type),
id=id,
) )
@property @property
@ -725,6 +749,10 @@ class MentionableSelect(BaseSelect[V]):
like to control the relative positioning of the row then passing an index is advised. 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 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). 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',) __component_attributes__ = BaseSelect.__component_attributes__ + ('default_values',)
@ -739,6 +767,7 @@ class MentionableSelect(BaseSelect[V]):
disabled: bool = False, disabled: bool = False,
row: Optional[int] = None, row: Optional[int] = None,
default_values: Sequence[ValidDefaultValues] = MISSING, default_values: Sequence[ValidDefaultValues] = MISSING,
id: Optional[int] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
self.type, self.type,
@ -749,6 +778,7 @@ class MentionableSelect(BaseSelect[V]):
disabled=disabled, disabled=disabled,
row=row, row=row,
default_values=_handle_select_defaults(default_values, self.type), default_values=_handle_select_defaults(default_values, self.type),
id=id,
) )
@property @property
@ -819,6 +849,10 @@ class ChannelSelect(BaseSelect[V]):
like to control the relative positioning of the row then passing an index is advised. 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 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). 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__ + ( __component_attributes__ = BaseSelect.__component_attributes__ + (
@ -837,6 +871,7 @@ class ChannelSelect(BaseSelect[V]):
disabled: bool = False, disabled: bool = False,
row: Optional[int] = None, row: Optional[int] = None,
default_values: Sequence[ValidDefaultValues] = MISSING, default_values: Sequence[ValidDefaultValues] = MISSING,
id: Optional[int] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
self.type, self.type,
@ -848,6 +883,7 @@ class ChannelSelect(BaseSelect[V]):
row=row, row=row,
channel_types=channel_types, channel_types=channel_types,
default_values=_handle_select_defaults(default_values, self.type), default_values=_handle_select_defaults(default_values, self.type),
id=id,
) )
@property @property
@ -899,7 +935,8 @@ def select(
max_values: int = ..., max_values: int = ...,
disabled: bool = ..., disabled: bool = ...,
row: Optional[int] = ..., row: Optional[int] = ...,
) -> SelectCallbackDecorator[V, SelectT]: id: Optional[int] = ...,
) -> SelectCallbackDecorator[SelectT]:
... ...
@ -916,7 +953,8 @@ def select(
disabled: bool = ..., disabled: bool = ...,
default_values: Sequence[ValidDefaultValues] = ..., default_values: Sequence[ValidDefaultValues] = ...,
row: Optional[int] = ..., row: Optional[int] = ...,
) -> SelectCallbackDecorator[V, UserSelectT]: id: Optional[int] = ...,
) -> SelectCallbackDecorator[UserSelectT]:
... ...
@ -933,7 +971,8 @@ def select(
disabled: bool = ..., disabled: bool = ...,
default_values: Sequence[ValidDefaultValues] = ..., default_values: Sequence[ValidDefaultValues] = ...,
row: Optional[int] = ..., row: Optional[int] = ...,
) -> SelectCallbackDecorator[V, RoleSelectT]: id: Optional[int] = ...,
) -> SelectCallbackDecorator[RoleSelectT]:
... ...
@ -950,7 +989,8 @@ def select(
disabled: bool = ..., disabled: bool = ...,
default_values: Sequence[ValidDefaultValues] = ..., default_values: Sequence[ValidDefaultValues] = ...,
row: Optional[int] = ..., row: Optional[int] = ...,
) -> SelectCallbackDecorator[V, ChannelSelectT]: id: Optional[int] = ...,
) -> SelectCallbackDecorator[ChannelSelectT]:
... ...
@ -967,7 +1007,8 @@ def select(
disabled: bool = ..., disabled: bool = ...,
default_values: Sequence[ValidDefaultValues] = ..., default_values: Sequence[ValidDefaultValues] = ...,
row: Optional[int] = ..., row: Optional[int] = ...,
) -> SelectCallbackDecorator[V, MentionableSelectT]: id: Optional[int] = ...,
) -> SelectCallbackDecorator[MentionableSelectT]:
... ...
@ -983,7 +1024,8 @@ def select(
disabled: bool = False, disabled: bool = False,
default_values: Sequence[ValidDefaultValues] = MISSING, default_values: Sequence[ValidDefaultValues] = MISSING,
row: Optional[int] = None, row: Optional[int] = None,
) -> SelectCallbackDecorator[V, BaseSelectT]: id: Optional[int] = None,
) -> SelectCallbackDecorator[BaseSelectT]:
"""A decorator that attaches a select menu to a component. """A decorator that attaches a select menu to a component.
The function being decorated should have three parameters, ``self`` representing The function being decorated should have three parameters, ``self`` representing
@ -1062,9 +1104,13 @@ def select(
Number of items must be in range of ``min_values`` and ``max_values``. Number of items must be in range of ``min_values`` and ``max_values``.
.. versionadded:: 2.4 .. 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): if not inspect.iscoroutinefunction(func):
raise TypeError('select function must be a coroutine function') raise TypeError('select function must be a coroutine function')
callback_cls = getattr(cls, '__origin__', cls) callback_cls = getattr(cls, '__origin__', cls)
@ -1080,6 +1126,7 @@ def select(
'min_values': min_values, 'min_values': min_values,
'max_values': max_values, 'max_values': max_values,
'disabled': disabled, 'disabled': disabled,
'id': id,
} }
if issubclass(callback_cls, Select): if issubclass(callback_cls, Select):
func.__discord_ui_model_kwargs__['options'] = options func.__discord_ui_model_kwargs__['options'] = options

127
discord/ui/separator.py

@ -0,0 +1,127 @@
"""
The MIT License (MIT)
Copyright (c) 2015-present Rapptz
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Literal, Optional, TypeVar
from .item import Item
from ..components import SeparatorComponent
from ..enums import SeparatorSize, ComponentType
if TYPE_CHECKING:
from 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:`.SeparatorSize`
The spacing of this separator.
row: Optional[:class:`int`]
The relative row this separator belongs to. By default
items are arranged automatically into those rows. If you'd
like to control the relative positioning of the row then
passing an index is advised. For example, row=1 will show
up before row=2. Defaults to ``None``, which is automatic
ordering. The row number must be between 0 and 39 (i.e. zero indexed)
id: Optional[:class:`int`]
The ID of this component. This must be unique across the view.
"""
def __init__(
self,
*,
visible: bool = True,
spacing: SeparatorSize = SeparatorSize.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) -> SeparatorSize:
""":class:`.SeparatorSize`: The spacing of this separator."""
return self._underlying.spacing
@spacing.setter
def spacing(self, value: SeparatorSize) -> None:
self._underlying.spacing = value
@property
def width(self):
return 5
@property
def type(self) -> Literal[ComponentType.separator]:
return self._underlying.type
def to_component_dict(self):
return self._underlying.to_dict()
@classmethod
def from_component(cls, component: SeparatorComponent) -> Self:
return cls(
visible=component.visible,
spacing=component.spacing,
id=component.id,
)

96
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,
)

8
discord/ui/text_input.py

@ -92,6 +92,10 @@ class TextInput(Item[V]):
like to control the relative positioning of the row then passing an index is advised. 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 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). 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, ...] = ( __item_repr_attributes__: Tuple[str, ...] = (
@ -112,6 +116,7 @@ class TextInput(Item[V]):
min_length: Optional[int] = None, min_length: Optional[int] = None,
max_length: Optional[int] = None, max_length: Optional[int] = None,
row: Optional[int] = None, row: Optional[int] = None,
id: Optional[int] = None,
) -> None: ) -> None:
super().__init__() super().__init__()
self._value: Optional[str] = default self._value: Optional[str] = default
@ -129,8 +134,10 @@ class TextInput(Item[V]):
required=required, required=required,
min_length=min_length, min_length=min_length,
max_length=max_length, max_length=max_length,
id=id,
) )
self.row = row self.row = row
self.id = id
def __str__(self) -> str: def __str__(self) -> str:
return self.value return self.value
@ -241,6 +248,7 @@ class TextInput(Item[V]):
min_length=component.min_length, min_length=component.min_length,
max_length=component.max_length, max_length=component.max_length,
row=None, row=None,
id=component.id,
) )
@property @property

116
discord/ui/thumbnail.py

@ -0,0 +1,116 @@
"""
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.
"""
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,
)

752
discord/ui/view.py

@ -23,7 +23,23 @@ DEALINGS IN THE SOFTWARE.
""" """
from __future__ import annotations 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 functools import partial
from itertools import groupby from itertools import groupby
@ -32,19 +48,35 @@ import logging
import sys import sys
import time import time
import os import os
import copy
from .item import Item, ItemCallbackType from .item import Item, ItemCallbackType
from .dynamic import DynamicItem from .dynamic import DynamicItem
from ..components import ( from ..components import (
Component, Component,
ActionRow as ActionRowComponent, ActionRow as ActionRowComponent,
MediaGalleryItem,
SelectDefaultValue,
UnfurledMediaItem,
_component_factory, _component_factory,
Button as ButtonComponent, Button as ButtonComponent,
SelectMenu as SelectComponent, SelectMenu as SelectComponent,
SectionComponent,
TextDisplay as TextDisplayComponent,
MediaGalleryComponent,
FileComponent,
SeparatorComponent,
ThumbnailComponent,
SelectOption,
) )
from ..utils import get as _utils_get, _get_as_snowflake
from ..enums import SeparatorSize, TextStyle, try_enum, ButtonStyle
from ..emoji import PartialEmoji
# fmt: off # fmt: off
__all__ = ( __all__ = (
'View', 'View',
'LayoutView',
) )
# fmt: on # fmt: on
@ -55,11 +87,13 @@ if TYPE_CHECKING:
from ..interactions import Interaction from ..interactions import Interaction
from ..message import Message 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 ..types.interactions import ModalSubmitComponentInteractionData as ModalSubmitComponentInteractionDataPayload
from ..state import ConnectionState from ..state import ConnectionState
from .modal import Modal from .modal import Modal
ItemLike = Union[ItemCallbackType[Any], Item[Any]]
_log = logging.getLogger(__name__) _log = logging.getLogger(__name__)
@ -73,6 +107,10 @@ def _walk_all_components(components: List[Component]) -> Iterator[Component]:
def _component_to_item(component: Component) -> Item: def _component_to_item(component: Component) -> Item:
if isinstance(component, ActionRowComponent):
from .action_row import ActionRow
return ActionRow.from_component(component)
if isinstance(component, ButtonComponent): if isinstance(component, ButtonComponent):
from .button import Button from .button import Button
@ -81,10 +119,163 @@ def _component_to_item(component: Component) -> Item:
from .select import BaseSelect from .select import BaseSelect
return BaseSelect.from_component(component) return BaseSelect.from_component(component)
if isinstance(component, SectionComponent):
from .section import Section
return Section.from_component(component)
if isinstance(component, TextDisplayComponent):
from .text_display import TextDisplay
return TextDisplay.from_component(component)
if isinstance(component, MediaGalleryComponent):
from .media_gallery import MediaGallery
return MediaGallery.from_component(component)
if isinstance(component, FileComponent):
from .file import File
return File.from_component(component)
if isinstance(component, SeparatorComponent):
from .separator import Separator
return Separator.from_component(component)
if isinstance(component, ThumbnailComponent):
from .thumbnail import Thumbnail
return Thumbnail.from_component(component)
return Item.from_component(component) return Item.from_component(component)
def _component_data_to_item(data: ComponentPayload) -> Item:
if data['type'] == 1:
from .action_row import ActionRow
return ActionRow(
*(_component_data_to_item(c) for c in data['components']),
id=data.get('id'),
)
elif data['type'] == 2:
from .button import Button
emoji = data.get('emoji')
return Button(
style=try_enum(ButtonStyle, data['style']),
custom_id=data.get('custom_id'),
url=data.get('url'),
disabled=data.get('disabled', False),
emoji=PartialEmoji.from_dict(emoji) if emoji else None,
label=data.get('label'),
sku_id=_get_as_snowflake(data, 'sku_id'),
)
elif data['type'] == 3:
from .select import Select
return Select(
custom_id=data['custom_id'],
placeholder=data.get('placeholder'),
min_values=data.get('min_values', 1),
max_values=data.get('max_values', 1),
disabled=data.get('disabled', False),
id=data.get('id'),
options=[SelectOption.from_dict(o) for o in data.get('options', [])],
)
elif data['type'] == 4:
from .text_input import TextInput
return TextInput(
label=data['label'],
style=try_enum(TextStyle, data['style']),
custom_id=data['custom_id'],
placeholder=data.get('placeholder'),
default=data.get('value'),
required=data.get('required', True),
min_length=data.get('min_length'),
max_length=data.get('max_length'),
id=data.get('id'),
)
elif data['type'] in (5, 6, 7, 8):
from .select import (
UserSelect,
RoleSelect,
MentionableSelect,
ChannelSelect,
)
cls_map: Dict[int, Type[Union[UserSelect, RoleSelect, MentionableSelect, ChannelSelect]]] = {
5: UserSelect,
6: RoleSelect,
7: MentionableSelect,
8: ChannelSelect,
}
return cls_map[data['type']](
custom_id=data['custom_id'], # type: ignore # will always be present in this point
placeholder=data.get('placeholder'),
min_values=data.get('min_values', 1),
max_values=data.get('max_values', 1),
disabled=data.get('disabled', False),
default_values=[SelectDefaultValue.from_dict(v) for v in data.get('default_values', [])],
id=data.get('id'),
)
elif data['type'] == 9:
from .section import Section
return Section(
*(_component_data_to_item(c) for c in data['components']),
accessory=_component_data_to_item(data['accessory']),
id=data.get('id'),
)
elif data['type'] == 10:
from .text_display import TextDisplay
return TextDisplay(data['content'], id=data.get('id'))
elif data['type'] == 11:
from .thumbnail import Thumbnail
return Thumbnail(
UnfurledMediaItem._from_data(data['media'], None),
description=data.get('description'),
spoiler=data.get('spoiler', False),
id=data.get('id'),
)
elif data['type'] == 12:
from .media_gallery import MediaGallery
return MediaGallery(
*(MediaGalleryItem._from_data(m, None) for m in data['items']),
id=data.get('id'),
)
elif data['type'] == 13:
from .file import File
return File(
UnfurledMediaItem._from_data(data['file'], None),
spoiler=data.get('spoiler', False),
id=data.get('id'),
)
elif data['type'] == 14:
from .separator import Separator
return Separator(
visible=data.get('divider', True),
spacing=try_enum(SeparatorSize, data.get('spacing', 1)),
id=data.get('id'),
)
elif data['type'] == 17:
from .container import Container
return Container(
*(_component_data_to_item(c) for c in data['components']),
accent_colour=data.get('accent_color'),
spoiler=data.get('spoiler', False),
id=data.get('type'),
)
else:
raise ValueError(f'invalid item with type {data["type"]} provided')
class _ViewWeights: class _ViewWeights:
# fmt: off # fmt: off
__slots__ = ( __slots__ = (
@ -132,71 +323,66 @@ class _ViewWeights:
class _ViewCallback: class _ViewCallback:
__slots__ = ('view', 'callback', 'item') __slots__ = ('view', 'callback', 'item')
def __init__(self, callback: ItemCallbackType[Any, Any], view: View, item: Item[View]) -> None: def __init__(self, callback: ItemCallbackType[Any], view: BaseView, item: Item[BaseView]) -> None:
self.callback: ItemCallbackType[Any, Any] = callback self.callback: ItemCallbackType[Any] = callback
self.view: View = view self.view: BaseView = view
self.item: Item[View] = item self.item: Item[BaseView] = item
def __call__(self, interaction: Interaction) -> Coroutine[Any, Any, Any]: def __call__(self, interaction: Interaction) -> Coroutine[Any, Any, Any]:
return self.callback(self.view, interaction, self.item) return self.callback(self.view, interaction, self.item)
class View: class BaseView:
"""Represents a UI view. __discord_ui_view__: ClassVar[bool] = False
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
__discord_ui_modal__: 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: def __init__(self, *, timeout: Optional[float] = 180.0) -> None:
raise TypeError('View cannot have more than 25 children')
cls.__view_children_items__ = list(children.values())
def _init_children(self) -> List[Item[Self]]:
children = []
for func in self.__view_children_items__:
item: Item = func.__discord_ui_model_type__(**func.__discord_ui_model_kwargs__)
item.callback = _ViewCallback(func, self, item) # type: ignore
item._view = self
setattr(self, func.__name__, item)
children.append(item)
return children
def __init__(self, *, timeout: Optional[float] = 180.0):
self.__timeout = timeout self.__timeout = timeout
self._children: List[Item[Self]] = self._init_children() self._children: List[Item[Self]] = self._init_children()
self.__weights = _ViewWeights(self._children)
self.id: str = os.urandom(16).hex() self.id: str = os.urandom(16).hex()
self._cache_key: Optional[int] = None 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_expiry: Optional[float] = None
self.__timeout_task: Optional[asyncio.Task[None]] = None self.__timeout_task: Optional[asyncio.Task[None]] = None
self.__stopped: asyncio.Future[bool] = asyncio.get_running_loop().create_future() 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: def __repr__(self) -> str:
return f'<{self.__class__.__name__} timeout={self.timeout} children={len(self._children)}>' return f'<{self.__class__.__name__} timeout={self.timeout} children={len(self._children)}>'
def _init_children(self) -> List[Item[Self]]:
children = []
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
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: async def __timeout_task_impl(self) -> None:
while True: while True:
# Guard just in case someone changes the value of the timeout at runtime # Guard just in case someone changes the value of the timeout at runtime
@ -219,25 +405,11 @@ class View:
# or not, this simply is, whether a view has a component other than a url button # 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) return any(item.is_dispatchable() for item in self.children)
def to_components(self) -> List[Dict[str, Any]]: def has_components_v2(self) -> bool:
def key(item: Item) -> int: return any(c._is_v2() for c in self.children)
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 to_components(self) -> List[Dict[str, Any]]:
return NotImplemented
def _refresh_timeout(self) -> None: def _refresh_timeout(self) -> None:
if self.__timeout: if self.__timeout:
@ -268,7 +440,7 @@ class View:
return self._children.copy() return self._children.copy()
@classmethod @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`. """Converts a message's components into a :class:`View`.
The :attr:`.Message.components` of a message are read-only The :attr:`.Message.components` of a message are read-only
@ -282,28 +454,8 @@ class View:
The message with components to convert into a view. The message with components to convert into a view.
timeout: Optional[:class:`float`] timeout: Optional[:class:`float`]
The timeout of the converted 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.
""" """
view = View(timeout=timeout) pass
row = 0
for component in message.components:
if isinstance(component, ActionRowComponent):
for child in component.children:
item = _component_to_item(child)
item.row = row
view.add_item(item)
row += 1
else:
item = _component_to_item(component)
item.row = row
view.add_item(item)
return view
def add_item(self, item: Item[Any]) -> Self: def add_item(self, item: Item[Any]) -> Self:
"""Adds an item to the view. """Adds an item to the view.
@ -321,19 +473,26 @@ class View:
TypeError TypeError
An :class:`Item` was not passed. An :class:`Item` was not passed.
ValueError ValueError
Maximum number of children has been exceeded (25) Maximum number of children has been exceeded, the
or the row the item is trying to be added to is full. 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): if not isinstance(item, Item):
raise TypeError(f'expected Item not {item.__class__.__name__}') raise TypeError(f'expected Item not {item.__class__.__name__}')
if item._is_v2() and not self._is_v2():
self.__weights.add_item(item) raise ValueError('v2 items cannot be added to this view')
item._view = self 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._children.append(item) self._children.append(item)
return self return self
@ -354,7 +513,15 @@ class View:
except ValueError: except ValueError:
pass pass
else: 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 return self
def clear_items(self) -> Self: def clear_items(self) -> Self:
@ -364,9 +531,31 @@ class View:
chaining. chaining.
""" """
self._children.clear() self._children.clear()
self.__weights.clear() self._total_children = 0
return self return self
def get_item_by_id(self, id: int, /) -> Optional[Item[Self]]:
"""Gets an item with :attr:`Item.id` set as ``id``, or ``None`` if
not found.
.. 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._children, id=id)
async def interaction_check(self, interaction: Interaction, /) -> bool: async def interaction_check(self, interaction: Interaction, /) -> bool:
"""|coro| """|coro|
@ -425,7 +614,7 @@ class View:
try: try:
item._refresh_state(interaction, interaction.data) # type: ignore 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: if not allow:
return return
@ -437,7 +626,7 @@ class View:
return await self.on_error(interaction, e, item) return await self.on_error(interaction, e, item)
def _start_listening_from_store(self, store: ViewStore) -> None: 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:
if self.__timeout_task is not None: if self.__timeout_task is not None:
self.__timeout_task.cancel() self.__timeout_task.cancel()
@ -465,7 +654,7 @@ class View:
def _refresh(self, components: List[Component]) -> None: def _refresh(self, components: List[Component]) -> None:
# fmt: off # fmt: off
old_state: Dict[str, Item[Any]] = { old_state: Dict[str, Item[Any]] = {
item.custom_id: item # type: ignore item.custom_id: item
for item in self._children for item in self._children
if item.is_dispatchable() if item.is_dispatchable()
} }
@ -533,13 +722,321 @@ class View:
""" """
return await self.__stopped 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:
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:
"""Converts a :class:`list` of :class:`dict` s to a :class:`View` provided it is in the
format that Discord expects it to be in.
You can find out about this format in the :ddocs:`official Discord documentation <components/reference#anatomy-of-a-component>`.
Parameters
----------
data: List[:class:`dict`]
The array of dictionaries to convert into a 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
@classmethod
def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> View:
"""Converts a message's components into a :class:`View`.
The :attr:`.Message.components` of a message are read-only
and separate types from those in the ``discord.ui`` namespace.
In order to modify and edit message components they must be
converted into a :class:`View` first.
.. warning::
This **will not** take into account every v2 component, if you
want to edit them, use :meth:`LayoutView.from_message` instead.
Parameters
-----------
message: :class:`discord.Message`
The message with components to convert into a view.
timeout: Optional[:class:`float`]
The timeout of the converted view.
Returns
--------
:class:`View`
The converted view. This always returns a :class:`View` and not
one of its subclasses.
"""
view = View(timeout=timeout)
row = 0
for component in message.components:
if isinstance(component, ActionRowComponent):
for child in component.children:
item = _component_to_item(child)
item.row = row
if item._is_v2():
raise RuntimeError('v2 components cannot be added to this View')
view.add_item(item)
row += 1
else:
item = _component_to_item(component)
item.row = row
if item._is_v2():
raise RuntimeError('v2 components cannot be added to this View')
view.add_item(item)
return view
def 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 <examples>`
.. 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:
"""Converts a :class:`list` of :class:`dict` s to a :class:`LayoutView` provided it is in the
format that Discord expects it to be in.
You can find out about this format in the :ddocs:`official Discord documentation <components/reference#anatomy-of-a-component>`.
Parameters
----------
data: List[:class:`dict`]
The array of dictionaries to convert into a LayoutView.
"""
...
def __init__(self, *, timeout: Optional[float] = 180.0) -> None:
super().__init__(timeout=timeout)
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
@classmethod
def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> LayoutView:
"""Converts a message's components into a :class:`LayoutView`.
The :attr:`.Message.components` of a message are read-only
and separate types from those in the ``discord.ui`` namespace.
In order to modify and edit message components they must be
converted into a :class:`LayoutView` first.
Unlike :meth:`View.from_message` this converts v2 components.
Parameters
-----------
message: :class:`discord.Message`
The message with components to convert into a view.
timeout: Optional[:class:`float`]
The timeout of the converted view.
Returns
--------
:class:`LayoutView`
The converted view. This always returns a :class:`LayoutView` and not
one of its subclasses.
"""
view = LayoutView(timeout=timeout)
for component in message.components:
item = _component_to_item(component)
item.row = 0
view.add_item(item)
return view
class ViewStore: class ViewStore:
def __init__(self, state: ConnectionState): def __init__(self, state: ConnectionState):
# entity_id: {(component_type, custom_id): Item} # 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 # message_id: View
self._synced_message_views: Dict[int, View] = {} self._synced_message_views: Dict[int, BaseView] = {}
# custom_id: Modal # custom_id: Modal
self._modals: Dict[str, Modal] = {} self._modals: Dict[str, Modal] = {}
# component_type is the key # component_type is the key
@ -547,7 +1044,7 @@ class ViewStore:
self._state: ConnectionState = state self._state: ConnectionState = state
@property @property
def persistent_views(self) -> Sequence[View]: def persistent_views(self) -> Sequence[BaseView]:
# fmt: off # fmt: off
views = { views = {
item.view.id: item.view item.view.id: item.view
@ -568,7 +1065,7 @@ class ViewStore:
pattern = item.__discord_ui_compiled_template__ pattern = item.__discord_ui_compiled_template__
self._dynamic_items.pop(pattern, None) 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) view._start_listening_from_store(self)
if view.__discord_ui_modal__: if view.__discord_ui_modal__:
self._modals[view.custom_id] = view # type: ignore self._modals[view.custom_id] = view # type: ignore
@ -581,8 +1078,34 @@ class ViewStore:
pattern = item.__discord_ui_compiled_template__ pattern = item.__discord_ui_compiled_template__
self._dynamic_items[pattern] = item.__class__ self._dynamic_items[pattern] = item.__class__
elif item.is_dispatchable(): elif item.is_dispatchable():
dispatch_info[(item.type.value, item.custom_id)] = item # type: ignore if getattr(item, '__discord_ui_container__', False):
is_fully_dynamic = False is_fully_dynamic = (
item._update_store_data( # type: ignore
dispatch_info,
self._dynamic_items,
)
or is_fully_dynamic
)
elif getattr(item, '__discord_ui_action_row__', False):
is_fully_dynamic = (
item._update_store_data( # type: ignore
dispatch_info,
self._dynamic_items,
)
or is_fully_dynamic
)
elif getattr(item, '__discord_ui_section__', False):
accessory: Item = item.accessory # type: ignore
accessory._view = view
if isinstance(accessory, DynamicItem):
pattern = accessory.__discord_ui_compiled_template__
self._dynamic_items[pattern] = accessory.__class__
else:
dispatch_info[(accessory.type.value, accessory.custom_id)] = accessory
else:
dispatch_info[(item.type.value, item.custom_id)] = item
is_fully_dynamic = False
view._cache_key = message_id view._cache_key = message_id
if message_id is not None and not is_fully_dynamic: if message_id is not None and not is_fully_dynamic:
@ -600,7 +1123,7 @@ class ViewStore:
pattern = item.__discord_ui_compiled_template__ pattern = item.__discord_ui_compiled_template__
self._dynamic_items.pop(pattern, None) self._dynamic_items.pop(pattern, None)
elif item.is_dispatchable(): elif item.is_dispatchable():
dispatch_info.pop((item.type.value, item.custom_id), None) # type: ignore dispatch_info.pop((item.type.value, item.custom_id), None)
if len(dispatch_info) == 0: if len(dispatch_info) == 0:
self._views.pop(view._cache_key, None) self._views.pop(view._cache_key, None)
@ -618,7 +1141,8 @@ class ViewStore:
if interaction.message is None: if interaction.message is None:
return 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: try:
base_item_index, base_item = next( base_item_index, base_item = next(
@ -636,7 +1160,7 @@ class ViewStore:
return return
# Swap the item in the view with our new dynamic item # Swap the item in the view with our new dynamic item
view._children[base_item_index] = item view._children[base_item_index] = item # type: ignore
item._view = view item._view = view
item._rendered_row = base_item._rendered_row item._rendered_row = base_item._rendered_row
item._refresh_state(interaction, interaction.data) # type: ignore item._refresh_state(interaction, interaction.data) # type: ignore
@ -678,7 +1202,7 @@ class ViewStore:
key = (component_type, custom_id) key = (component_type, custom_id)
# The entity_id can either be message_id, interaction_id, or None in that priority order. # 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: if message_id is not None:
item = self._views.get(message_id, {}).get(key) item = self._views.get(message_id, {}).get(key)
@ -730,14 +1254,14 @@ class ViewStore:
def is_message_tracked(self, message_id: int) -> bool: def is_message_tracked(self, message_id: int) -> bool:
return message_id in self._synced_message_views 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) 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] = [] components: List[Component] = []
for component_data in data: for component_data in data:
component = _component_factory(component_data) component = _component_factory(component_data, self._state) # type: ignore
if component is not None: if component is not None:
components.append(component) components.append(component)

92
discord/webhook/async_.py

@ -71,7 +71,7 @@ if TYPE_CHECKING:
from ..emoji import Emoji from ..emoji import Emoji
from ..channel import VoiceChannel from ..channel import VoiceChannel
from ..abc import Snowflake from ..abc import Snowflake
from ..ui.view import View from ..ui.view import BaseView, View, LayoutView
from ..poll import Poll from ..poll import Poll
import datetime import datetime
from ..types.webhook import ( from ..types.webhook import (
@ -552,7 +552,7 @@ def interaction_message_response_params(
embed: Optional[Embed] = MISSING, embed: Optional[Embed] = MISSING,
embeds: Sequence[Embed] = MISSING, embeds: Sequence[Embed] = MISSING,
attachments: Sequence[Union[Attachment, File]] = MISSING, attachments: Sequence[Union[Attachment, File]] = MISSING,
view: Optional[View] = MISSING, view: Optional[BaseView] = MISSING,
allowed_mentions: Optional[AllowedMentions] = MISSING, allowed_mentions: Optional[AllowedMentions] = MISSING,
previous_allowed_mentions: Optional[AllowedMentions] = None, previous_allowed_mentions: Optional[AllowedMentions] = None,
poll: Poll = MISSING, poll: Poll = MISSING,
@ -592,6 +592,13 @@ def interaction_message_response_params(
if view is not MISSING: if view is not MISSING:
if view is not None: if view is not None:
data['components'] = view.to_components() 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: else:
data['components'] = [] data['components'] = []
@ -802,7 +809,7 @@ class WebhookMessage(Message):
embeds: Sequence[Embed] = MISSING, embeds: Sequence[Embed] = MISSING,
embed: Optional[Embed] = MISSING, embed: Optional[Embed] = MISSING,
attachments: Sequence[Union[Attachment, File]] = MISSING, attachments: Sequence[Union[Attachment, File]] = MISSING,
view: Optional[View] = MISSING, view: Optional[BaseView] = MISSING,
allowed_mentions: Optional[AllowedMentions] = None, allowed_mentions: Optional[AllowedMentions] = None,
) -> WebhookMessage: ) -> WebhookMessage:
"""|coro| """|coro|
@ -1598,6 +1605,46 @@ class Webhook(BaseWebhook):
# state is artificial # state is artificial
return WebhookMessage(data=data, state=state, channel=channel) # type: ignore 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 @overload
async def send( async def send(
self, self,
@ -1661,7 +1708,7 @@ class Webhook(BaseWebhook):
embed: Embed = MISSING, embed: Embed = MISSING,
embeds: Sequence[Embed] = MISSING, embeds: Sequence[Embed] = MISSING,
allowed_mentions: AllowedMentions = MISSING, allowed_mentions: AllowedMentions = MISSING,
view: View = MISSING, view: BaseView = MISSING,
thread: Snowflake = MISSING, thread: Snowflake = MISSING,
thread_name: str = MISSING, thread_name: str = MISSING,
wait: bool = False, wait: bool = False,
@ -1727,12 +1774,14 @@ class Webhook(BaseWebhook):
Controls the mentions being processed in this message. Controls the mentions being processed in this message.
.. versionadded:: 1.4 .. 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 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. is not managed by the library, then you can only send URL buttons.
Otherwise, you can send views with any type of components. Otherwise, you can send views with any type of components.
.. versionadded:: 2.0 .. versionadded:: 2.0
.. versionchanged:: 2.6
This now accepts :class:`discord.ui.LayoutView` instances.
thread: :class:`~discord.abc.Snowflake` thread: :class:`~discord.abc.Snowflake`
The thread to send this webhook to. The thread to send this webhook to.
@ -1931,6 +1980,33 @@ class Webhook(BaseWebhook):
) )
return self._create_message(data, thread=thread) 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( async def edit_message(
self, self,
message_id: int, message_id: int,
@ -1939,7 +2015,7 @@ class Webhook(BaseWebhook):
embeds: Sequence[Embed] = MISSING, embeds: Sequence[Embed] = MISSING,
embed: Optional[Embed] = MISSING, embed: Optional[Embed] = MISSING,
attachments: Sequence[Union[Attachment, File]] = MISSING, attachments: Sequence[Union[Attachment, File]] = MISSING,
view: Optional[View] = MISSING, view: Optional[BaseView] = MISSING,
allowed_mentions: Optional[AllowedMentions] = None, allowed_mentions: Optional[AllowedMentions] = None,
thread: Snowflake = MISSING, thread: Snowflake = MISSING,
) -> WebhookMessage: ) -> WebhookMessage:
@ -1978,12 +2054,14 @@ class Webhook(BaseWebhook):
allowed_mentions: :class:`AllowedMentions` allowed_mentions: :class:`AllowedMentions`
Controls the mentions being processed in this message. Controls the mentions being processed in this message.
See :meth:`.abc.Messageable.send` for more information. 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 updated view to update this message with. If ``None`` is passed then
the view is removed. The webhook must have state attached, similar to the view is removed. The webhook must have state attached, similar to
:meth:`send`. :meth:`send`.
.. versionadded:: 2.0 .. versionadded:: 2.0
.. versionchanged:: 2.6
This now accepts :class:`~discord.ui.LayoutView` instances.
thread: :class:`~discord.abc.Snowflake` thread: :class:`~discord.abc.Snowflake`
The thread the webhook message belongs to. The thread the webhook message belongs to.

85
discord/webhook/sync.py

@ -66,7 +66,7 @@ if TYPE_CHECKING:
from ..message import Attachment from ..message import Attachment
from ..abc import Snowflake from ..abc import Snowflake
from ..state import ConnectionState from ..state import ConnectionState
from ..ui import View from ..ui.view import BaseView, View, LayoutView
from ..types.webhook import ( from ..types.webhook import (
Webhook as WebhookPayload, Webhook as WebhookPayload,
) )
@ -856,6 +856,44 @@ class SyncWebhook(BaseWebhook):
# state is artificial # state is artificial
return SyncWebhookMessage(data=data, state=state, channel=channel) # type: ignore 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 @overload
def send( def send(
self, self,
@ -876,6 +914,7 @@ class SyncWebhook(BaseWebhook):
silent: bool = MISSING, silent: bool = MISSING,
applied_tags: List[ForumTag] = MISSING, applied_tags: List[ForumTag] = MISSING,
poll: Poll = MISSING, poll: Poll = MISSING,
view: View = MISSING,
) -> SyncWebhookMessage: ) -> SyncWebhookMessage:
... ...
@ -899,6 +938,7 @@ class SyncWebhook(BaseWebhook):
silent: bool = MISSING, silent: bool = MISSING,
applied_tags: List[ForumTag] = MISSING, applied_tags: List[ForumTag] = MISSING,
poll: Poll = MISSING, poll: Poll = MISSING,
view: View = MISSING,
) -> None: ) -> None:
... ...
@ -921,7 +961,7 @@ class SyncWebhook(BaseWebhook):
silent: bool = False, silent: bool = False,
applied_tags: List[ForumTag] = MISSING, applied_tags: List[ForumTag] = MISSING,
poll: Poll = MISSING, poll: Poll = MISSING,
view: View = MISSING, view: BaseView = MISSING,
) -> Optional[SyncWebhookMessage]: ) -> Optional[SyncWebhookMessage]:
"""Sends a message using the webhook. """Sends a message using the webhook.
@ -994,13 +1034,15 @@ class SyncWebhook(BaseWebhook):
When sending a Poll via webhook, you cannot manually end it. When sending a Poll via webhook, you cannot manually end it.
.. versionadded:: 2.4 .. versionadded:: 2.4
view: :class:`~discord.ui.View` view: Union[:class:`~discord.ui.View`, :class:`~discord.ui.LayoutView`]
The view to send with the message. This can only have URL buttons, which donnot The view to send with the message. This can only have non-interactible items, which donnot
require a state to be attached to it. 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`. If you want to send a view with any component attached to it, check :meth:`Webhook.send`.
.. versionadded:: 2.5 .. versionadded:: 2.5
.. versionchanged:: 2.6
This now accepts :class:`discord.ui.LayoutView` instances.
Raises Raises
-------- --------
@ -1143,6 +1185,33 @@ class SyncWebhook(BaseWebhook):
) )
return self._create_message(data, thread=thread) 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( def edit_message(
self, self,
message_id: int, message_id: int,
@ -1151,6 +1220,7 @@ class SyncWebhook(BaseWebhook):
embeds: Sequence[Embed] = MISSING, embeds: Sequence[Embed] = MISSING,
embed: Optional[Embed] = MISSING, embed: Optional[Embed] = MISSING,
attachments: Sequence[Union[Attachment, File]] = MISSING, attachments: Sequence[Union[Attachment, File]] = MISSING,
view: Optional[BaseView] = MISSING,
allowed_mentions: Optional[AllowedMentions] = None, allowed_mentions: Optional[AllowedMentions] = None,
thread: Snowflake = MISSING, thread: Snowflake = MISSING,
) -> SyncWebhookMessage: ) -> SyncWebhookMessage:
@ -1177,6 +1247,13 @@ class SyncWebhook(BaseWebhook):
then all attachments are removed. then all attachments are removed.
.. versionadded:: 2.0 .. 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` allowed_mentions: :class:`AllowedMentions`
Controls the mentions being processed in this message. Controls the mentions being processed in this message.
See :meth:`.abc.Messageable.send` for more information. See :meth:`.abc.Messageable.send` for more information.

50
docs/api.rst

@ -3871,6 +3871,27 @@ of :class:`enum.Enum`.
An alias for :attr:`.default`. 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: .. _discord-api-audit-logs:
Audit Log Data Audit Log Data
@ -5435,8 +5456,6 @@ PollAnswer
.. autoclass:: PollAnswer() .. autoclass:: PollAnswer()
:members: :members:
.. _discord_api_data:
MessageSnapshot MessageSnapshot
~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~
@ -5453,6 +5472,16 @@ ClientStatus
.. autoclass:: ClientStatus() .. autoclass:: ClientStatus()
:members: :members:
CallMessage
~~~~~~~~~~~~~~~~~~~
.. attributetable:: CallMessage
.. autoclass:: CallMessage()
:members:
.. _discord_api_data:
Data Classes Data Classes
-------------- --------------
@ -5756,12 +5785,21 @@ PollMedia
.. autoclass:: PollMedia .. autoclass:: PollMedia
:members: :members:
CallMessage UnfurledMediaItem
~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~
.. attributetable:: CallMessage .. attributetable:: UnfurledMediaItem
.. autoclass:: CallMessage() .. autoclass:: UnfurledMediaItem
:members:
MediaGalleryItem
~~~~~~~~~~~~~~~~
.. attributetable:: MediaGalleryItem
.. autoclass:: MediaGalleryItem
:members: :members:

208
docs/interactions/api.rst

@ -113,6 +113,77 @@ TextInput
:members: :members:
:inherited-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 AppCommand
~~~~~~~~~~~ ~~~~~~~~~~~
@ -299,7 +370,7 @@ Enumerations
.. attribute:: action_row .. 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 .. attribute:: button
@ -329,6 +400,38 @@ Enumerations
Represents a select in which both users and roles can be selected. Represents a select in which both users and roles can be selected.
.. attribute:: channel_select
Represents a channel select component.
.. attribute:: section
Represents a component which holds different components in a section.
.. attribute:: text_display
Represents a text display component.
.. attribute:: thumbnail
Represents a thumbnail component.
.. attribute:: media_gallery
Represents a media gallery component.
.. attribute:: file
Represents a file component.
.. attribute:: separator
Represents a separator component.
.. attribute:: container
Represents a component which holds different components in a container.
.. class:: ButtonStyle .. class:: ButtonStyle
Represents the style of the button component. Represents the style of the button component.
@ -463,6 +566,19 @@ Enumerations
The permission is for a user. The permission is for a user.
.. class:: SeparatorSize
The separator's size type.
.. versionadded:: 2.6
.. attribute:: small
A small separator.
.. attribute:: large
A large separator.
.. _discord_ui_kit: .. _discord_ui_kit:
Bot UI Kit Bot UI Kit
@ -478,6 +594,7 @@ View
.. autoclass:: discord.ui.View .. autoclass:: discord.ui.View
:members: :members:
:inherited-members:
Modal Modal
~~~~~~ ~~~~~~
@ -488,6 +605,15 @@ Modal
:members: :members:
:inherited-members: :inherited-members:
LayoutView
~~~~~~~~~~
.. attributetable:: discord.ui.LayoutView
.. autoclass:: discord.ui.LayoutView
:members:
:inherited-members:
Item Item
~~~~~~~ ~~~~~~~
@ -582,6 +708,86 @@ TextInput
:members: :members:
:inherited-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: .. _discord_app_commands:
Application Commands Application Commands

2
examples/views/dynamic_counter.py

@ -17,7 +17,7 @@ import re
# Note that custom_ids can only be up to 100 characters long. # Note that custom_ids can only be up to 100 characters long.
class DynamicCounter( class DynamicCounter(
discord.ui.DynamicItem[discord.ui.Button], discord.ui.DynamicItem[discord.ui.Button, discord.ui.View],
template=r'counter:(?P<count>[0-9]+):user:(?P<id>[0-9]+)', template=r'counter:(?P<count>[0-9]+):user:(?P<id>[0-9]+)',
): ):
def __init__(self, user_id: int, count: int = 0) -> None: def __init__(self, user_id: int, count: int = 0) -> None:

47
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')
Loading…
Cancel
Save