Add support for components V2

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

View File

@ -23,7 +23,23 @@ DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
from typing import Any, Callable, ClassVar, Coroutine, Dict, Iterator, List, Optional, Sequence, TYPE_CHECKING, Tuple, Type
from typing import (
Any,
Callable,
ClassVar,
Coroutine,
Dict,
Generator,
Iterator,
List,
Optional,
Sequence,
TYPE_CHECKING,
Tuple,
Type,
Union,
)
from functools import partial
from itertools import groupby
@ -32,6 +48,7 @@ import logging
import sys
import time
import os
from .item import Item, ItemCallbackType
from .select import Select
from .dynamic import DynamicItem
@ -41,26 +58,37 @@ from ..components import (
_component_factory,
Button as ButtonComponent,
SelectMenu as SelectComponent,
SectionComponent,
TextDisplay as TextDisplayComponent,
MediaGalleryComponent,
FileComponent,
SeparatorComponent,
ThumbnailComponent,
Container as ContainerComponent,
)
from ..utils import get as _utils_get, find as _utils_find
# fmt: off
__all__ = (
'View',
'LayoutView',
)
# fmt: on
if TYPE_CHECKING:
from typing_extensions import Self
from typing_extensions import Self, TypeGuard
import re
from ..interactions import Interaction
from ..message import Message
from ..types.components import Component as ComponentPayload
from ..types.components import ComponentBase as ComponentBasePayload, Component as ComponentPayload
from ..types.interactions import ModalSubmitComponentInteractionData as ModalSubmitComponentInteractionDataPayload
from ..state import ConnectionState
from .modal import Modal
ItemLike = Union[ItemCallbackType[Any, Any], Item[Any]]
_log = logging.getLogger(__name__)
@ -69,21 +97,61 @@ def _walk_all_components(components: List[Component]) -> Iterator[Component]:
for item in components:
if isinstance(item, ActionRowComponent):
yield from item.children
elif isinstance(item, ContainerComponent):
yield from _walk_all_components(item.children)
elif isinstance(item, SectionComponent):
yield from item.children
yield item.accessory
else:
yield item
def _component_to_item(component: Component) -> Item:
if isinstance(component, ButtonComponent):
def _component_to_item(component: Component, parent: Optional[Item] = None) -> Item:
if isinstance(component, ActionRowComponent):
from .action_row import ActionRow
item = ActionRow.from_component(component)
elif isinstance(component, ButtonComponent):
from .button import Button
return Button.from_component(component)
if isinstance(component, SelectComponent):
item = Button.from_component(component)
elif isinstance(component, SelectComponent):
from .select import BaseSelect
return BaseSelect.from_component(component)
item = BaseSelect.from_component(component)
elif isinstance(component, SectionComponent):
from .section import Section
return Item.from_component(component)
item = Section.from_component(component)
elif isinstance(component, TextDisplayComponent):
from .text_display import TextDisplay
item = TextDisplay.from_component(component)
elif isinstance(component, MediaGalleryComponent):
from .media_gallery import MediaGallery
item = MediaGallery.from_component(component)
elif isinstance(component, FileComponent):
from .file import File
item = File.from_component(component)
elif isinstance(component, SeparatorComponent):
from .separator import Separator
item = Separator.from_component(component)
elif isinstance(component, ThumbnailComponent):
from .thumbnail import Thumbnail
item = Thumbnail.from_component(component)
elif isinstance(component, ContainerComponent):
from .container import Container
item = Container.from_component(component)
else:
item = Item.from_component(component)
item._parent = parent
return item
class _ViewWeights:
@ -133,73 +201,66 @@ class _ViewWeights:
class _ViewCallback:
__slots__ = ('view', 'callback', 'item')
def __init__(self, callback: ItemCallbackType[Any, Any], view: View, item: Item[View]) -> None:
def __init__(self, callback: ItemCallbackType[Any, Any], view: BaseView, item: Item[BaseView]) -> None:
self.callback: ItemCallbackType[Any, Any] = callback
self.view: View = view
self.item: Item[View] = item
self.view: BaseView = view
self.item: Item[BaseView] = item
def __call__(self, interaction: Interaction) -> Coroutine[Any, Any, Any]:
return self.callback(self.view, interaction, self.item)
class View:
"""Represents a UI view.
This object must be inherited to create a UI within Discord.
.. versionadded:: 2.0
Parameters
-----------
timeout: Optional[:class:`float`]
Timeout in seconds from last interaction with the UI before no longer accepting input.
If ``None`` then there is no timeout.
"""
__discord_ui_view__: ClassVar[bool] = True
class BaseView:
__discord_ui_view__: ClassVar[bool] = False
__discord_ui_modal__: ClassVar[bool] = False
__view_children_items__: ClassVar[List[ItemCallbackType[Any, Any]]] = []
__view_children_items__: ClassVar[Dict[str, ItemLike]] = {}
def __init_subclass__(cls) -> None:
super().__init_subclass__()
children: Dict[str, ItemCallbackType[Any, Any]] = {}
for base in reversed(cls.__mro__):
for name, member in base.__dict__.items():
if hasattr(member, '__discord_ui_model_type__'):
children[name] = member
if len(children) > 25:
raise TypeError('View cannot have more than 25 children')
cls.__view_children_items__ = list(children.values())
def _init_children(self) -> List[Item[Self]]:
children = []
for func in self.__view_children_items__:
item: Item = func.__discord_ui_model_type__(**func.__discord_ui_model_kwargs__)
item.callback = _ViewCallback(func, self, item) # type: ignore
item._view = self
if isinstance(item, Select):
item.options = [option.copy() for option in item.options]
setattr(self, func.__name__, item)
children.append(item)
return children
def __init__(self, *, timeout: Optional[float] = 180.0):
def __init__(self, *, timeout: Optional[float] = 180.0) -> None:
self.__timeout = timeout
self._children: List[Item[Self]] = self._init_children()
self.__weights = _ViewWeights(self._children)
self.id: str = os.urandom(16).hex()
self._cache_key: Optional[int] = None
self.__cancel_callback: Optional[Callable[[View], None]] = None
self.__cancel_callback: Optional[Callable[[BaseView], None]] = None
self.__timeout_expiry: Optional[float] = None
self.__timeout_task: Optional[asyncio.Task[None]] = None
self.__stopped: asyncio.Future[bool] = asyncio.get_running_loop().create_future()
self._total_children: int = len(tuple(self.walk_children()))
def _is_layout(self) -> TypeGuard[LayoutView]: # type: ignore
return False
def __repr__(self) -> str:
return f'<{self.__class__.__name__} timeout={self.timeout} children={len(self._children)}>'
def _init_children(self) -> List[Item[Self]]:
children = []
parents = {}
for name, raw in self.__view_children_items__.items():
if isinstance(raw, Item):
item = raw.copy()
setattr(self, name, item)
item._update_view(self)
parent = getattr(item, '__discord_ui_parent__', None)
if parent and parent._view is None:
parent._view = self
children.append(item)
parents[raw] = item
else:
item: Item = raw.__discord_ui_model_type__(**raw.__discord_ui_model_kwargs__)
item.callback = _ViewCallback(raw, self, item) # type: ignore
item._view = self
if isinstance(item, Select):
item.options = [option.copy() for option in item.options]
setattr(self, raw.__name__, item)
parent = getattr(raw, '__discord_ui_parent__', None)
if parent:
parents.get(parent, parent)._children.append(item)
continue
children.append(item)
return children
async def __timeout_task_impl(self) -> None:
while True:
# Guard just in case someone changes the value of the timeout at runtime
@ -218,29 +279,16 @@ class View:
await asyncio.sleep(self.__timeout_expiry - now)
def is_dispatchable(self) -> bool:
# this is used by webhooks to check whether a view requires a state attached
# or not, this simply is, whether a view has a component other than a url button
return any(item.is_dispatchable() for item in self.children)
# checks whether any interactable items (buttons or selects) are present
# in this view, and check whether this requires a state attached in case
# of webhooks and if the view should be stored in the view store
return any(item.is_dispatchable() for item in self.walk_children())
def has_components_v2(self) -> bool:
return any(c._is_v2() for c in self.children)
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
return NotImplemented
def _refresh_timeout(self) -> None:
if self.__timeout:
@ -271,13 +319,17 @@ class View:
return self._children.copy()
@classmethod
def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> View:
def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> Union[View, LayoutView]:
"""Converts a message's components into a :class:`View`.
The :attr:`.Message.components` of a message are read-only
and separate types from those in the ``discord.ui`` namespace.
In order to modify and edit message components they must be
converted into a :class:`View` first.
converted into a :class:`View` or :class:`LayoutView` first.
If the message has any v2 components, then you must use
:class:`LayoutView` in order for them to be converted into
their respective items. :class:`View` does not support v2 components.
Parameters
-----------
@ -287,24 +339,43 @@ class View:
The timeout of the converted view.
Returns
--------
:class:`View`
The converted view. This always returns a :class:`View` and not
one of its subclasses.
-------
Union[:class:`View`, :class:`LayoutView`]
The converted view. This will always return one of :class:`View` or
:class:`LayoutView`, and not one of its subclasses.
"""
view = View(timeout=timeout)
if issubclass(cls, View):
view_cls = View
elif issubclass(cls, LayoutView):
view_cls = LayoutView
else:
raise TypeError('unreachable exception')
view = view_cls(timeout=timeout)
row = 0
for component in message.components:
if isinstance(component, ActionRowComponent):
if not view._is_layout() and isinstance(component, ActionRowComponent):
for child in component.children:
item = _component_to_item(child)
item.row = row
# this error should never be raised, because ActionRows can only
# contain items that View accepts, but check anyways
if item._is_v2():
raise ValueError(f'{item.__class__.__name__} cannot be added to {view.__class__.__name__}')
view.add_item(item)
row += 1
else:
item = _component_to_item(component)
item.row = row
view.add_item(item)
row += 1
continue
item = _component_to_item(component)
item.row = row
if item._is_v2() and not view._is_layout():
raise ValueError(f'{item.__class__.__name__} cannot be added to {view.__class__.__name__}')
view.add_item(item)
row += 1
return view
@ -324,19 +395,25 @@ class View:
TypeError
An :class:`Item` was not passed.
ValueError
Maximum number of children has been exceeded (25)
or the row the item is trying to be added to is full.
Maximum number of children has been exceeded, the
row the item is trying to be added to is full or the item
you tried to add is not allowed in this View.
"""
if len(self._children) >= 25:
raise ValueError('maximum number of children exceeded')
if not isinstance(item, Item):
raise TypeError(f'expected Item not {item.__class__.__name__}')
if item._is_v2() and not self._is_layout():
raise ValueError('v2 items cannot be added to this view')
self.__weights.add_item(item)
item._update_view(self)
added = 1
item._view = self
if item._has_children():
added += len(tuple(item.walk_children())) # type: ignore
if self._is_layout() and self._total_children + added > 40:
raise ValueError('maximum number of children exceeded')
self._total_children += added
self._children.append(item)
return self
@ -357,7 +434,15 @@ class View:
except ValueError:
pass
else:
self.__weights.remove_item(item)
removed = 1
if item._has_children():
removed += len(tuple(item.walk_children())) # type: ignore
if self._total_children - removed < 0:
self._total_children = 0
else:
self._total_children -= removed
return self
def clear_items(self) -> Self:
@ -367,9 +452,31 @@ class View:
chaining.
"""
self._children.clear()
self.__weights.clear()
self._total_children = 0
return self
def find_item(self, id: int, /) -> Optional[Item[Self]]:
"""Gets an item with :attr:`Item.id` set as ``id``, or ``None`` if
not found.
.. warning::
This is **not the same** as ``custom_id``.
.. versionadded:: 2.6
Parameters
----------
id: :class:`int`
The ID of the component.
Returns
-------
Optional[:class:`Item`]
The item found, or ``None``.
"""
return _utils_get(self.walk_children(), id=id)
async def interaction_check(self, interaction: Interaction, /) -> bool:
"""|coro|
@ -428,7 +535,7 @@ class View:
try:
item._refresh_state(interaction, interaction.data) # type: ignore
allow = await item.interaction_check(interaction) and await self.interaction_check(interaction)
allow = await item._run_checks(interaction) and await self.interaction_check(interaction)
if not allow:
return
@ -440,7 +547,7 @@ class View:
return await self.on_error(interaction, e, item)
def _start_listening_from_store(self, store: ViewStore) -> None:
self.__cancel_callback = partial(store.remove_view)
self.__cancel_callback = partial(store.remove_view) # type: ignore
if self.timeout:
if self.__timeout_task is not None:
self.__timeout_task.cancel()
@ -469,7 +576,7 @@ class View:
# fmt: off
old_state: Dict[str, Item[Any]] = {
item.custom_id: item # type: ignore
for item in self._children
for item in self.walk_children()
if item.is_dispatchable()
}
# fmt: on
@ -536,13 +643,193 @@ class View:
"""
return await self.__stopped
def walk_children(self) -> Generator[Item[Any], None, None]:
"""An iterator that recursively walks through all the children of this view
and its children, if applicable.
Yields
------
:class:`Item`
An item in the view.
"""
for child in self.children:
yield child
if child._has_children():
yield from child.walk_children() # type: ignore
class View(BaseView):
"""Represents a UI view.
This object must be inherited to create a UI within Discord.
.. versionadded:: 2.0
Parameters
-----------
timeout: Optional[:class:`float`]
Timeout in seconds from last interaction with the UI before no longer accepting input.
If ``None`` then there is no timeout.
"""
__discord_ui_view__: ClassVar[bool] = True
if TYPE_CHECKING:
@classmethod
def from_dict(cls, data: List[ComponentPayload], *, timeout: Optional[float] = 180.0) -> View:
...
@classmethod
def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> View:
...
def __init_subclass__(cls) -> None:
super().__init_subclass__()
children: Dict[str, ItemLike] = {}
for base in reversed(cls.__mro__):
for name, member in base.__dict__.items():
if hasattr(member, '__discord_ui_model_type__'):
children[name] = member
elif isinstance(member, Item) and member._is_v2():
raise ValueError(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)
def to_components(self) -> List[Dict[str, Any]]:
def key(item: Item) -> int:
return item._rendered_row or 0
children = sorted(self._children, key=key)
components: List[Dict[str, Any]] = []
for _, group in groupby(children, key=key):
children = [item.to_component_dict() for item in group]
if not children:
continue
components.append(
{
'type': 1,
'components': children,
}
)
return components
def add_item(self, item: Item[Any]) -> Self:
if len(self._children) >= 25:
raise ValueError('maximum number of children exceeded')
super().add_item(item)
try:
self.__weights.add_item(item)
except ValueError as e:
# if the item has no space left then remove it from _children
self._children.remove(item)
raise e
return self
def remove_item(self, item: Item[Any]) -> Self:
try:
self._children.remove(item)
except ValueError:
pass
else:
self.__weights.remove_item(item)
return self
def clear_items(self) -> Self:
super().clear_items()
self.__weights.clear()
return self
class LayoutView(BaseView):
"""Represents a layout view for components.
This object must be inherited to create a UI within Discord.
You can find usage examples in the :resource:`repository <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.
"""
if TYPE_CHECKING:
@classmethod
def from_dict(cls, data: List[ComponentPayload], *, timeout: Optional[float] = 180.0) -> LayoutView:
...
@classmethod
def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> LayoutView:
...
def __init__(self, *, timeout: Optional[float] = 180.0) -> None:
super().__init__(timeout=timeout)
if self._total_children > 40:
raise ValueError('maximum number of children exceeded (40)')
def __init_subclass__(cls) -> None:
super().__init_subclass__()
children: Dict[str, ItemLike] = {}
callback_children: Dict[str, ItemCallbackType[Any, Any]] = {}
for base in reversed(cls.__mro__):
for name, member in base.__dict__.items():
if isinstance(member, Item):
if member._parent is not None:
continue
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_layout(self) -> TypeGuard[LayoutView]: # type: ignore
return True
def to_components(self):
components: List[Dict[str, Any]] = []
for i in self._children:
components.append(i.to_component_dict())
return components
def add_item(self, item: Item[Any]) -> Self:
if self._total_children >= 40:
raise ValueError('maximum number of children exceeded (40)')
super().add_item(item)
return self
class ViewStore:
def __init__(self, state: ConnectionState):
# entity_id: {(component_type, custom_id): Item}
self._views: Dict[Optional[int], Dict[Tuple[int, str], Item[View]]] = {}
self._views: Dict[Optional[int], Dict[Tuple[int, str], Item[BaseView]]] = {}
# message_id: View
self._synced_message_views: Dict[int, View] = {}
self._synced_message_views: Dict[int, BaseView] = {}
# custom_id: Modal
self._modals: Dict[str, Modal] = {}
# component_type is the key
@ -550,7 +837,7 @@ class ViewStore:
self._state: ConnectionState = state
@property
def persistent_views(self) -> Sequence[View]:
def persistent_views(self) -> Sequence[BaseView]:
# fmt: off
views = {
item.view.id: item.view
@ -571,7 +858,7 @@ class ViewStore:
pattern = item.__discord_ui_compiled_template__
self._dynamic_items.pop(pattern, None)
def add_view(self, view: View, message_id: Optional[int] = None) -> None:
def add_view(self, view: BaseView, message_id: Optional[int] = None) -> None:
view._start_listening_from_store(self)
if view.__discord_ui_modal__:
self._modals[view.custom_id] = view # type: ignore
@ -579,7 +866,7 @@ class ViewStore:
dispatch_info = self._views.setdefault(message_id, {})
is_fully_dynamic = True
for item in view._children:
for item in view.walk_children():
if isinstance(item, DynamicItem):
pattern = item.__discord_ui_compiled_template__
self._dynamic_items[pattern] = item.__class__
@ -621,15 +908,16 @@ class ViewStore:
if interaction.message is None:
return
view = View.from_message(interaction.message, timeout=None)
view_cls = View if not interaction.message.flags.components_v2 else LayoutView
view = view_cls.from_message(interaction.message, timeout=None)
try:
base_item_index, base_item = next(
(index, child)
for index, child in enumerate(view._children)
if child.type.value == component_type and getattr(child, 'custom_id', None) == custom_id
)
except StopIteration:
base_item = _utils_find(
lambda i: i.type.value == component_type and getattr(i, 'custom_id', None) == custom_id,
view.walk_children(),
)
# if the item is not found then return
if not base_item:
return
try:
@ -638,8 +926,17 @@ class ViewStore:
_log.exception('Ignoring exception in dynamic item creation for %r', factory)
return
# Swap the item in the view with our new dynamic item
view._children[base_item_index] = item
# Swap the item in the view or parent with our new dynamic item
# Prioritize the item parent:
parent = base_item._parent or view
try:
child_index = parent._children.index(base_item) # type: ignore
except ValueError:
return
else:
parent._children[child_index] = item # type: ignore
item._view = view
item._rendered_row = base_item._rendered_row
item._refresh_state(interaction, interaction.data) # type: ignore
@ -681,7 +978,7 @@ class ViewStore:
key = (component_type, custom_id)
# The entity_id can either be message_id, interaction_id, or None in that priority order.
item: Optional[Item[View]] = None
item: Optional[Item[BaseView]] = None
if message_id is not None:
item = self._views.get(message_id, {}).get(key)
@ -733,14 +1030,14 @@ class ViewStore:
def is_message_tracked(self, message_id: int) -> bool:
return message_id in self._synced_message_views
def remove_message_tracking(self, message_id: int) -> Optional[View]:
def remove_message_tracking(self, message_id: int) -> Optional[BaseView]:
return self._synced_message_views.pop(message_id, None)
def update_from_message(self, message_id: int, data: List[ComponentPayload]) -> None:
def update_from_message(self, message_id: int, data: List[ComponentBasePayload]) -> None:
components: List[Component] = []
for component_data in data:
component = _component_factory(component_data)
component = _component_factory(component_data, self._state) # type: ignore
if component is not None:
components.append(component)