diff --git a/discord/abc.py b/discord/abc.py index 5c67c098c2..4f419a3942 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -1615,6 +1615,12 @@ async def send( ) components = view.to_components() + if view.is_components_v2(): + if embeds or content: + raise TypeError( + "cannot send embeds or content with a view using v2 component logic" + ) + flags.is_components_v2 = True else: components = None @@ -1679,8 +1685,10 @@ async def send( ret = state.create_message(channel=channel, data=data) if view: - state.store_view(view, ret.id) + if view.is_dispatchable(): + state.store_view(view, ret.id) view.message = ret + view.refresh(ret.components) if delete_after is not None: await ret.delete(delay=delete_after) diff --git a/discord/bot.py b/discord/bot.py index 037e5a947a..7dd246afe3 100644 --- a/discord/bot.py +++ b/discord/bot.py @@ -884,7 +884,7 @@ async def process_application_commands( ctx = await self.get_application_context(interaction) if command: - ctx.command = command + interaction.command = command await self.invoke_application_command(ctx) async def on_application_command_auto_complete( @@ -892,7 +892,7 @@ async def on_application_command_auto_complete( ) -> None: async def callback() -> None: ctx = await self.get_autocomplete_context(interaction) - ctx.command = command + interaction.command = command return await command.invoke_autocomplete_callback(ctx) autocomplete_task = self._bot.loop.create_task(callback()) diff --git a/discord/channel.py b/discord/channel.py index be6c68b38a..de589a5502 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -26,7 +26,16 @@ from __future__ import annotations import datetime -from typing import TYPE_CHECKING, Any, Callable, Iterable, Mapping, TypeVar, overload +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Iterable, + Mapping, + Sequence, + TypeVar, + overload, +) import discord.abc @@ -45,7 +54,7 @@ ) from .errors import ClientException, InvalidArgument from .file import File -from .flags import ChannelFlags +from .flags import ChannelFlags, MessageFlags from .invite import Invite from .iterators import ArchivedThreadIterator from .mixins import Hashable @@ -71,12 +80,15 @@ if TYPE_CHECKING: from .abc import Snowflake, SnowflakeTime + from .embeds import Embed from .guild import Guild from .guild import GuildChannel as GuildChannelType from .member import Member, VoiceState + from .mentions import AllowedMentions from .message import EmojiInputType, Message, PartialMessage from .role import Role from .state import ConnectionState + from .sticker import GuildSticker, StickerItem from .types.channel import CategoryChannel as CategoryChannelPayload from .types.channel import DMChannel as DMChannelPayload from .types.channel import ForumChannel as ForumChannelPayload @@ -87,6 +99,7 @@ from .types.channel import VoiceChannel as VoiceChannelPayload from .types.snowflake import SnowflakeList from .types.threads import ThreadArchiveDuration + from .ui.view import View from .user import BaseUser, ClientUser, User from .webhook import Webhook @@ -1181,18 +1194,20 @@ async def edit(self, *, reason=None, **options): async def create_thread( self, name: str, - content=None, + content: str | None = None, *, - embed=None, - embeds=None, - file=None, - files=None, - stickers=None, - delete_message_after=None, - nonce=None, - allowed_mentions=None, - view=None, - applied_tags=None, + embed: Embed | None = None, + embeds: list[Embed] | None = None, + file: File | None = None, + files: list[File] | None = None, + stickers: Sequence[GuildSticker | StickerItem] | None = None, + delete_message_after: float | None = None, + nonce: int | str | None = None, + allowed_mentions: AllowedMentions | None = None, + view: View | None = None, + applied_tags: list[ForumTag] | None = None, + suppress: bool = False, + silent: bool = False, auto_archive_duration: ThreadArchiveDuration = MISSING, slowmode_delay: int = MISSING, reason: str | None = None, @@ -1292,6 +1307,11 @@ async def create_thread( else: allowed_mentions = allowed_mentions.to_dict() + flags = MessageFlags( + suppress_embeds=bool(suppress), + suppress_notifications=bool(silent), + ) + if view: if not hasattr(view, "__discord_ui_view__"): raise InvalidArgument( @@ -1299,6 +1319,12 @@ async def create_thread( ) components = view.to_components() + if view.is_components_v2(): + if embeds or content: + raise TypeError( + "cannot send embeds or content with a view using v2 component logic" + ) + flags.is_components_v2 = True else: components = None @@ -1337,6 +1363,7 @@ async def create_thread( or self.default_auto_archive_duration, rate_limit_per_user=slowmode_delay or self.slowmode_delay, applied_tags=applied_tags, + flags=flags.value, reason=reason, ) finally: @@ -1346,7 +1373,7 @@ async def create_thread( ret = Thread(guild=self.guild, state=self._state, data=data) msg = ret.get_partial_message(int(data["last_message_id"])) - if view: + if view and view.is_dispatchable(): state.store_view(view, msg.id) if delete_message_after is not None: diff --git a/discord/client.py b/discord/client.py index 6768d4a660..2d0f1b8770 100644 --- a/discord/client.py +++ b/discord/client.py @@ -68,9 +68,11 @@ if TYPE_CHECKING: from .abc import GuildChannel, PrivateChannel, Snowflake, SnowflakeTime from .channel import DMChannel + from .interaction import Interaction from .member import Member from .message import Message from .poll import Poll + from .ui.item import Item from .voice_client import VoiceProtocol __all__ = ("Client",) @@ -541,6 +543,38 @@ async def on_error(self, event_method: str, *args: Any, **kwargs: Any) -> None: print(f"Ignoring exception in {event_method}", file=sys.stderr) traceback.print_exc() + async def on_view_error( + self, error: Exception, item: Item, interaction: Interaction + ) -> None: + """|coro| + + The default view error handler provided by the client. + + This only fires for a view if you did not define its :func:`~discord.ui.View.on_error`. + """ + + print( + f"Ignoring exception in view {interaction.view} for item {item}:", + file=sys.stderr, + ) + traceback.print_exception( + error.__class__, error, error.__traceback__, file=sys.stderr + ) + + async def on_modal_error(self, error: Exception, interaction: Interaction) -> None: + """|coro| + + The default modal error handler provided by the client. + The default implementation prints the traceback to stderr. + + This only fires for a modal if you did not define its :func:`~discord.ui.Modal.on_error`. + """ + + print(f"Ignoring exception in modal {interaction.modal}:", file=sys.stderr) + traceback.print_exception( + error.__class__, error, error.__traceback__, file=sys.stderr + ) + # hooks async def _call_before_identify_hook( diff --git a/discord/commands/context.py b/discord/commands/context.py index 532d8abe2a..e066bd32bc 100644 --- a/discord/commands/context.py +++ b/discord/commands/context.py @@ -80,8 +80,6 @@ class ApplicationContext(discord.abc.Messageable): The bot that the command belongs to. interaction: :class:`.Interaction` The interaction object that invoked the command. - command: :class:`.ApplicationCommand` - The command that this context belongs to. """ def __init__(self, bot: Bot, interaction: Interaction): @@ -89,7 +87,6 @@ def __init__(self, bot: Bot, interaction: Interaction): self.interaction = interaction # below attributes will be set after initialization - self.command: ApplicationCommand = None # type: ignore self.focused: Option = None # type: ignore self.value: str = None # type: ignore self.options: dict = None # type: ignore @@ -136,6 +133,15 @@ async def invoke( """ return await command(self, *args, **kwargs) + @property + def command(self) -> ApplicationCommand | None: + """The command that this context belongs to.""" + return self.interaction.command + + @command.setter + def command(self, value: ApplicationCommand | None) -> None: + self.interaction.command = value + @cached_property def channel(self) -> InteractionChannel | None: """Union[:class:`abc.GuildChannel`, :class:`PartialMessageable`, :class:`Thread`]: @@ -393,8 +399,6 @@ class AutocompleteContext: The bot that the command belongs to. interaction: :class:`.Interaction` The interaction object that invoked the autocomplete. - command: :class:`.ApplicationCommand` - The command that this context belongs to. focused: :class:`.Option` The option the user is currently typing. value: :class:`.str` @@ -403,13 +407,12 @@ class AutocompleteContext: A name to value mapping of the options that the user has selected before this option. """ - __slots__ = ("bot", "interaction", "command", "focused", "value", "options") + __slots__ = ("bot", "interaction", "focused", "value", "options") def __init__(self, bot: Bot, interaction: Interaction): self.bot = bot self.interaction = interaction - self.command: ApplicationCommand = None # type: ignore self.focused: Option = None # type: ignore self.value: str = None # type: ignore self.options: dict = None # type: ignore @@ -423,3 +426,12 @@ def cog(self) -> Cog | None: return None return self.command.cog + + @property + def command(self) -> ApplicationCommand | None: + """The command that this context belongs to.""" + return self.interaction.command + + @command.setter + def command(self, value: ApplicationCommand | None) -> None: + self.interaction.command = value diff --git a/discord/components.py b/discord/components.py index c80eb5a57c..37642d3ec2 100644 --- a/discord/components.py +++ b/discord/components.py @@ -25,20 +25,40 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, ClassVar, TypeVar - -from .enums import ButtonStyle, ChannelType, ComponentType, InputTextStyle, try_enum +from typing import TYPE_CHECKING, Any, ClassVar, Iterator, TypeVar + +from .asset import AssetMixin +from .colour import Colour +from .enums import ( + ButtonStyle, + ChannelType, + ComponentType, + InputTextStyle, + SeparatorSpacingSize, + try_enum, +) +from .flags import AttachmentFlags from .partial_emoji import PartialEmoji, _EmojiTag from .utils import MISSING, get_slots if TYPE_CHECKING: from .emoji import AppEmoji, GuildEmoji from .types.components import ActionRow as ActionRowPayload + from .types.components import BaseComponent as BaseComponentPayload from .types.components import ButtonComponent as ButtonComponentPayload from .types.components import Component as ComponentPayload + from .types.components import ContainerComponent as ContainerComponentPayload + from .types.components import FileComponent as FileComponentPayload from .types.components import InputText as InputTextComponentPayload + from .types.components import MediaGalleryComponent as MediaGalleryComponentPayload + from .types.components import MediaGalleryItem as MediaGalleryItemPayload + from .types.components import SectionComponent as SectionComponentPayload from .types.components import SelectMenu as SelectMenuPayload from .types.components import SelectOption as SelectOptionPayload + from .types.components import SeparatorComponent as SeparatorComponentPayload + from .types.components import TextDisplayComponent as TextDisplayComponentPayload + from .types.components import ThumbnailComponent as ThumbnailComponentPayload + from .types.components import UnfurledMediaItem as UnfurledMediaItemPayload __all__ = ( "Component", @@ -47,6 +67,15 @@ "SelectMenu", "SelectOption", "InputText", + "Section", + "TextDisplay", + "Thumbnail", + "MediaGallery", + "MediaGalleryItem", + "UnfurledMediaItem", + "FileComponent", + "Separator", + "Container", ) C = TypeVar("C", bound="Component") @@ -55,11 +84,18 @@ class Component: """Represents a Discord Bot UI Kit Component. - Currently, the only components supported by Discord are: + The components supported by Discord in messages are as follows: - :class:`ActionRow` - :class:`Button` - :class:`SelectMenu` + - :class:`Section` + - :class:`TextDisplay` + - :class:`Thumbnail` + - :class:`MediaGallery` + - :class:`FileComponent` + - :class:`Separator` + - :class:`Container` This class is abstract and cannot be instantiated. @@ -69,12 +105,16 @@ class Component: ---------- type: :class:`ComponentType` The type of component. + id: :class:`int` + The component's ID. If not provided by the user, it is set sequentially by Discord. + The ID `0` is treated as if no ID was provided. """ - __slots__: tuple[str, ...] = ("type",) + __slots__: tuple[str, ...] = ("type", "id") __repr_info__: ClassVar[tuple[str, ...]] type: ComponentType + versions: tuple[int, ...] def __repr__(self) -> str: attrs = " ".join(f"{key}={getattr(self, key)!r}" for key in self.__repr_info__) @@ -95,6 +135,10 @@ def _raw_construct(cls: type[C], **kwargs) -> C: def to_dict(self) -> dict[str, Any]: raise NotImplementedError + def is_v2(self) -> bool: + """Whether this component was introduced in Components V2.""" + return self.versions and 1 not in self.versions + class ActionRow(Component): """Represents a Discord Bot UI Kit Action Row. @@ -116,19 +160,39 @@ class ActionRow(Component): __slots__: tuple[str, ...] = ("children",) __repr_info__: ClassVar[tuple[str, ...]] = __slots__ + versions: tuple[int, ...] = (1, 2) def __init__(self, data: ComponentPayload): self.type: ComponentType = try_enum(ComponentType, data["type"]) + self.id: int = data.get("id") self.children: list[Component] = [ _component_factory(d) for d in data.get("components", []) ] + @property + def width(self): + """Return the sum of the children's widths.""" + t = 0 + for item in self.children: + t += 1 if item.type is ComponentType.button else 5 + return t + def to_dict(self) -> ActionRowPayload: return { "type": int(self.type), + "id": self.id, "components": [child.to_dict() for child in self.children], } # type: ignore + def walk_components(self) -> Iterator[Component]: + yield from self.children + + @classmethod + def with_components(cls, *components, id=None): + return cls._raw_construct( + type=ComponentType.action_row, id=id, children=[c for c in components] + ) + class InputText(Component): """Represents an Input Text field from the Discord Bot UI Kit. @@ -139,7 +203,7 @@ class InputText(Component): style: :class:`.InputTextStyle` The style of the input text field. custom_id: Optional[:class:`str`] - The ID of the input text field that gets received during an interaction. + The custom ID of the input text field that gets received during an interaction. label: :class:`str` The label for the input text field. placeholder: Optional[:class:`str`] @@ -153,6 +217,8 @@ class InputText(Component): Whether the input text field is required or not. Defaults to `True`. value: Optional[:class:`str`] The value that has been entered in the input text field. + id: Optional[:class:`int`] + The input text's ID. """ __slots__: tuple[str, ...] = ( @@ -165,12 +231,15 @@ class InputText(Component): "max_length", "required", "value", + "id", ) __repr_info__: ClassVar[tuple[str, ...]] = __slots__ + versions: tuple[int, ...] = (1, 2) def __init__(self, data: InputTextComponentPayload): self.type = ComponentType.input_text + self.id: int | None = data.get("id") self.style: InputTextStyle = try_enum(InputTextStyle, data["style"]) self.custom_id = data["custom_id"] self.label: str = data.get("label", None) @@ -183,6 +252,7 @@ def __init__(self, data: InputTextComponentPayload): def to_dict(self) -> InputTextComponentPayload: payload = { "type": 4, + "id": self.id, "style": self.style.value, "label": self.label, } @@ -214,8 +284,7 @@ class Button(Component): .. note:: - The user constructible and usable type to create a button is :class:`discord.ui.Button` - not this one. + This class is not useable by end-users; see :class:`discord.ui.Button` instead. .. versionadded:: 2.0 @@ -249,24 +318,27 @@ class Button(Component): ) __repr_info__: ClassVar[tuple[str, ...]] = __slots__ + versions: tuple[int, ...] = (1, 2) def __init__(self, data: ButtonComponentPayload): self.type: ComponentType = try_enum(ComponentType, data["type"]) + self.id: int = data.get("id") self.style: ButtonStyle = try_enum(ButtonStyle, data["style"]) self.custom_id: str | None = data.get("custom_id") self.url: str | None = data.get("url") self.disabled: bool = data.get("disabled", False) self.label: str | None = data.get("label") self.emoji: PartialEmoji | None - try: - self.emoji = PartialEmoji.from_dict(data["emoji"]) - except KeyError: + if e := data.get("emoji"): + self.emoji = PartialEmoji.from_dict(e) + else: self.emoji = None self.sku_id: str | None = data.get("sku_id") def to_dict(self) -> ButtonComponentPayload: payload = { "type": 2, + "id": self.id, "style": int(self.style), "label": self.label, "disabled": self.disabled, @@ -294,8 +366,7 @@ class SelectMenu(Component): .. note:: - The user constructible and usable type to create a select menu is - :class:`discord.ui.Select` not this one. + This class is not useable by end-users; see :class:`discord.ui.Select` instead. .. versionadded:: 2.0 @@ -341,9 +412,11 @@ class SelectMenu(Component): ) __repr_info__: ClassVar[tuple[str, ...]] = __slots__ + versions: tuple[int, ...] = (1, 2) def __init__(self, data: SelectMenuPayload): self.type = try_enum(ComponentType, data["type"]) + self.id: int = data.get("id") self.custom_id: str = data["custom_id"] self.placeholder: str | None = data.get("placeholder") self.min_values: int = data.get("min_values", 1) @@ -359,6 +432,7 @@ def __init__(self, data: SelectMenuPayload): def to_dict(self) -> SelectMenuPayload: payload: SelectMenuPayload = { "type": self.type.value, + "id": self.id, "custom_id": self.custom_id, "min_values": self.min_values, "max_values": self.max_values, @@ -465,9 +539,9 @@ def emoji(self, value) -> None: @classmethod def from_dict(cls, data: SelectOptionPayload) -> SelectOption: - try: - emoji = PartialEmoji.from_dict(data["emoji"]) - except KeyError: + if e := data.get("emoji"): + emoji = PartialEmoji.from_dict(e) + else: emoji = None return cls( @@ -494,16 +568,485 @@ def to_dict(self) -> SelectOptionPayload: return payload -def _component_factory(data: ComponentPayload) -> Component: +class Section(Component): + """Represents a Section from Components V2. + + This is a component that groups other components together with an additional component to the right as the accessory. + + This inherits from :class:`Component`. + + .. note:: + + This class is not useable by end-users; see :class:`discord.ui.Section` instead. + + .. versionadded:: 2.7 + + Attributes + ---------- + components: List[:class:`Component`] + The components contained in this section. Currently supports :class:`TextDisplay`. + accessory: Optional[:class:`Component`] + The accessory attached to this Section. Currently supports :class:`Button` and :class:`Thumbnail`. + """ + + __slots__: tuple[str, ...] = ("components", "accessory") + + __repr_info__: ClassVar[tuple[str, ...]] = __slots__ + versions: tuple[int, ...] = (2,) + + def __init__(self, data: SectionComponentPayload, state=None): + self.type: ComponentType = try_enum(ComponentType, data["type"]) + self.id: int = data.get("id") + self.components: list[Component] = [ + _component_factory(d, state=state) for d in data.get("components", []) + ] + self.accessory: Component | None = None + if _accessory := data.get("accessory"): + self.accessory = _component_factory(_accessory, state=state) + + def to_dict(self) -> SectionComponentPayload: + payload = { + "type": int(self.type), + "id": self.id, + "components": [c.to_dict() for c in self.components], + } + if self.accessory: + payload["accessory"] = self.accessory.to_dict() + return payload + + def walk_components(self) -> Iterator[Component]: + r = self.components + if self.accessory: + yield from r + [self.accessory] + yield from r + + +class TextDisplay(Component): + """Represents a Text Display from Components V2. + + This is a component that displays text. + + This inherits from :class:`Component`. + + .. note:: + + This class is not useable by end-users; see :class:`discord.ui.TextDisplay` instead. + + .. versionadded:: 2.7 + + Attributes + ---------- + content: :class:`str` + The component's text content. + """ + + __slots__: tuple[str, ...] = ("content",) + + __repr_info__: ClassVar[tuple[str, ...]] = __slots__ + versions: tuple[int, ...] = (2,) + + def __init__(self, data: TextDisplayComponentPayload): + self.type: ComponentType = try_enum(ComponentType, data["type"]) + self.id: int = data.get("id") + self.content: str = data.get("content") + + def to_dict(self) -> TextDisplayComponentPayload: + return {"type": int(self.type), "id": self.id, "content": self.content} + + +class UnfurledMediaItem(AssetMixin): + """Represents an Unfurled Media Item used in Components V2. + + This is used as an underlying component for other media-based components such as :class:`Thumbnail`, :class:`FileComponent`, and :class:`MediaGalleryItem`. + + .. versionadded:: 2.7 + + Attributes + ---------- + url: :class:`str` + The URL of this media item. This can either be an arbitrary URL or an ``attachment://`` URL to work with local files. + """ + + def __init__(self, url: str): + self._state = None + self._url: str = url + self.proxy_url: str | None = None + self.height: int | None = None + self.width: int | None = None + self.content_type: str | None = None + self.flags: AttachmentFlags | None = None + self.attachment_id: int | None = None + + @property + def url(self) -> str: + """Returns this media item's url.""" + return self._url + + @classmethod + def from_dict(cls, data: UnfurledMediaItemPayload, state=None) -> UnfurledMediaItem: + + r = cls(data.get("url")) + r.proxy_url = data.get("proxy_url") + r.height = data.get("height") + r.width = data.get("width") + r.content_type = data.get("content_type") + r.flags = AttachmentFlags._from_value(data.get("flags", 0)) + r.attachment_id = data.get("attachment_id") + r._state = state + return r + + def to_dict(self) -> dict[str, str]: + return {"url": self.url} + + +class Thumbnail(Component): + """Represents a Thumbnail from Components V2. + + This is a component that displays media, such as images and videos. + + This inherits from :class:`Component`. + + .. note:: + + This class is not useable by end-users; see :class:`discord.ui.Thumbnail` instead. + + .. versionadded:: 2.7 + + Attributes + ---------- + media: :class:`UnfurledMediaItem` + The component's underlying media object. + description: Optional[:class:`str`] + The thumbnail's description, up to 1024 characters. + spoiler: Optional[:class:`bool`] + Whether the thumbnail has the spoiler overlay. + """ + + __slots__: tuple[str, ...] = ( + "media", + "description", + "spoiler", + ) + + __repr_info__: ClassVar[tuple[str, ...]] = __slots__ + versions: tuple[int, ...] = (2,) + + def __init__(self, data: ThumbnailComponentPayload, state=None): + self.type: ComponentType = try_enum(ComponentType, data["type"]) + self.id: int = data.get("id") + self.media: UnfurledMediaItem = ( + umi := data.get("media") + ) and UnfurledMediaItem.from_dict(umi, state=state) + self.description: str | None = data.get("description") + self.spoiler: bool | None = data.get("spoiler") + + @property + def url(self) -> str: + """Returns the URL of this thumbnail's underlying media item.""" + return self.media.url + + def to_dict(self) -> ThumbnailComponentPayload: + payload = {"type": int(self.type), "id": self.id, "media": self.media.to_dict()} + if self.description: + payload["description"] = self.description + if self.spoiler is not None: + payload["spoiler"] = self.spoiler + return payload + + +class MediaGalleryItem: + """Represents an item used in the :class:`MediaGallery` component. + + This is used as an underlying component for other media-based components such as :class:`Thumbnail`, :class:`FileComponent`, and :class:`MediaGalleryItem`. + + .. versionadded:: 2.7 + + Attributes + ---------- + url: :class:`str` + The URL of this gallery item. This can either be an arbitrary URL or an ``attachment://`` URL to work with local files. + description: Optional[:class:`str`] + The gallery item's description, up to 1024 characters. + spoiler: Optional[:class:`bool`] + Whether the gallery item is a spoiler. + """ + + def __init__(self, url, *, description=None, spoiler=False): + self._state = None + self.media: UnfurledMediaItem = UnfurledMediaItem(url) + self.description: str | None = description + self.spoiler: bool = spoiler + + @property + def url(self) -> str: + """Returns the URL of this gallery's underlying media item.""" + return self.media.url + + def is_dispatchable(self) -> bool: + return False + + @classmethod + def from_dict(cls, data: MediaGalleryItemPayload, state=None) -> MediaGalleryItem: + media = (umi := data.get("media")) and UnfurledMediaItem.from_dict( + umi, state=state + ) + description = data.get("description") + spoiler = data.get("spoiler", False) + + r = cls( + url=media.url, + description=description, + spoiler=spoiler, + ) + r._state = state + r.media = media + return r + + def to_dict(self) -> dict[str, Any]: + payload = {"media": self.media.to_dict()} + if self.description: + payload["description"] = self.description + if self.spoiler is not None: + payload["spoiler"] = self.spoiler + return payload + + +class MediaGallery(Component): + """Represents a Media Gallery from Components V2. + + This is a component that displays up to 10 different :class:`MediaGalleryItem` objects. + + This inherits from :class:`Component`. + + .. note:: + + This class is not useable by end-users; see :class:`discord.ui.MediaGallery` instead. + + .. versionadded:: 2.7 + + Attributes + ---------- + items: List[:class:`MediaGalleryItem`] + The media this gallery contains. + """ + + __slots__: tuple[str, ...] = ("items",) + + __repr_info__: ClassVar[tuple[str, ...]] = __slots__ + versions: tuple[int, ...] = (2,) + + def __init__(self, data: MediaGalleryComponentPayload, state=None): + self.type: ComponentType = try_enum(ComponentType, data["type"]) + self.id: int = data.get("id") + self.items: list[MediaGalleryItem] = [ + MediaGalleryItem.from_dict(d, state=state) for d in data.get("items", []) + ] + + def to_dict(self) -> MediaGalleryComponentPayload: + return { + "type": int(self.type), + "id": self.id, + "items": [i.to_dict() for i in self.items], + } + + +class FileComponent(Component): + """Represents a File from Components V2. + + This component displays a downloadable file in a message. + + This inherits from :class:`Component`. + + .. note:: + + This class is not useable by end-users; see :class:`discord.ui.File` instead. + + .. versionadded:: 2.7 + + Attributes + ---------- + file: :class:`UnfurledMediaItem` + The file's media item. + name: :class:`str` + The file's name. + size: :class:`int` + The file's size in bytes. + spoiler: Optional[:class:`bool`] + Whether the file has the spoiler overlay. + """ + + __slots__: tuple[str, ...] = ( + "file", + "spoiler", + "name", + "size", + ) + + __repr_info__: ClassVar[tuple[str, ...]] = __slots__ + versions: tuple[int, ...] = (2,) + + def __init__(self, data: FileComponentPayload, state=None): + self.type: ComponentType = try_enum(ComponentType, data["type"]) + self.id: int = data.get("id") + self.name: str = data.get("name") + self.size: int = data.get("size") + self.file: UnfurledMediaItem = UnfurledMediaItem.from_dict( + data.get("file", {}), state=state + ) + self.spoiler: bool | None = data.get("spoiler") + + def to_dict(self) -> FileComponentPayload: + payload = {"type": int(self.type), "id": self.id, "file": self.file.to_dict()} + if self.spoiler is not None: + payload["spoiler"] = self.spoiler + return payload + + +class Separator(Component): + """Represents a Separator from Components V2. + + This is a component that visually separates components. + + This inherits from :class:`Component`. + + .. note:: + + This class is not useable by end-users; see :class:`discord.ui.Separator` instead. + + .. versionadded:: 2.7 + + Attributes + ---------- + divider: :class:`bool` + Whether the separator will show a horizontal line in addition to vertical spacing. + spacing: Optional[:class:`SeparatorSpacingSize`] + The separator's spacing size. + """ + + __slots__: tuple[str, ...] = ( + "divider", + "spacing", + ) + + __repr_info__: ClassVar[tuple[str, ...]] = __slots__ + versions: tuple[int, ...] = (2,) + + def __init__(self, data: SeparatorComponentPayload): + self.type: ComponentType = try_enum(ComponentType, data["type"]) + self.id: int = data.get("id") + self.divider: bool = data.get("divider") + self.spacing: SeparatorSpacingSize = try_enum( + SeparatorSpacingSize, data.get("spacing", 1) + ) + + def to_dict(self) -> SeparatorComponentPayload: + return { + "type": int(self.type), + "id": self.id, + "divider": self.divider, + "spacing": int(self.spacing), + } + + +class Container(Component): + """Represents a Container from Components V2. + + This is a component that contains different :class:`Component` objects. + It may only contain: + + - :class:`ActionRow` + - :class:`TextDisplay` + - :class:`Section` + - :class:`MediaGallery` + - :class:`Separator` + - :class:`FileComponent` + + This inherits from :class:`Component`. + + .. note:: + + This class is not useable by end-users; see :class:`discord.ui.Container` instead. + + .. versionadded:: 2.7 + + Attributes + ---------- + components: List[:class:`Component`] + The components contained in this container. + accent_color: Optional[:class:`Colour`] + The accent color of the container. + spoiler: Optional[:class:`bool`] + Whether the entire container has the spoiler overlay. + """ + + __slots__: tuple[str, ...] = ( + "accent_color", + "spoiler", + "components", + ) + + __repr_info__: ClassVar[tuple[str, ...]] = __slots__ + versions: tuple[int, ...] = (2,) + + def __init__(self, data: ContainerComponentPayload, state=None): + self.type: ComponentType = try_enum(ComponentType, data["type"]) + self.id: int = data.get("id") + self.accent_color: Colour | None = (c := data.get("accent_color")) and Colour( + c + ) # at this point, not adding alternative spelling + self.spoiler: bool | None = data.get("spoiler") + self.components: list[Component] = [ + _component_factory(d, state=state) for d in data.get("components", []) + ] + + def to_dict(self) -> ContainerComponentPayload: + payload = { + "type": int(self.type), + "id": self.id, + "components": [c.to_dict() for c in self.components], + } + if self.accent_color: + payload["accent_color"] = self.accent_color.value + if self.spoiler is not None: + payload["spoiler"] = self.spoiler + return payload + + def walk_components(self) -> Iterator[Component]: + for c in self.components: + if hasattr(c, "walk_components"): + yield from c.walk_components() + else: + yield c + + +COMPONENT_MAPPINGS = { + 1: ActionRow, + 2: Button, + 3: SelectMenu, + 4: InputText, + 5: SelectMenu, + 6: SelectMenu, + 7: SelectMenu, + 8: SelectMenu, + 9: Section, + 10: TextDisplay, + 11: Thumbnail, + 12: MediaGallery, + 13: FileComponent, + 14: Separator, + 17: Container, +} + +STATE_COMPONENTS = (Section, Container, Thumbnail, MediaGallery, FileComponent) + + +def _component_factory(data: ComponentPayload, state=None) -> Component: component_type = data["type"] - if component_type == 1: - return ActionRow(data) - elif component_type == 2: - return Button(data) # type: ignore - elif component_type == 4: - return InputText(data) # type: ignore - elif component_type in (3, 5, 6, 7, 8): - return SelectMenu(data) # type: ignore + if cls := COMPONENT_MAPPINGS.get(component_type): + if issubclass(cls, STATE_COMPONENTS): + return cls(data, state=state) + else: + return cls(data) else: as_enum = try_enum(ComponentType, component_type) return Component._raw_construct(type=as_enum) diff --git a/discord/enums.py b/discord/enums.py index 01d10275c8..a8affc8a8b 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -78,6 +78,8 @@ "InteractionContextType", "PollLayoutType", "MessageReferenceType", + "SubscriptionStatus", + "SeparatorSpacingSize", ) @@ -720,6 +722,14 @@ class ComponentType(Enum): role_select = 6 mentionable_select = 7 channel_select = 8 + section = 9 + text_display = 10 + thumbnail = 11 + media_gallery = 12 + file = 13 + separator = 14 + content_inventory_entry = 16 + container = 17 def __int__(self): return self.value @@ -1078,6 +1088,16 @@ class SubscriptionStatus(Enum): inactive = 2 +class SeparatorSpacingSize(Enum): + """A separator component's spacing size.""" + + small = 1 + large = 2 + + def __int__(self): + return self.value + + T = TypeVar("T") diff --git a/discord/ext/pages/pagination.py b/discord/ext/pages/pagination.py index 24d66c6c46..8dbf1c0f43 100644 --- a/discord/ext/pages/pagination.py +++ b/discord/ext/pages/pagination.py @@ -155,9 +155,9 @@ def __init__( files: list[discord.File] | None = None, **kwargs, ): - if content is None and embeds is None: + if content is None and embeds is None and custom_view is None: raise discord.InvalidArgument( - "A page cannot have both content and embeds equal to None." + "A page must at least have content, embeds, or custom_view set." ) self._content = content self._embeds = embeds or [] @@ -591,8 +591,9 @@ async def update( async def on_timeout(self) -> None: """Disables all buttons when the view times out.""" if self.disable_on_timeout: - for item in self.children: - item.disabled = True + for item in self.walk_children(): + if hasattr(item, "disabled"): + item.disabled = True page = self.pages[self.current_page] page = self.get_page_content(page) files = page.update_files() @@ -617,12 +618,12 @@ async def disable( The page content to show after disabling the paginator. """ page = self.get_page_content(page) - for item in self.children: + for item in self.walk_children(): if ( include_custom or not self.custom_view or item not in self.custom_view.children - ): + ) and hasattr(item, "disabled"): item.disabled = True if page: await self.message.edit( @@ -918,6 +919,8 @@ def get_page_content( return Page(content=None, embeds=[page], files=[]) elif isinstance(page, discord.File): return Page(content=None, embeds=[], files=[page]) + elif isinstance(page, discord.ui.View): + return Page(content=None, embeds=[], files=[], custom_view=page) elif isinstance(page, List): if all(isinstance(x, discord.Embed) for x in page): return Page(content=None, embeds=page, files=[]) @@ -927,7 +930,7 @@ def get_page_content( raise TypeError("All list items must be embeds or files.") else: raise TypeError( - "Page content must be a Page object, string, an embed, a list of" + "Page content must be a Page object, string, an embed, a view, a list of" " embeds, a file, or a list of files." ) diff --git a/discord/flags.py b/discord/flags.py index 3a4c8d27bc..cb2059e866 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -333,22 +333,22 @@ class MessageFlags(BaseFlags): @flag_value def crossposted(self): """:class:`bool`: Returns ``True`` if the message is the original crossposted message.""" - return 1 + return 1 << 0 @flag_value def is_crossposted(self): """:class:`bool`: Returns ``True`` if the message was crossposted from another channel.""" - return 2 + return 1 << 1 @flag_value def suppress_embeds(self): """:class:`bool`: Returns ``True`` if the message's embeds have been suppressed.""" - return 4 + return 1 << 2 @flag_value def source_message_deleted(self): """:class:`bool`: Returns ``True`` if the source message for this crosspost has been deleted.""" - return 8 + return 1 << 3 @flag_value def urgent(self): @@ -356,7 +356,7 @@ def urgent(self): An urgent message is one sent by Discord Trust and Safety. """ - return 16 + return 1 << 4 @flag_value def has_thread(self): @@ -364,7 +364,7 @@ def has_thread(self): .. versionadded:: 2.0 """ - return 32 + return 1 << 5 @flag_value def ephemeral(self): @@ -372,7 +372,7 @@ def ephemeral(self): .. versionadded:: 2.0 """ - return 64 + return 1 << 6 @flag_value def loading(self): @@ -382,7 +382,7 @@ def loading(self): .. versionadded:: 2.0 """ - return 128 + return 1 << 7 @flag_value def failed_to_mention_some_roles_in_thread(self): @@ -390,7 +390,7 @@ def failed_to_mention_some_roles_in_thread(self): .. versionadded:: 2.0 """ - return 256 + return 1 << 8 @flag_value def suppress_notifications(self): @@ -401,7 +401,7 @@ def suppress_notifications(self): .. versionadded:: 2.4 """ - return 4096 + return 1 << 12 @flag_value def is_voice_message(self): @@ -409,7 +409,15 @@ def is_voice_message(self): .. versionadded:: 2.5 """ - return 8192 + return 1 << 13 + + @flag_value + def is_components_v2(self): + """:class:`bool`: Returns ``True`` if this message has v2 components. This flag disables sending `content`, `embed`, and `embeds`. + + .. versionadded:: 2.7 + """ + return 1 << 15 @flag_value def has_snapshot(self): diff --git a/discord/http.py b/discord/http.py index 2db704b268..a6eec1a82a 100644 --- a/discord/http.py +++ b/discord/http.py @@ -1228,6 +1228,7 @@ def start_forum_thread( allowed_mentions: message.AllowedMentions | None = None, stickers: list[sticker.StickerItem] | None = None, components: list[components.Component] | None = None, + flags: int | None = None, ) -> Response[threads.Thread]: payload: dict[str, Any] = { "name": name, @@ -1264,6 +1265,9 @@ def start_forum_thread( if stickers: message["sticker_ids"] = stickers + if flags: + message["flags"] = flags + if message != {}: payload["message"] = message diff --git a/discord/interactions.py b/discord/interactions.py index 0834e9bb75..0e9084468d 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -76,7 +76,7 @@ VoiceChannel, ) from .client import Client - from .commands import OptionChoice + from .commands import ApplicationCommand, OptionChoice from .embeds import Embed from .mentions import AllowedMentions from .poll import Poll @@ -153,6 +153,18 @@ class Interaction: The context in which this command was executed. .. versionadded:: 2.6 + command: Optional[:class:`ApplicationCommand`] + The command that this interaction belongs to. + + .. versionadded:: 2.7 + view: Optional[:class:`View`] + The view that this interaction belongs to. + + .. versionadded:: 2.7 + modal: Optional[:class:`Modal`] + The modal that this interaction belongs to. + + .. versionadded:: 2.7 """ __slots__: tuple[str, ...] = ( @@ -173,6 +185,9 @@ class Interaction: "entitlements", "context", "authorizing_integration_owners", + "command", + "view", + "modal", "_channel_data", "_message_data", "_guild_data", @@ -225,6 +240,10 @@ def _from_data(self, data: InteractionPayload): else None ) + self.command: ApplicationCommand | None = None + self.view: View | None = None + self.modal: Modal | None = None + self.message: Message | None = None self.channel = None @@ -577,7 +596,9 @@ async def edit_original_response( message = InteractionMessage(state=state, channel=self.channel, data=data) # type: ignore if view and not view.is_finished(): view.message = message - self._state.store_view(view, message.id) + view.refresh(message.components) + if view.is_dispatchable(): + self._state.store_view(view, message.id) if delete_after is not None: await self.delete_original_response(delay=delete_after) @@ -939,7 +960,7 @@ async def send_message( HTTPException Sending the message failed. TypeError - You specified both ``embed`` and ``embeds``. + You specified both ``embed`` and ``embeds``, or sent content or embeds with V2 components. ValueError The length of ``embeds`` was invalid. InteractionResponded @@ -970,6 +991,12 @@ async def send_message( if view is not None: payload["components"] = view.to_components() + if view.is_components_v2(): + if embeds or content: + raise TypeError( + "cannot send embeds or content with a view using v2 component logic" + ) + flags.is_components_v2 = True if poll is not None: payload["poll"] = poll.to_dict() @@ -1035,7 +1062,8 @@ async def send_message( view.timeout = 15 * 60.0 view.parent = self._parent - self._parent._state.store_view(view) + if view.is_dispatchable(): + self._parent._state.store_view(view) self._responded = True if delete_after is not None: diff --git a/discord/message.py b/discord/message.py index 3523d84f83..4458844a26 100644 --- a/discord/message.py +++ b/discord/message.py @@ -1018,7 +1018,7 @@ def __init__( StickerItem(data=d, state=state) for d in data.get("sticker_items", []) ] self.components: list[Component] = [ - _component_factory(d) for d in data.get("components", []) + _component_factory(d, state=state) for d in data.get("components", []) ] try: @@ -1281,7 +1281,7 @@ def _handle_mention_roles(self, role_mentions: list[int]) -> None: self.role_mentions.append(role) def _handle_components(self, components: list[ComponentPayload]): - self.components = [_component_factory(d) for d in components] + self.components = [_component_factory(d, state=self._state) for d in components] def _rebind_cached_references( self, new_guild: Guild, new_channel: TextChannel | Thread @@ -1740,10 +1740,10 @@ async def edit( elif embeds is not MISSING: payload["embeds"] = [e.to_dict() for e in embeds] + flags = MessageFlags._from_value(self.flags.value) + if suppress is not MISSING: - flags = MessageFlags._from_value(self.flags.value) flags.suppress_embeds = suppress - payload["flags"] = flags.value if allowed_mentions is MISSING: if ( @@ -1765,9 +1765,14 @@ async def edit( if view is not MISSING: self._state.prevent_view_updates_for(self.id) payload["components"] = view.to_components() if view else [] + if view and view.is_components_v2(): + flags.is_components_v2 = True if file is not MISSING and files is not MISSING: raise InvalidArgument("cannot pass both file and files parameter to edit()") + if flags.value != self.flags.value: + payload["flags"] = flags.value + if file is not MISSING or files is not MISSING: if file is not MISSING: if not isinstance(file, File): @@ -1802,7 +1807,9 @@ async def edit( if view and not view.is_finished(): view.message = message - self._state.store_view(view, self.id) + view.refresh(message.components) + if view.is_dispatchable(): + self._state.store_view(view, self.id) if delete_after is not None: await self.delete(delay=delete_after) @@ -2443,7 +2450,9 @@ async def edit(self, **fields: Any) -> Message | None: msg = self._state.create_message(channel=self.channel, data=data) # type: ignore if view and not view.is_finished(): view.message = msg - self._state.store_view(view, self.id) + view.refresh(msg.components) + if view.is_dispatchable(): + self._state.store_view(view, self.id) return msg async def end_poll(self) -> Message: diff --git a/discord/types/components.py b/discord/types/components.py index 7b05f8bf08..16d9661b3a 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -33,17 +33,23 @@ from .emoji import PartialEmoji from .snowflake import Snowflake -ComponentType = Literal[1, 2, 3, 4] +ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 17] ButtonStyle = Literal[1, 2, 3, 4, 5, 6] InputTextStyle = Literal[1, 2] +SeparatorSpacingSize = Literal[1, 2] -class ActionRow(TypedDict): +class BaseComponent(TypedDict): + type: ComponentType + id: NotRequired[int] + + +class ActionRow(BaseComponent): type: Literal[1] - components: list[Component] + components: list[ButtonComponent, InputText, SelectMenu] -class ButtonComponent(TypedDict): +class ButtonComponent(BaseComponent): custom_id: NotRequired[str] url: NotRequired[str] disabled: NotRequired[bool] @@ -54,7 +60,7 @@ class ButtonComponent(TypedDict): sku_id: Snowflake -class InputText(TypedDict): +class InputText(BaseComponent): min_length: NotRequired[int] max_length: NotRequired[int] required: NotRequired[bool] @@ -74,7 +80,7 @@ class SelectOption(TypedDict): default: bool -class SelectMenu(TypedDict): +class SelectMenu(BaseComponent): placeholder: NotRequired[str] min_values: NotRequired[int] max_values: NotRequired[int] @@ -85,4 +91,74 @@ class SelectMenu(TypedDict): custom_id: str +class TextDisplayComponent(BaseComponent): + type: Literal[10] + content: str + + +class SectionComponent(BaseComponent): + type: Literal[9] + components: list[TextDisplayComponent] + accessory: NotRequired[ThumbnailComponent, ButtonComponent] + + +class UnfurledMediaItem(TypedDict): + url: str + proxy_url: str + height: NotRequired[int | None] + width: NotRequired[int | None] + content_type: NotRequired[str] + flags: NotRequired[int] + attachment_id: NotRequired[Snowflake] + + +class ThumbnailComponent(BaseComponent): + type: Literal[11] + media: UnfurledMediaItem + description: NotRequired[str] + spoiler: NotRequired[bool] + + +class MediaGalleryItem(TypedDict): + media: UnfurledMediaItem + description: NotRequired[str] + spoiler: NotRequired[bool] + + +class MediaGalleryComponent(BaseComponent): + type: Literal[12] + items: list[MediaGalleryItem] + + +class FileComponent(BaseComponent): + type: Literal[13] + file: UnfurledMediaItem + spoiler: NotRequired[bool] + name: str + size: int + + +class SeparatorComponent(BaseComponent): + type: Literal[14] + divider: NotRequired[bool] + spacing: NotRequired[SeparatorSpacingSize] + + +class ContainerComponent(BaseComponent): + type: Literal[17] + accent_color: NotRequired[int] + spoiler: NotRequired[bool] + components: list[AllowedContainerComponents] + + Component = Union[ActionRow, ButtonComponent, SelectMenu, InputText] + + +AllowedContainerComponents = Union[ + ActionRow, + TextDisplayComponent, + MediaGalleryComponent, + FileComponent, + SeparatorComponent, + SectionComponent, +] diff --git a/discord/ui/__init__.py b/discord/ui/__init__.py index fa1767d220..473ac45563 100644 --- a/discord/ui/__init__.py +++ b/discord/ui/__init__.py @@ -9,8 +9,15 @@ """ from .button import * +from .container import * +from .file import * from .input_text import * from .item import * +from .media_gallery import * from .modal import * +from .section import * from .select import * +from .separator import * +from .text_display import * +from .thumbnail import * from .view import * diff --git a/discord/ui/button.py b/discord/ui/button.py index 42d4af8d08..29e74cd8af 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -75,6 +75,13 @@ class Button(Item[V]): 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 4 (i.e. zero indexed). + + .. warning:: + + This parameter does not work with V2 components or with more than 25 items in your view. + + id: Optional[:class:`int`] + The button's ID. """ __item_repr_attributes__: tuple[str, ...] = ( @@ -85,6 +92,8 @@ class Button(Item[V]): "emoji", "sku_id", "row", + "custom_id", + "id", ) def __init__( @@ -98,6 +107,7 @@ def __init__( emoji: str | GuildEmoji | AppEmoji | PartialEmoji | None = None, sku_id: int | None = None, row: int | None = None, + id: int | None = None, ): super().__init__() if label and len(str(label)) > 80: @@ -145,6 +155,7 @@ def __init__( style=style, emoji=emoji, sku_id=sku_id, + id=id, ) self.row = row @@ -172,6 +183,7 @@ def custom_id(self, value: str | None): if value and len(value) > 100: raise ValueError("custom_id must be 100 characters or fewer") self._underlying.custom_id = value + self._provided_custom_id = value is not None @property def url(self) -> str | None: @@ -248,6 +260,7 @@ def from_component(cls: type[B], button: ButtonComponent) -> B: emoji=button.emoji, sku_id=button.sku_id, row=None, + id=button.id, ) @property @@ -260,6 +273,9 @@ def to_component_dict(self): def is_dispatchable(self) -> bool: return self.custom_id is not None + def is_storable(self) -> bool: + return self.is_dispatchable() + def is_persistent(self) -> bool: if self.style is ButtonStyle.link: return self.url is not None @@ -277,6 +293,7 @@ def button( style: ButtonStyle = ButtonStyle.secondary, emoji: str | GuildEmoji | AppEmoji | PartialEmoji | None = None, row: int | None = None, + id: int | None = None, ) -> Callable[[ItemCallbackType], ItemCallbackType]: """A decorator that attaches a button to a component. @@ -326,6 +343,7 @@ def decorator(func: ItemCallbackType) -> ItemCallbackType: "label": label, "emoji": emoji, "row": row, + "id": id, } return func diff --git a/discord/ui/container.py b/discord/ui/container.py new file mode 100644 index 0000000000..e18b0ec0d2 --- /dev/null +++ b/discord/ui/container.py @@ -0,0 +1,431 @@ +from __future__ import annotations + +from functools import partial +from typing import TYPE_CHECKING, ClassVar, Iterator, TypeVar + +from ..colour import Colour +from ..components import ActionRow +from ..components import Container as ContainerComponent +from ..components import _component_factory +from ..enums import ComponentType, SeparatorSpacingSize +from ..utils import find, get +from .file import File +from .item import Item, ItemCallbackType +from .media_gallery import MediaGallery +from .section import Section +from .separator import Separator +from .text_display import TextDisplay +from .view import _walk_all_components + +__all__ = ("Container",) + +if TYPE_CHECKING: + from typing_extensions import Self + + from ..types.components import ContainerComponent as ContainerComponentPayload + from .view import View + + +C = TypeVar("C", bound="Container") +V = TypeVar("V", bound="View", covariant=True) + + +class Container(Item[V]): + """Represents a UI Container. + + The current items supported are as follows: + + - :class:`discord.ui.Button` + - :class:`discord.ui.Select` + - :class:`discord.ui.Section` + - :class:`discord.ui.TextDisplay` + - :class:`discord.ui.MediaGallery` + - :class:`discord.ui.File` + - :class:`discord.ui.Separator` + + .. versionadded:: 2.7 + + Parameters + ---------- + *items: :class:`Item` + The initial items in this container. + colour: Union[:class:`Colour`, :class:`int`] + The accent colour of the container. Aliased to ``color`` as well. + spoiler: Optional[:class:`bool`] + Whether this container has the spoiler overlay. + id: Optional[:class:`int`] + The container's ID. + """ + + __item_repr_attributes__: tuple[str, ...] = ( + "items", + "colour", + "spoiler", + "id", + ) + + __container_children_items__: ClassVar[list[ItemCallbackType]] = [] + + def __init_subclass__(cls) -> None: + children: list[ItemCallbackType] = [] + for base in reversed(cls.__mro__): + for member in base.__dict__.values(): + if hasattr(member, "__discord_ui_model_type__"): + children.append(member) + + cls.__container_children_items__ = children + + def __init__( + self, + *items: Item, + colour: int | Colour | None = None, + color: int | Colour | None = None, + spoiler: bool = False, + id: int | None = None, + ): + super().__init__() + + self.items: list[Item] = [] + + self._underlying = ContainerComponent._raw_construct( + type=ComponentType.container, + id=id, + components=[], + accent_color=None, + spoiler=spoiler, + ) + self.color = colour or color + + for func in self.__container_children_items__: + item: Item = func.__discord_ui_model_type__( + **func.__discord_ui_model_kwargs__ + ) + item.callback = partial(func, self, item) + self.add_item(item) + setattr(self, func.__name__, item) + for i in items: + self.add_item(i) + + def _add_component_from_item(self, item: Item): + if item._underlying.is_v2(): + self._underlying.components.append(item._underlying) + else: + found = False + for row in reversed(self._underlying.components): + if ( + isinstance(row, ActionRow) and row.width + item.width <= 5 + ): # If a valid ActionRow exists + row.children.append(item._underlying) + found = True + elif not isinstance(row, ActionRow): + # create new row if last component is v2 + break + if not found: + row = ActionRow.with_components(item._underlying) + self._underlying.components.append(row) + + def _set_components(self, items: list[Item]): + self._underlying.components.clear() + for item in items: + self._add_component_from_item(item) + + def add_item(self, item: Item) -> Self: + """Adds an item to the container. + + Parameters + ---------- + item: :class:`Item` + The item to add to the container. + + Raises + ------ + TypeError + An :class:`Item` was not passed. + """ + + if not isinstance(item, Item): + raise TypeError(f"expected Item not {item.__class__!r}") + + item._view = self.view + if hasattr(item, "items"): + item.view = self + item.parent = self + + self.items.append(item) + self._add_component_from_item(item) + return self + + def remove_item(self, item: Item | str | int) -> Self: + """Removes an item from the container. If an int or str is passed, it will remove by Item :attr:`id` or ``custom_id`` respectively. + + Parameters + ---------- + item: Union[:class:`Item`, :class:`int`, :class:`str`] + The item, ``id``, or item ``custom_id`` to remove from the container. + """ + + if isinstance(item, (str, int)): + item = self.get_item(item) + try: + self.items.remove(item) + except ValueError: + pass + return self + + def get_item(self, id: str | int) -> Item | None: + """Get an item from this container. Roughly equivalent to `utils.get(container.items, ...)`. + If an ``int`` is provided, the item will be retrieved by ``id``, otherwise by ``custom_id``. + This method will also search for nested items. + + Parameters + ---------- + id: Union[:class:`str`, :class:`int`] + The id or custom_id of the item to get. + + Returns + ------- + Optional[:class:`Item`] + The item with the matching ``id`` or ``custom_id`` if it exists. + """ + if not id: + return None + attr = "id" if isinstance(id, int) else "custom_id" + child = find(lambda i: getattr(i, attr, None) == id, self.items) + if not child: + for i in self.items: + if hasattr(i, "get_item"): + if child := i.get_item(id): + return child + return child + + def add_section( + self, + *items: Item, + accessory: Item, + id: int | None = None, + ) -> Self: + """Adds a :class:`Section` to the container. + + To append a pre-existing :class:`Section`, use the + :meth:`add_item` method, instead. + + Parameters + ---------- + *items: :class:`Item` + The items contained in this section, up to 3. + Currently only supports :class:`~discord.ui.TextDisplay`. + accessory: Optional[:class:`Item`] + The section's accessory. This is displayed in the top right of the section. + Currently only supports :class:`~discord.ui.Button` and :class:`~discord.ui.Thumbnail`. + id: Optional[:class:`int`] + The section's ID. + """ + + section = Section(*items, accessory=accessory, id=id) + + return self.add_item(section) + + def add_text(self, content: str, id: int | None = None) -> Self: + """Adds a :class:`TextDisplay` to the container. + + Parameters + ---------- + content: :class:`str` + The content of the TextDisplay + id: Optiona[:class:`int`] + The text displays' ID. + """ + + text = TextDisplay(content, id=id) + + return self.add_item(text) + + def add_gallery( + self, + *items: Item, + id: int | None = None, + ) -> Self: + """Adds a :class:`MediaGallery` to the container. + + To append a pre-existing :class:`MediaGallery`, use :meth:`add_item` instead. + + Parameters + ---------- + *items: :class:`MediaGalleryItem` + The media this gallery contains. + id: Optiona[:class:`int`] + The gallery's ID. + """ + + g = MediaGallery(*items, id=id) + + return self.add_item(g) + + def add_file(self, url: str, spoiler: bool = False, id: int | None = None) -> Self: + """Adds a :class:`TextDisplay` to the container. + + Parameters + ---------- + url: :class:`str` + The URL of this file's media. This must be an ``attachment://`` URL that references a :class:`~discord.File`. + spoiler: Optional[:class:`bool`] + Whether the file has the spoiler overlay. Defaults to ``False``. + id: Optiona[:class:`int`] + The file's ID. + """ + + f = File(url, spoiler=spoiler, id=id) + + return self.add_item(f) + + def add_separator( + self, + *, + divider: bool = True, + spacing: SeparatorSpacingSize = SeparatorSpacingSize.small, + id: int | None = None, + ) -> Self: + """Adds a :class:`Separator` to the container. + + Parameters + ---------- + divider: :class:`bool` + Whether the separator is a divider. Defaults to ``True``. + spacing: :class:`~discord.SeparatorSpacingSize` + The spacing size of the separator. Defaults to :attr:`~discord.SeparatorSpacingSize.small`. + id: Optional[:class:`int`] + The separator's ID. + """ + + s = Separator(divider=divider, spacing=spacing, id=id) + + return self.add_item(s) + + def copy_text(self) -> str: + """Returns the text of all :class:`~discord.ui.TextDisplay` items in this container. + Equivalent to the `Copy Text` option on Discord clients. + """ + return "\n".join(t for i in self.items if (t := i.copy_text())) + + @property + def spoiler(self) -> bool: + """Whether the container has the spoiler overlay. Defaults to ``False``.""" + return self._underlying.spoiler + + @spoiler.setter + def spoiler(self, spoiler: bool) -> None: + self._underlying.spoiler = spoiler + + @property + def colour(self) -> Colour | None: + return self._underlying.accent_color + + @colour.setter + def colour(self, value: int | Colour | None): # type: ignore + if value is None or isinstance(value, Colour): + self._underlying.accent_color = value + elif isinstance(value, int): + self._underlying.accent_color = Colour(value=value) + else: + raise TypeError( + "Expected discord.Colour, int, or None but received" + f" {value.__class__.__name__} instead." + ) + + color = colour + + @Item.view.setter + def view(self, value): + self._view = value + for item in self.items: + item.parent = self + item._view = value + if hasattr(item, "items"): + item.view = value + + @property + def type(self) -> ComponentType: + return self._underlying.type + + @property + def width(self) -> int: + return 5 + + def is_dispatchable(self) -> bool: + return any(item.is_dispatchable() for item in self.items) + + def is_persistent(self) -> bool: + return all(item.is_persistent() for item in self.items) + + def refresh_component(self, component: ContainerComponent) -> None: + self._underlying = component + i = 0 + flattened = [] + for c in component.components: + if isinstance(c, ActionRow): + flattened += c.children + else: + flattened.append(c) + for y in flattened: + x = self.items[i] + x.refresh_component(y) + i += 1 + + def disable_all_items(self, *, exclusions: list[Item] | None = None) -> Self: + """ + Disables all buttons and select menus in the container. + + Parameters + ---------- + exclusions: Optional[List[:class:`Item`]] + A list of items in `self.items` to not disable from the view. + """ + for item in self.walk_items(): + if hasattr(item, "disabled") and ( + exclusions is None or item not in exclusions + ): + item.disabled = True + return self + + def enable_all_items(self, *, exclusions: list[Item] | None = None) -> Self: + """ + Enables all buttons and select menus in the container. + + Parameters + ---------- + exclusions: Optional[List[:class:`Item`]] + A list of items in `self.items` to not enable from the view. + """ + for item in self.walk_items(): + if hasattr(item, "disabled") and ( + exclusions is None or item not in exclusions + ): + item.disabled = False + return self + + def walk_items(self) -> Iterator[Item]: + for item in self.items: + if hasattr(item, "walk_items"): + yield from item.walk_items() + else: + yield item + + def to_component_dict(self) -> ContainerComponentPayload: + self._set_components(self.items) + return self._underlying.to_dict() + + @classmethod + def from_component(cls: type[C], component: ContainerComponent) -> C: + from .view import _component_to_item + + items = [ + _component_to_item(c) for c in _walk_all_components(component.components) + ] + return cls( + *items, + colour=component.accent_color, + spoiler=component.spoiler, + id=component.id, + ) + + callback = None diff --git a/discord/ui/file.py b/discord/ui/file.py new file mode 100644 index 0000000000..e502b46588 --- /dev/null +++ b/discord/ui/file.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, TypeVar +from urllib.parse import urlparse + +from ..components import FileComponent, UnfurledMediaItem, _component_factory +from ..enums import ComponentType +from .item import Item + +__all__ = ("File",) + +if TYPE_CHECKING: + from ..types.components import FileComponent as FileComponentPayload + from .view import View + + +F = TypeVar("F", bound="File") +V = TypeVar("V", bound="View", covariant=True) + + +class File(Item[V]): + """Represents a UI File. + + .. note:: + This component does not show media previews. Use :class:`MediaGallery` for previews instead. + + .. versionadded:: 2.7 + + Parameters + ---------- + url: :class:`str` + The URL of this file. This must be an ``attachment://`` URL referring to a local file used with :class:`~discord.File`. + spoiler: Optional[:class:`bool`] + Whether this file has the spoiler overlay. + id: Optional[:class:`int`] + The file component's ID. + """ + + __item_repr_attributes__: tuple[str, ...] = ( + "file", + "spoiler", + "id", + ) + + def __init__(self, url: str, *, spoiler: bool = False, id: int | None = None): + super().__init__() + + file = UnfurledMediaItem(url) + + self._underlying = FileComponent._raw_construct( + type=ComponentType.file, + id=id, + file=file, + spoiler=spoiler, + ) + + @property + def type(self) -> ComponentType: + return self._underlying.type + + @property + def width(self) -> int: + return 5 + + @property + def url(self) -> str: + """The URL of this file's media. This must be an ``attachment://`` URL that references a :class:`~discord.File`.""" + return self._underlying.file and self._underlying.file.url + + @url.setter + def url(self, value: str) -> None: + self._underlying.file.url = value + + @property + def spoiler(self) -> bool: + """Whether the file has the spoiler overlay. Defaults to ``False``.""" + return self._underlying.spoiler + + @spoiler.setter + def spoiler(self, spoiler: bool) -> None: + self._underlying.spoiler = spoiler + + @property + def name(self) -> str: + """The name of this file, if provided by Discord.""" + return self._underlying.name + + @property + def size(self) -> int: + """The size of this file in bytes, if provided by Discord.""" + return self._underlying.size + + def to_component_dict(self) -> FileComponentPayload: + return self._underlying.to_dict() + + @classmethod + def from_component(cls: type[F], component: FileComponent) -> F: + url = component.file and component.file.url + if not url.startswith("attachment://"): + url = "attachment://" + urlparse(url).path.rsplit("/", 1)[-1] + return cls( + url, + spoiler=component.spoiler, + id=component.id, + ) + + callback = None diff --git a/discord/ui/input_text.py b/discord/ui/input_text.py index dd7438be21..fa9d7615ab 100644 --- a/discord/ui/input_text.py +++ b/discord/ui/input_text.py @@ -48,6 +48,18 @@ class InputText: ordering. The row number must be between 0 and 4 (i.e. zero indexed). """ + __item_repr_attributes__: tuple[str, ...] = ( + "label", + "placeholder", + "value", + "required", + "style", + "min_length", + "max_length", + "custom_id", + "id", + ) + def __init__( self, *, @@ -60,6 +72,7 @@ def __init__( required: bool | None = True, value: str | None = None, row: int | None = None, + id: int | None = None, ): super().__init__() if len(str(label)) > 45: @@ -88,11 +101,18 @@ def __init__( max_length=max_length, required=required, value=value, + id=id, ) self._input_value = False self.row = row self._rendered_row: int | None = None + def __repr__(self) -> str: + attrs = " ".join( + f"{key}={getattr(self, key)!r}" for key in self.__item_repr_attributes__ + ) + return f"<{self.__class__.__name__} {attrs}>" + @property def type(self) -> ComponentType: return self._underlying.type @@ -102,6 +122,11 @@ def style(self) -> InputTextStyle: """The style of the input text field.""" return self._underlying.style + @property + def id(self) -> int | None: + """The input text's ID. If not provided by the user, it is set sequentially by Discord.""" + return self._underlying.id + @style.setter def style(self, value: InputTextStyle): if not isinstance(value, InputTextStyle): diff --git a/discord/ui/item.py b/discord/ui/item.py index fd8dc747ad..7c324b0b2b 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -44,12 +44,25 @@ class Item(Generic[V]): """Represents the base UI item that all UI components inherit from. - The current UI items supported are: + The following are the original items: - :class:`discord.ui.Button` - :class:`discord.ui.Select` + And the following are new items under the "Components V2" specification: + + - :class:`discord.ui.Section` + - :class:`discord.ui.TextDisplay` + - :class:`discord.ui.Thumbnail` + - :class:`discord.ui.MediaGallery` + - :class:`discord.ui.File` + - :class:`discord.ui.Separator` + - :class:`discord.ui.Container` + .. versionadded:: 2.0 + + .. versionchanged:: 2.7 + Added V2 Components. """ __item_repr_attributes__: tuple[str, ...] = ("row",) @@ -58,6 +71,7 @@ def __init__(self): self._view: V | None = None self._row: int | None = None self._rendered_row: int | None = None + self._underlying: Component | None = None # This works mostly well but there is a gotcha with # the interaction with from_component, since that technically provides # a custom_id most dispatchable items would get this set to True even though @@ -65,12 +79,13 @@ def __init__(self): # 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. self._provided_custom_id: bool = False + self.parent: Item | View | None = self.view def to_component_dict(self) -> dict[str, Any]: raise NotImplementedError def refresh_component(self, component: Component) -> None: - return None + self._underlying = component def refresh_state(self, interaction: Interaction) -> None: return None @@ -86,8 +101,14 @@ def type(self) -> ComponentType: def is_dispatchable(self) -> bool: return False + def is_storable(self) -> bool: + return False + def is_persistent(self) -> bool: - return self._provided_custom_id + return not self.is_dispatchable() or self._provided_custom_id + + def copy_text(self) -> str: + return "" def __repr__(self) -> str: attrs = " ".join( @@ -100,7 +121,7 @@ def row(self) -> int | None: """Gets or sets the row position of this item within its parent view. The row position determines the vertical placement of the item in the UI. - The value must be an integer between 0 and 4 (inclusive), or ``None`` to indicate + The value must be an integer between 0 and 39 (inclusive), or ``None`` to indicate that no specific row is set. Returns @@ -111,7 +132,7 @@ def row(self) -> int | None: Raises ------ ValueError - If the row value is not ``None`` and is outside the range [0, 4]. + If the row value is not ``None`` and is outside the range [0, 39]. """ return self._row @@ -119,10 +140,10 @@ def row(self) -> int | None: def row(self, value: int | None): if value is None: self._row = None - elif 5 > value >= 0: + elif 39 > value >= 0: self._row = value else: - raise ValueError("row cannot be negative or greater than or equal to 5") + raise ValueError("row cannot be negative or greater than or equal to 39") @property def width(self) -> int: @@ -137,6 +158,25 @@ def width(self) -> int: """ return 1 + @property + def id(self) -> int | None: + """Gets this item's ID. + + This can be set by the user when constructing an Item. If not, Discord will automatically provide one when the View is sent. + + Returns + ------- + Optional[:class:`int`] + The ID of this item, or ``None`` if the user didn't set one. + """ + return self._underlying and self._underlying.id + + @id.setter + def id(self, value) -> None: + if not self._underlying: + return + self._underlying.id = value + @property def view(self) -> V | None: """Gets the parent view associated with this item. diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py new file mode 100644 index 0000000000..b50daef71c --- /dev/null +++ b/discord/ui/media_gallery.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, TypeVar + +from ..components import MediaGallery as MediaGalleryComponent +from ..components import MediaGalleryItem +from ..enums import ComponentType +from .item import Item + +__all__ = ("MediaGallery",) + +if TYPE_CHECKING: + from typing_extensions import Self + + from ..types.components import MediaGalleryComponent as MediaGalleryComponentPayload + from .view import View + + +M = TypeVar("M", bound="MediaGallery") +V = TypeVar("V", bound="View", covariant=True) + + +class MediaGallery(Item[V]): + """Represents a UI Media Gallery. Galleries may contain up to 10 :class:`MediaGalleryItem` objects. + + .. versionadded:: 2.7 + + Parameters + ---------- + *items: :class:`MediaGalleryItem` + The initial items contained in this gallery, up to 10. + id: Optional[:class:`int`] + The gallery's ID. + """ + + __item_repr_attributes__: tuple[str, ...] = ( + "items", + "id", + ) + + def __init__(self, *items: MediaGalleryItem, id: int | None = None): + super().__init__() + + self._underlying = MediaGalleryComponent._raw_construct( + type=ComponentType.media_gallery, id=id, items=[i for i in items] + ) + + @property + def items(self): + return self._underlying.items + + def append_item(self, item: MediaGalleryItem) -> Self: + """Adds a :attr:`MediaGalleryItem` to the gallery. + + Parameters + ---------- + item: :class:`MediaGalleryItem` + The gallery 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.items) >= 10: + raise ValueError("maximum number of children exceeded") + + if not isinstance(item, MediaGalleryItem): + raise TypeError(f"expected MediaGalleryItem not {item.__class__!r}") + + self._underlying.items.append(item) + return self + + def add_item( + self, + url: str, + *, + description: str = None, + spoiler: bool = False, + ) -> None: + """Adds a new media item to the gallery. + + Parameters + ---------- + url: :class:`str` + The URL of the media item. This can either be an arbitrary URL or an ``attachment://`` URL. + description: Optional[:class:`str`] + The media item's description, up to 1024 characters. + spoiler: Optional[:class:`bool`] + Whether the media item has the spoiler overlay. + + Raises + ------ + ValueError + Maximum number of items has been exceeded (10). + """ + + if len(self.items) >= 10: + raise ValueError("maximum number of items exceeded") + + item = MediaGalleryItem(url, description=description, spoiler=spoiler) + + return self.append_item(item) + + @Item.view.setter + def view(self, value): + self._view = value + + @property + def type(self) -> ComponentType: + return self._underlying.type + + @property + def width(self) -> int: + return 5 + + def to_component_dict(self) -> MediaGalleryComponentPayload: + return self._underlying.to_dict() + + @classmethod + def from_component(cls: type[M], component: MediaGalleryComponent) -> M: + return cls(*component.items, id=component.id) + + callback = None diff --git a/discord/ui/modal.py b/discord/ui/modal.py index 966e6abe0f..58cf7db1e6 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -4,7 +4,6 @@ import os import sys import time -import traceback from functools import partial from itertools import groupby from typing import TYPE_CHECKING, Any, Callable @@ -18,6 +17,8 @@ if TYPE_CHECKING: + from typing_extensions import Self + from ..interactions import Interaction from ..state import ConnectionState @@ -44,6 +45,12 @@ class Modal: If ``None`` then there is no timeout. """ + __item_repr_attributes__: tuple[str, ...] = ( + "title", + "children", + "timeout", + ) + def __init__( self, *children: InputText, @@ -69,6 +76,12 @@ def __init__( self.__timeout_task: asyncio.Task[None] | None = None self.loop = asyncio.get_event_loop() + def __repr__(self) -> str: + attrs = " ".join( + f"{key}={getattr(self, key)!r}" for key in self.__item_repr_attributes__ + ) + return f"<{self.__class__.__name__} {attrs}>" + def _start_listening_from_store(self, store: ModalStore) -> None: self.__cancel_callback = partial(store.remove_modal) if self.timeout: @@ -188,7 +201,7 @@ def key(item: InputText) -> int: return components - def add_item(self, item: InputText): + def add_item(self, item: InputText) -> Self: """Adds an InputText component to the modal dialog. Parameters @@ -205,8 +218,9 @@ def add_item(self, item: InputText): self._weights.add_item(item) self._children.append(item) + return self - def remove_item(self, item: InputText): + def remove_item(self, item: InputText) -> Self: """Removes an InputText component from the modal dialog. Parameters @@ -218,6 +232,7 @@ def remove_item(self, item: InputText): self._children.remove(item) except ValueError: pass + return self def stop(self) -> None: """Stops listening to interaction events from the modal dialog.""" @@ -253,10 +268,7 @@ async def on_error(self, error: Exception, interaction: Interaction) -> None: interaction: :class:`~discord.Interaction` The interaction that led to the failure. """ - print(f"Ignoring exception in modal {self}:", file=sys.stderr) - traceback.print_exception( - error.__class__, error, error.__traceback__, file=sys.stderr - ) + interaction.client.dispatch("modal_error", error, interaction) async def on_timeout(self) -> None: """|coro| @@ -326,6 +338,7 @@ async def dispatch(self, user_id: int, custom_id: str, interaction: Interaction) value = self._modals.get(key) if value is None: return + interaction.modal = value try: components = [ diff --git a/discord/ui/section.py b/discord/ui/section.py new file mode 100644 index 0000000000..922f4819ad --- /dev/null +++ b/discord/ui/section.py @@ -0,0 +1,328 @@ +from __future__ import annotations + +from functools import partial +from typing import TYPE_CHECKING, ClassVar, Iterator, TypeVar + +from ..components import Section as SectionComponent +from ..components import _component_factory +from ..enums import ComponentType +from ..utils import find, get +from .button import Button +from .item import Item, ItemCallbackType +from .text_display import TextDisplay +from .thumbnail import Thumbnail + +__all__ = ("Section",) + +if TYPE_CHECKING: + from typing_extensions import Self + + from ..types.components import SectionComponent as SectionComponentPayload + from .view import View + + +S = TypeVar("S", bound="Section") +V = TypeVar("V", bound="View", covariant=True) + + +class Section(Item[V]): + """Represents a UI section. Sections must have 1-3 (inclusive) items and an accessory set. + + .. versionadded:: 2.7 + + Parameters + ---------- + *items: :class:`Item` + The initial items contained in this section, up to 3. + Currently only supports :class:`~discord.ui.TextDisplay`. + Sections must have at least 1 item before being sent. + accessory: Optional[:class:`Item`] + The section's accessory. This is displayed in the top right of the section. + Currently only supports :class:`~discord.ui.Button` and :class:`~discord.ui.Thumbnail`. + Sections must have an accessory attached before being sent. + id: Optional[:class:`int`] + The section's ID. + """ + + __item_repr_attributes__: tuple[str, ...] = ( + "items", + "accessory", + "id", + ) + + __section_accessory_item__: ClassVar[ItemCallbackType] = [] + + def __init_subclass__(cls) -> None: + accessory: list[ItemCallbackType] = [] + for base in reversed(cls.__mro__): + for member in base.__dict__.values(): + if hasattr(member, "__discord_ui_model_type__"): + accessory.append(member) + + cls.__section_accessory_item__ = accessory + + def __init__(self, *items: Item, accessory: Item = None, id: int | None = None): + super().__init__() + + self.items: list[Item] = [] + self.accessory: Item | None = None + + self._underlying = SectionComponent._raw_construct( + type=ComponentType.section, + id=id, + components=[], + accessory=None, + ) + for func in self.__section_accessory_item__: + item: Item = func.__discord_ui_model_type__( + **func.__discord_ui_model_kwargs__ + ) + item.callback = partial(func, self, item) + self.set_accessory(item) + setattr(self, func.__name__, item) + if accessory: + self.set_accessory(accessory) + for i in items: + self.add_item(i) + + def _add_component_from_item(self, item: Item): + self._underlying.components.append(item._underlying) + + def _set_components(self, items: list[Item]): + self._underlying.components.clear() + for item in items: + self._add_component_from_item(item) + + def add_item(self, item: Item) -> Self: + """Adds an item to the section. + + Parameters + ---------- + item: :class:`Item` + The item to add to the section. + + Raises + ------ + TypeError + An :class:`Item` was not passed. + ValueError + Maximum number of items has been exceeded (3). + """ + + if len(self.items) >= 3: + raise ValueError("maximum number of children exceeded") + + if not isinstance(item, Item): + raise TypeError(f"expected Item not {item.__class__!r}") + + item.parent = self + self.items.append(item) + self._add_component_from_item(item) + return self + + def remove_item(self, item: Item | str | int) -> Self: + """Removes an item from the section. If an :class:`int` or :class:`str` is passed, + the item will be removed by Item ``id`` or ``custom_id`` respectively. + + Parameters + ---------- + item: Union[:class:`Item`, :class:`int`, :class:`str`] + The item, item ``id``, or item ``custom_id`` to remove from the section. + """ + + if isinstance(item, (str, int)): + item = self.get_item(item) + try: + self.items.remove(item) + except ValueError: + pass + return self + + def get_item(self, id: int | str) -> Item | None: + """Get an item from this section. Alias for `utils.get(section.walk_items(), ...)`. + If an ``int`` is provided, it will be retrieved by ``id``, otherwise it will check the accessory's ``custom_id``. + + Parameters + ---------- + id: Union[:class:`str`, :class:`int`] + The id or custom_id of the item to get. + + Returns + ------- + Optional[:class:`Item`] + The item with the matching ``id`` if it exists. + """ + if not id: + return None + attr = "id" if isinstance(id, int) else "custom_id" + if self.accessory and id == getattr(self.accessory, attr, None): + return self.accessory + child = find(lambda i: getattr(i, attr, None) == id, self.items) + return child + + def add_text(self, content: str, *, id: int | None = None) -> Self: + """Adds a :class:`TextDisplay` to the section. + + Parameters + ---------- + content: :class:`str` + The content of the text display. + id: Optional[:class:`int`] + The text display's ID. + + Raises + ------ + ValueError + Maximum number of items has been exceeded (3). + """ + + if len(self.items) >= 3: + raise ValueError("maximum number of children exceeded") + + text = TextDisplay(content, id=id) + + return self.add_item(text) + + def set_accessory(self, item: Item) -> Self: + """Set an item as the section's :attr:`accessory`. + + Parameters + ---------- + item: :class:`Item` + The item to set as accessory. + Currently only supports :class:`~discord.ui.Button` and :class:`~discord.ui.Thumbnail`. + + Raises + ------ + TypeError + An :class:`Item` was not passed. + """ + + if not isinstance(item, Item): + raise TypeError(f"expected Item not {item.__class__!r}") + if self.view: + item._view = self.view + item.parent = self + + self.accessory = item + self._underlying.accessory = item._underlying + return self + + def set_thumbnail( + self, + url: str, + *, + description: str | None = None, + spoiler: bool = False, + id: int | None = None, + ) -> Self: + """Sets a :class:`Thumbnail` with the provided URL as the section's :attr:`accessory`. + + Parameters + ---------- + url: :class:`str` + The url of the thumbnail. + description: Optional[:class:`str`] + The thumbnail's description, up to 1024 characters. + spoiler: Optional[:class:`bool`] + Whether the thumbnail has the spoiler overlay. Defaults to ``False``. + id: Optional[:class:`int`] + The thumbnail's ID. + """ + + thumbnail = Thumbnail(url, description=description, spoiler=spoiler, id=id) + + return self.set_accessory(thumbnail) + + @Item.view.setter + def view(self, value): + self._view = value + for item in self.walk_items(): + item._view = value + item.parent = self + + def copy_text(self) -> str: + """Returns the text of all :class:`~discord.ui.TextDisplay` items in this section. + Equivalent to the `Copy Text` option on Discord clients. + """ + return "\n".join(t for i in self.items if (t := i.copy_text())) + + @property + def type(self) -> ComponentType: + return self._underlying.type + + @property + def width(self) -> int: + return 5 + + def is_dispatchable(self) -> bool: + return self.accessory and self.accessory.is_dispatchable() + + def is_persistent(self) -> bool: + if not isinstance(self.accessory, Button): + return True + return self.accessory.is_persistent() + + def refresh_component(self, component: SectionComponent) -> None: + self._underlying = component + for x, y in zip(self.items, component.components): + x.refresh_component(y) + if self.accessory and component.accessory: + self.accessory.refresh_component(component.accessory) + + def disable_all_items(self, *, exclusions: list[Item] | None = None) -> Self: + """ + Disables all buttons and select menus in the section. + At the moment, this only disables :attr:`accessory` if it is a button. + + Parameters + ---------- + exclusions: Optional[List[:class:`Item`]] + A list of items in `self.items` to not disable from the view. + """ + for item in self.walk_items(): + if hasattr(item, "disabled") and ( + exclusions is None or item not in exclusions + ): + item.disabled = True + return self + + def enable_all_items(self, *, exclusions: list[Item] | None = None) -> Self: + """ + Enables all buttons and select menus in the section. + At the moment, this only enables :attr:`accessory` if it is a button. + + Parameters + ---------- + exclusions: Optional[List[:class:`Item`]] + A list of items in `self.items` to not enable from the view. + """ + for item in self.walk_items(): + if hasattr(item, "disabled") and ( + exclusions is None or item not in exclusions + ): + item.disabled = False + return self + + def walk_items(self) -> Iterator[Item]: + r = self.items + if self.accessory: + yield from r + [self.accessory] + else: + yield from r + + def to_component_dict(self) -> SectionComponentPayload: + self._set_components(self.items) + if self.accessory: + self.set_accessory(self.accessory) + return self._underlying.to_dict() + + @classmethod + def from_component(cls: type[S], component: SectionComponent) -> S: + from .view import _component_to_item + + items = [_component_to_item(c) for c in component.components] + accessory = _component_to_item(component.accessory) + return cls(*items, accessory=accessory, id=component.id) + + callback = None diff --git a/discord/ui/select.py b/discord/ui/select.py index 35785890b5..7c2f8b1f4a 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -54,6 +54,8 @@ ) if TYPE_CHECKING: + from typing_extensions import Self + from ..abc import GuildChannel from ..types.components import SelectMenu as SelectMenuPayload from ..types.interactions import ComponentInteractionData @@ -110,6 +112,8 @@ class Select(Item[V]): 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 4 (i.e. zero indexed). + id: Optional[:class:`int`] + The select menu's ID. """ __item_repr_attributes__: tuple[str, ...] = ( @@ -120,6 +124,8 @@ class Select(Item[V]): "options", "channel_types", "disabled", + "custom_id", + "id", ) def __init__( @@ -134,6 +140,7 @@ def __init__( channel_types: list[ChannelType] | None = None, disabled: bool = False, row: int | None = None, + id: int | None = None, ) -> None: if options and select_type is not ComponentType.string_select: raise InvalidArgument("options parameter is only valid for string selects") @@ -166,6 +173,7 @@ def __init__( disabled=disabled, options=options or [], channel_types=channel_types or [], + id=id, ) self.row = row @@ -181,6 +189,7 @@ def custom_id(self, value: str): if len(value) > 100: raise ValueError("custom_id must be 100 characters or fewer") self._underlying.custom_id = value + self._provided_custom_id = value is not None @property def placeholder(self) -> str | None: @@ -262,7 +271,7 @@ def add_option( description: str | None = None, emoji: str | GuildEmoji | AppEmoji | PartialEmoji | None = None, default: bool = False, - ): + ) -> Self: """Adds an option to the select menu. To append a pre-existing :class:`discord.SelectOption` use the @@ -301,9 +310,9 @@ def add_option( default=default, ) - self.append_option(option) + return self.append_option(option) - def append_option(self, option: SelectOption): + def append_option(self, option: SelectOption) -> Self: """Appends an option to the select menu. Parameters @@ -323,6 +332,7 @@ def append_option(self, option: SelectOption): raise ValueError("maximum number of options already provided") self._underlying.options.append(option) + return self @property def values( @@ -427,6 +437,7 @@ def from_component(cls: type[S], component: SelectMenu) -> S: channel_types=component.channel_types, disabled=component.disabled, row=None, + id=component.id, ) @property @@ -436,6 +447,9 @@ def type(self) -> ComponentType: def is_dispatchable(self) -> bool: return True + def is_storable(self) -> bool: + return True + _select_types = ( ComponentType.string_select, @@ -457,6 +471,7 @@ def select( channel_types: list[ChannelType] = MISSING, disabled: bool = False, row: int | None = None, + id: int | None = None, ) -> Callable[[ItemCallbackType], ItemCallbackType]: """A decorator that attaches a select menu to a component. @@ -504,6 +519,8 @@ def select( Defaults to all channel types. disabled: :class:`bool` Whether the select is disabled or not. Defaults to ``False``. + id: Optional[:class:`int`] + The select menu's ID. """ if select_type not in _select_types: raise ValueError( @@ -531,6 +548,7 @@ def decorator(func: ItemCallbackType) -> ItemCallbackType: "min_values": min_values, "max_values": max_values, "disabled": disabled, + "id": id, } if options: model_kwargs["options"] = options @@ -554,6 +572,7 @@ def string_select( options: list[SelectOption] = MISSING, disabled: bool = False, row: int | None = None, + id: int | None = None, ) -> Callable[[ItemCallbackType], ItemCallbackType]: """A shortcut for :meth:`discord.ui.select` with select type :attr:`discord.ComponentType.string_select`. @@ -568,6 +587,7 @@ def string_select( options=options, disabled=disabled, row=row, + id=id, ) @@ -579,6 +599,7 @@ def user_select( max_values: int = 1, disabled: bool = False, row: int | None = None, + id: int | None = None, ) -> Callable[[ItemCallbackType], ItemCallbackType]: """A shortcut for :meth:`discord.ui.select` with select type :attr:`discord.ComponentType.user_select`. @@ -592,6 +613,7 @@ def user_select( max_values=max_values, disabled=disabled, row=row, + id=id, ) @@ -603,6 +625,7 @@ def role_select( max_values: int = 1, disabled: bool = False, row: int | None = None, + id: int | None = None, ) -> Callable[[ItemCallbackType], ItemCallbackType]: """A shortcut for :meth:`discord.ui.select` with select type :attr:`discord.ComponentType.role_select`. @@ -616,6 +639,7 @@ def role_select( max_values=max_values, disabled=disabled, row=row, + id=id, ) @@ -627,6 +651,7 @@ def mentionable_select( max_values: int = 1, disabled: bool = False, row: int | None = None, + id: int | None = None, ) -> Callable[[ItemCallbackType], ItemCallbackType]: """A shortcut for :meth:`discord.ui.select` with select type :attr:`discord.ComponentType.mentionable_select`. @@ -640,6 +665,7 @@ def mentionable_select( max_values=max_values, disabled=disabled, row=row, + id=id, ) @@ -652,6 +678,7 @@ def channel_select( disabled: bool = False, channel_types: list[ChannelType] = MISSING, row: int | None = None, + id: int | None = None, ) -> Callable[[ItemCallbackType], ItemCallbackType]: """A shortcut for :meth:`discord.ui.select` with select type :attr:`discord.ComponentType.channel_select`. @@ -666,4 +693,5 @@ def channel_select( disabled=disabled, channel_types=channel_types, row=row, + id=id, ) diff --git a/discord/ui/separator.py b/discord/ui/separator.py new file mode 100644 index 0000000000..6b81674401 --- /dev/null +++ b/discord/ui/separator.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, TypeVar + +from ..components import Separator as SeparatorComponent +from ..components import _component_factory +from ..enums import ComponentType, SeparatorSpacingSize +from .item import Item + +__all__ = ("Separator",) + +if TYPE_CHECKING: + from ..types.components import SeparatorComponent as SeparatorComponentPayload + from .view import View + + +S = TypeVar("S", bound="Separator") +V = TypeVar("V", bound="View", covariant=True) + + +class Separator(Item[V]): + """Represents a UI Separator. + + .. versionadded:: 2.7 + + Parameters + ---------- + divider: :class:`bool` + Whether the separator is a divider. Defaults to ``True``. + spacing: :class:`~discord.SeparatorSpacingSize` + The spacing size of the separator. Defaults to :attr:`~discord.SeparatorSpacingSize.small`. + id: Optional[:class:`int`] + The separator's ID. + """ + + __item_repr_attributes__: tuple[str, ...] = ( + "divider", + "spacing", + "id", + ) + + def __init__( + self, + *, + divider: bool = True, + spacing: SeparatorSpacingSize = SeparatorSpacingSize.small, + id: int | None = None, + ): + super().__init__() + + self._underlying = SeparatorComponent._raw_construct( + type=ComponentType.separator, + id=id, + divider=divider, + spacing=spacing, + ) + + @property + def type(self) -> ComponentType: + return self._underlying.type + + @property + def divider(self) -> bool: + """Whether the separator is a divider. Defaults to ``True``.""" + return self._underlying.divider + + @divider.setter + def divider(self, value: bool) -> None: + self._underlying.divider = value + + @property + def spacing(self) -> SeparatorSpacingSize: + """The spacing size of the separator. Defaults to :attr:`~discord.SeparatorSpacingSize.small`.""" + return self._underlying.spacing + + @spacing.setter + def spacing(self, value: SeparatorSpacingSize) -> None: + self._underlying.spacing = value + + @property + def width(self) -> int: + return 5 + + def to_component_dict(self) -> SeparatorComponentPayload: + return self._underlying.to_dict() + + @classmethod + def from_component(cls: type[S], component: SeparatorComponent) -> S: + return cls( + divider=component.divider, spacing=component.spacing, id=component.id + ) + + callback = None diff --git a/discord/ui/text_display.py b/discord/ui/text_display.py new file mode 100644 index 0000000000..6624500a3f --- /dev/null +++ b/discord/ui/text_display.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, TypeVar + +from ..components import TextDisplay as TextDisplayComponent +from ..components import _component_factory +from ..enums import ComponentType +from .item import Item + +__all__ = ("TextDisplay",) + +if TYPE_CHECKING: + from ..types.components import TextDisplayComponent as TextDisplayComponentPayload + from .view import View + + +T = TypeVar("T", bound="TextDisplay") +V = TypeVar("V", bound="View", covariant=True) + + +class TextDisplay(Item[V]): + """Represents a UI text display. A message can have up to 4000 characters across all :class:`TextDisplay` objects combined. + + .. versionadded:: 2.7 + + Parameters + ---------- + content: :class:`str` + The text display's content, up to 4000 characters. + id: Optional[:class:`int`] + The text display's ID. + """ + + __item_repr_attributes__: tuple[str, ...] = ( + "content", + "id", + ) + + def __init__( + self, + content: str, + id: int | None = None, + ): + super().__init__() + + self._underlying = TextDisplayComponent._raw_construct( + type=ComponentType.text_display, + id=id, + content=content, + ) + + @property + def type(self) -> ComponentType: + return self._underlying.type + + @property + def content(self) -> str: + """The text display's content.""" + return self._underlying.content + + @content.setter + def content(self, value: str) -> None: + self._underlying.content = value + + @property + def width(self) -> int: + return 5 + + def to_component_dict(self) -> TextDisplayComponentPayload: + return self._underlying.to_dict() + + def copy_text(self) -> str: + """Returns the content of this text display. Equivalent to the `Copy Text` option on Discord clients.""" + return self.content + + @classmethod + def from_component(cls: type[T], component: TextDisplayComponent) -> T: + return cls(component.content, id=component.id) + + callback = None diff --git a/discord/ui/thumbnail.py b/discord/ui/thumbnail.py new file mode 100644 index 0000000000..f14e3022eb --- /dev/null +++ b/discord/ui/thumbnail.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, TypeVar + +from ..components import Thumbnail as ThumbnailComponent +from ..components import UnfurledMediaItem, _component_factory +from ..enums import ComponentType +from .item import Item + +__all__ = ("Thumbnail",) + +if TYPE_CHECKING: + from ..types.components import ThumbnailComponent as ThumbnailComponentPayload + from .view import View + + +T = TypeVar("T", bound="Thumbnail") +V = TypeVar("V", bound="View", covariant=True) + + +class Thumbnail(Item[V]): + """Represents a UI Thumbnail. + + .. versionadded:: 2.7 + + Parameters + ---------- + url: :class:`str` + The url of the thumbnail. This can either be an arbitrary URL or an ``attachment://`` URL to work with local files. + description: Optional[:class:`str`] + The thumbnail's description, up to 1024 characters. + spoiler: Optional[:class:`bool`] + Whether the thumbnail has the spoiler overlay. Defaults to ``False``. + id: Optional[:class:`int`] + The thumbnail's ID. + """ + + __item_repr_attributes__: tuple[str, ...] = ( + "url", + "description", + "spoiler", + "id", + ) + + def __init__( + self, + url: str, + *, + description: str = None, + spoiler: bool = False, + id: int = None, + ): + super().__init__() + + media = UnfurledMediaItem(url) + + self._underlying = ThumbnailComponent._raw_construct( + type=ComponentType.thumbnail, + id=id, + media=media, + description=description, + spoiler=spoiler, + ) + + @property + def type(self) -> ComponentType: + return self._underlying.type + + @property + def width(self) -> int: + return 5 + + @property + def url(self) -> str: + """The URL of this thumbnail's media. This can either be an arbitrary URL or an ``attachment://`` URL.""" + return self._underlying.media and self._underlying.media.url + + @url.setter + def url(self, value: str) -> None: + self._underlying.media.url = value + + @property + def description(self) -> str | None: + """The thumbnail's description, up to 1024 characters.""" + return self._underlying.description + + @description.setter + def description(self, description: str | None) -> None: + self._underlying.description = description + + @property + def spoiler(self) -> bool: + """Whether the thumbnail has the spoiler overlay. Defaults to ``False``.""" + + return self._underlying.spoiler + + @spoiler.setter + def spoiler(self, spoiler: bool) -> None: + self._underlying.spoiler = spoiler + + def to_component_dict(self) -> ThumbnailComponentPayload: + return self._underlying.to_dict() + + @classmethod + def from_component(cls: type[T], component: ThumbnailComponent) -> T: + return cls( + component.media and component.media.url, + description=component.description, + spoiler=component.spoiler, + id=component.id, + ) + + callback = None diff --git a/discord/ui/view.py b/discord/ui/view.py index bbfa353478..64b0520172 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -29,7 +29,6 @@ import os import sys import time -import traceback from functools import partial from itertools import groupby from typing import TYPE_CHECKING, Any, Callable, ClassVar, Iterator, Sequence, TypeVar @@ -37,12 +36,19 @@ from ..components import ActionRow as ActionRowComponent from ..components import Button as ButtonComponent from ..components import Component +from ..components import Container as ContainerComponent +from ..components import FileComponent +from ..components import MediaGallery as MediaGalleryComponent +from ..components import Section as SectionComponent from ..components import SelectMenu as SelectComponent +from ..components import Separator as SeparatorComponent +from ..components import TextDisplay as TextDisplayComponent +from ..components import Thumbnail as ThumbnailComponent from ..components import _component_factory -from ..utils import get +from ..utils import find, get from .item import Item, ItemCallbackType -__all__ = ("View",) +__all__ = ("View", "_component_to_item", "_walk_all_components") if TYPE_CHECKING: @@ -62,7 +68,18 @@ def _walk_all_components(components: list[Component]) -> Iterator[Component]: yield item +def _walk_all_components_v2(components: list[Component]) -> Iterator[Component]: + for item in components: + if isinstance(item, ActionRowComponent): + yield from item.children + elif isinstance(item, (SectionComponent, ContainerComponent)): + yield from item.walk_components() + else: + yield item + + def _component_to_item(component: Component) -> Item[V]: + if isinstance(component, ButtonComponent): from .button import Button @@ -71,6 +88,38 @@ def _component_to_item(component: Component) -> Item[V]: from .select import Select return Select.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, ThumbnailComponent): + from .thumbnail import Thumbnail + + return Thumbnail.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, ContainerComponent): + from .container import Container + + return Container.from_component(component) + if isinstance(component, ActionRowComponent): + # Handle ActionRow.children manually, or design ui.ActionRow? + + return component return Item.from_component(component) @@ -88,12 +137,21 @@ def __init__(self, children: list[Item[V]]): def find_open_space(self, item: Item[V]) -> int: for index, weight in enumerate(self.weights): - if weight + item.width <= 5: + # check if open space AND (next row has no items OR this is the last row) + if (weight + item.width <= 5) and ( + (index < len(self.weights) - 1 and self.weights[index + 1] == 0) + or index == len(self.weights) - 1 + ): return index raise ValueError("could not find open space for item") def add_item(self, item: Item[V]) -> None: + if ( + item._underlying.is_v2() or not self.fits_legacy(item) + ) and not self.requires_v2(): + self.weights.extend([0, 0, 0, 0, 0] * 7) + if item.row is not None: total = self.weights[item.row] + item.width if total > 5: @@ -115,6 +173,14 @@ def remove_item(self, item: Item[V]) -> None: def clear(self) -> None: self.weights = [0, 0, 0, 0, 0] + def requires_v2(self) -> bool: + return sum(w > 0 for w in self.weights) > 5 or len(self.weights) > 5 + + def fits_legacy(self, item) -> bool: + if item.row is not None: + return item.row <= 4 + return self.weights[-1] + item.width <= 5 + class View: """Represents a UI view. @@ -158,8 +224,8 @@ def __init_subclass__(cls) -> None: if hasattr(member, "__discord_ui_model_type__"): children.append(member) - if len(children) > 25: - raise TypeError("View cannot have more than 25 children") + if len(children) > 40: + raise TypeError("View cannot have more than 40 children") cls.__view_children_items__ = children @@ -178,6 +244,7 @@ def __init__( ) item.callback = partial(func, self, item) item._view = self + item.parent = self setattr(self, func.__name__, item) self.children.append(item) @@ -221,16 +288,20 @@ def key(item: Item[V]) -> int: 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] + items = list(group) + children = [item.to_component_dict() for item in items] if not children: continue - components.append( - { - "type": 1, - "components": children, - } - ) + if any([i._underlying.is_v2() for i in items]): + components += children + else: + components.append( + { + "type": 1, + "components": children, + } + ) return components @@ -263,6 +334,35 @@ def from_message( view.add_item(_component_to_item(component)) return view + @classmethod + def from_dict( + cls, + data: list[Component], + /, + *, + timeout: float | None = 180.0, + ) -> View: + """Converts a list of component dicts into a :class:`View`. + + Parameters + ---------- + data: List[:class:`.Component`] + The list of 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) + components = [_component_factory(d) for d in data] + for component in _walk_all_components(components): + view.add_item(_component_to_item(component)) + return view + @property def _expires_at(self) -> float | None: if self.timeout: @@ -282,11 +382,11 @@ def add_item(self, item: Item[V]) -> None: TypeError An :class:`Item` was not passed. ValueError - Maximum number of children has been exceeded (25) + Maximum number of children has been exceeded (40) or the row the item is trying to be added to is full. """ - if len(self.children) > 25: + if len(self.children) >= 40: raise ValueError("maximum number of children exceeded") if not isinstance(item, Item): @@ -294,32 +394,43 @@ def add_item(self, item: Item[V]) -> None: self.__weights.add_item(item) + item.parent = self item._view = self + if hasattr(item, "items"): + item.view = self self.children.append(item) + return self - def remove_item(self, item: Item[V]) -> None: - """Removes an item from the view. + def remove_item(self, item: Item[V] | int | str) -> None: + """Removes an item from the view. If an :class:`int` or :class:`str` is passed, + the item will be removed by Item ``id`` or ``custom_id`` respectively. Parameters ---------- - item: :class:`Item` - The item to remove from the view. + item: Union[:class:`Item`, :class:`int`, :class:`str`] + The item, item ``id``, or item ``custom_id`` to remove from the view. """ + if isinstance(item, (str, int)): + item = self.get_item(item) try: self.children.remove(item) except ValueError: pass else: self.__weights.remove_item(item) + return self def clear_items(self) -> None: """Removes all items from the view.""" self.children.clear() self.__weights.clear() + return self - def get_item(self, custom_id: str) -> Item[V] | None: - """Get an item from the view with the given custom ID. Alias for `utils.get(view.children, custom_id=custom_id)`. + def get_item(self, custom_id: str | int) -> Item[V] | None: + """Gets an item from the view. Roughly equal to `utils.get(view.children, ...)`. + If an :class:`int` is provided, the item will be retrieved by ``id``, otherwise by ``custom_id``. + This method will also search nested items. Parameters ---------- @@ -329,9 +440,18 @@ def get_item(self, custom_id: str) -> Item[V] | None: Returns ------- Optional[:class:`Item`] - The item with the matching ``custom_id`` if it exists. + The item with the matching ``custom_id`` or ``id`` if it exists. """ - return get(self.children, custom_id=custom_id) + if not custom_id: + return None + attr = "id" if isinstance(custom_id, int) else "custom_id" + child = find(lambda i: getattr(i, attr, None) == custom_id, self.children) + if not child: + for i in self.children: + if hasattr(i, "get_item"): + if child := i.get_item(custom_id): + return child + return child async def interaction_check(self, interaction: Interaction) -> bool: """|coro| @@ -411,10 +531,7 @@ async def on_error( interaction: :class:`~discord.Interaction` The interaction that led to the failure. """ - print(f"Ignoring exception in view {self} for item {item}:", file=sys.stderr) - traceback.print_exception( - error.__class__, error, error.__traceback__, file=sys.stderr - ) + interaction.client.dispatch("view_error", error, item, interaction) async def _scheduled_task(self, item: Item[V], interaction: Interaction): try: @@ -461,26 +578,26 @@ def _dispatch_item(self, item: Item[V], interaction: Interaction): ) def refresh(self, components: list[Component]): - # This is pretty hacky at the moment - old_state: dict[tuple[int, str], Item[V]] = { - (item.type.value, item.custom_id): item for item in self.children if item.is_dispatchable() # type: ignore - } - children: list[Item[V]] = [ - item for item in self.children if not item.is_dispatchable() - ] - for component in _walk_all_components(components): + # Refreshes view data using discord's values + # Assumes the components and items are identical + if not components: + return + + i = 0 + flattened = [] + for c in components: + if isinstance(c, ActionRowComponent): + flattened += c.children + else: + flattened.append(c) + for c in flattened: try: - older = old_state[(component.type.value, component.custom_id)] # type: ignore - except (KeyError, AttributeError): - item = _component_to_item(component) - if not item.is_dispatchable(): - continue - children.append(item) + item = self.children[i] + except: + break else: - older.refresh_component(component) - children.append(older) - - self.children = children + item.refresh_component(c) + i += 1 def stop(self) -> None: """Stops listening to interaction events from this view. @@ -503,6 +620,9 @@ def is_finished(self) -> bool: """Whether the view has finished interacting.""" return self.__stopped.done() + def is_dispatchable(self) -> bool: + return any(item.is_dispatchable() for item in self.children) + def is_dispatching(self) -> bool: """Whether the view has been added for dispatching purposes.""" return self.__cancel_callback is not None @@ -517,6 +637,16 @@ def is_persistent(self) -> bool: item.is_persistent() for item in self.children ) + def is_components_v2(self) -> bool: + """Whether the view contains V2 components. + + A view containing V2 components cannot be sent alongside message content or embeds. + """ + return ( + any([item._underlying.is_v2() for item in self.children]) + or self.__weights.requires_v2() + ) + async def wait(self) -> bool: """Waits until the view has finished interacting. @@ -533,7 +663,7 @@ async def wait(self) -> bool: def disable_all_items(self, *, exclusions: list[Item[V]] | None = None) -> None: """ - Disables all items in the view. + Disables all buttons and select menus in the view. Parameters ---------- @@ -541,12 +671,17 @@ def disable_all_items(self, *, exclusions: list[Item[V]] | None = None) -> None: A list of items in `self.children` to not disable from the view. """ for child in self.children: - if exclusions is None or child not in exclusions: + if hasattr(child, "disabled") and ( + exclusions is None or child not in exclusions + ): child.disabled = True + if hasattr(child, "disable_all_items"): + child.disable_all_items(exclusions=exclusions) + return self def enable_all_items(self, *, exclusions: list[Item[V]] | None = None) -> None: """ - Enables all items in the view. + Enables all buttons and select menus in the view. Parameters ---------- @@ -554,8 +689,26 @@ def enable_all_items(self, *, exclusions: list[Item[V]] | None = None) -> None: A list of items in `self.children` to not enable from the view. """ for child in self.children: - if exclusions is None or child not in exclusions: + if hasattr(child, "disabled") and ( + exclusions is None or child not in exclusions + ): child.disabled = False + if hasattr(child, "enable_all_items"): + child.enable_all_items(exclusions=exclusions) + return self + + def walk_children(self) -> Iterator[Item]: + for item in self.children: + if hasattr(item, "walk_items"): + yield from item.walk_items() + else: + yield item + + def copy_text(self) -> str: + """Returns the text of all :class:`~discord.ui.TextDisplay` items in this View. + Equivalent to the `Copy Text` option on Discord clients. + """ + return "\n".join(t for i in self.children if (t := i.copy_text())) @property def message(self): @@ -596,16 +749,16 @@ def add_view(self, view: View, message_id: int | None = None): self.__verify_integrity() view._start_listening_from_store(self) - for item in view.children: - if item.is_dispatchable(): + for item in view.walk_children(): + if item.is_storable(): self._views[(item.type.value, message_id, item.custom_id)] = (view, item) # type: ignore if message_id is not None: self._synced_message_views[message_id] = view def remove_view(self, view: View): - for item in view.children: - if item.is_dispatchable(): + for item in view.walk_children(): + if item.is_storable(): self._views.pop((item.type.value, item.custom_id), None) # type: ignore for key, value in self._synced_message_views.items(): @@ -626,6 +779,7 @@ def dispatch(self, component_type: int, custom_id: str, interaction: Interaction return view, item = value + interaction.view = view item.refresh_state(interaction) view._dispatch_item(item, interaction) @@ -638,4 +792,5 @@ def remove_message_tracking(self, message_id: int) -> View | None: def update_from_message(self, message_id: int, components: list[ComponentPayload]): # pre-req: is_message_tracked == true view = self._synced_message_views[message_id] - view.refresh([_component_factory(d) for d in components]) + components = [_component_factory(d, state=self._state) for d in components] + view.refresh(components) diff --git a/discord/webhook/async_.py b/discord/webhook/async_.py index 3edc81d8e2..764bda3549 100644 --- a/discord/webhook/async_.py +++ b/discord/webhook/async_.py @@ -337,12 +337,20 @@ def execute_webhook( multipart: list[dict[str, Any]] | None = None, files: list[File] | None = None, thread_id: int | None = None, + thread_name: str | None = None, + with_components: bool | None = None, wait: bool = False, ) -> Response[MessagePayload | None]: params = {"wait": int(wait)} if thread_id: params["thread_id"] = thread_id + if thread_name: + payload["thread_name"] = thread_name + + if with_components is not None: + params["with_components"] = int(with_components) + route = Route( "POST", "/webhooks/{webhook_id}/{webhook_token}", @@ -400,12 +408,16 @@ def edit_webhook_message( payload: dict[str, Any] | None = None, multipart: list[dict[str, Any]] | None = None, files: list[File] | None = None, + with_components: bool | None = None, ) -> Response[WebhookMessage]: params = {} if thread_id: params["thread_id"] = thread_id + if with_components is not None: + params["with_components"] = int(with_components) + route = Route( "PATCH", "/webhooks/{webhook_id}/{webhook_token}/messages/{message_id}", @@ -650,8 +662,19 @@ def handle_message_parameters( if attachments is not MISSING: _attachments = [a.to_dict() for a in attachments] + flags = MessageFlags( + suppress_embeds=suppress, + ephemeral=ephemeral, + ) + if view is not MISSING: payload["components"] = view.to_components() if view is not None else [] + if view and view.is_components_v2(): + if payload.get("content") or payload.get("embeds"): + raise TypeError( + "cannot send embeds or content with a view using v2 component logic" + ) + flags.is_components_v2 = True if poll is not MISSING: payload["poll"] = poll.to_dict() payload["tts"] = tts @@ -660,11 +683,6 @@ def handle_message_parameters( if username: payload["username"] = username - flags = MessageFlags( - suppress_embeds=suppress, - ephemeral=ephemeral, - ) - if applied_tags is not MISSING: payload["applied_tags"] = applied_tags @@ -1752,8 +1770,8 @@ async def send( InvalidArgument Either there was no token associated with this webhook, ``ephemeral`` was passed with the improper webhook type, there was no state attached with this webhook when - giving it a view, you specified both ``thread_name`` and ``thread``, or ``applied_tags`` - was passed with neither ``thread_name`` nor ``thread`` specified. + giving it a dispatchable view, you specified both ``thread_name`` and ``thread``, + or ``applied_tags`` was passed with neither ``thread_name`` nor ``thread`` specified. """ if self.token is None: @@ -1782,13 +1800,21 @@ async def send( if application_webhook: wait = True + with_components = False + if view is not MISSING: - if isinstance(self._state, _WebhookState): + if ( + isinstance(self._state, _WebhookState) + and view + and view.is_dispatchable() + ): raise InvalidArgument( - "Webhook views require an associated state with the webhook" + "Dispatchable Webhook views require an associated state with the webhook" ) if ephemeral is True and view.timeout is None: view.timeout = 15 * 60.0 + if not application_webhook: + with_components = True if poll is None: poll = MISSING @@ -1826,6 +1852,7 @@ async def send( files=params.files, thread_id=thread_id, wait=wait, + with_components=with_components, ) msg = None @@ -1835,7 +1862,10 @@ async def send( if view is not MISSING and not view.is_finished(): message_id = None if msg is None else msg.id view.message = None if msg is None else msg - self._state.store_view(view, message_id) + if msg: + view.refresh(msg.components) + if view.is_dispatchable(): + self._state.store_view(view, message_id) if delete_after is not None: @@ -1990,13 +2020,21 @@ async def edit_message( "This webhook does not have a token associated with it" ) + with_components = False + if view is not MISSING: - if isinstance(self._state, _WebhookState): + if ( + isinstance(self._state, _WebhookState) + and view + and view.is_dispatchable() + ): raise InvalidArgument( - "This webhook does not have state associated with it" + "Dispatchable Webhook views require an associated state with the webhook" ) self._state.prevent_view_updates_for(message_id) + if self.type is not WebhookType.application: + with_components = True previous_mentions: AllowedMentions | None = getattr( self._state, "allowed_mentions", None @@ -2030,12 +2068,15 @@ async def edit_message( payload=params.payload, multipart=params.multipart, files=params.files, + with_components=with_components, ) message = self._create_message(data) if view and not view.is_finished(): view.message = message - self._state.store_view(view, message_id) + view.refresh(message.components) + if view.is_dispatchable(): + self._state.store_view(view, message_id) return message async def delete_message( diff --git a/docs/api/data_classes.rst b/docs/api/data_classes.rst index 1d891b90cd..2e2e814289 100644 --- a/docs/api/data_classes.rst +++ b/docs/api/data_classes.rst @@ -26,6 +26,16 @@ dynamic attributes in mind. .. autoclass:: SelectOption :members: +.. attributetable:: MediaGalleryItem + +.. autoclass:: SelectOption + :members: + +.. attributetable:: UnfurledMediaItem + +.. autoclass:: UnfurledMediaItem + :members: + .. attributetable:: Intents .. autoclass:: Intents diff --git a/docs/api/enums.rst b/docs/api/enums.rst index 4d278e3758..1210990717 100644 --- a/docs/api/enums.rst +++ b/docs/api/enums.rst @@ -2519,3 +2519,18 @@ of :class:`enum.Enum`. .. attribute:: inactive The subscription is inactive and the subscription owner is not being charged. + + +.. class:: SeparatorSpacingSize + + Represents the padding size around a separator component. + + .. versionadded:: 2.7 + + .. attribute:: small + + The separator uses small padding. + + .. attribute:: large + + The separator uses large padding. diff --git a/docs/api/models.rst b/docs/api/models.rst index cb702b2c38..ed8452ec1c 100644 --- a/docs/api/models.rst +++ b/docs/api/models.rst @@ -368,6 +368,9 @@ Interactions .. autoclass:: AuthorizingIntegrationOwners() :members: +Message Components +------------ + .. attributetable:: Component .. autoclass:: Component() @@ -390,6 +393,48 @@ Interactions :members: :inherited-members: +.. attributetable:: Section + +.. autoclass:: Section() + :members: + :inherited-members: + +.. attributetable:: TextDisplay + +.. autoclass:: TextDisplay() + :members: + :inherited-members: + +.. attributetable:: Thumbnail + +.. autoclass:: Thumbnail() + :members: + :inherited-members: + +.. attributetable:: MediaGallery + +.. autoclass:: MediaGallery() + :members: + :inherited-members: + +.. attributetable:: FileComponent + +.. autoclass:: FileComponent() + :members: + :inherited-members: + +.. attributetable:: Separator + +.. autoclass:: Separator() + :members: + :inherited-members: + +.. attributetable:: Container + +.. autoclass:: Container() + :members: + :inherited-members: + Emoji ----- diff --git a/docs/api/ui_kit.rst b/docs/api/ui_kit.rst index 18bb9de89b..ad2769eb03 100644 --- a/docs/api/ui_kit.rst +++ b/docs/api/ui_kit.rst @@ -55,6 +55,48 @@ Objects :members: :inherited-members: +.. attributetable:: discord.ui.Section + +.. autoclass:: discord.ui.Section + :members: + :inherited-members: + +.. attributetable:: discord.ui.TextDisplay + +.. autoclass:: discord.ui.TextDisplay + :members: + :inherited-members: + +.. attributetable:: discord.ui.Thumbnail + +.. autoclass:: discord.ui.Thumbnail + :members: + :inherited-members: + +.. attributetable:: discord.ui.MediaGallery + +.. autoclass:: discord.ui.MediaGallery + :members: + :inherited-members: + +.. attributetable:: discord.ui.File + +.. autoclass:: discord.ui.File + :members: + :inherited-members: + +.. attributetable:: discord.ui.Separator + +.. autoclass:: discord.ui.Separator + :members: + :inherited-members: + +.. attributetable:: discord.ui.Container + +.. autoclass:: discord.ui.Container + :members: + :inherited-members: + .. attributetable:: discord.ui.Modal .. autoclass:: discord.ui.Modal diff --git a/examples/views/new_components.py b/examples/views/new_components.py new file mode 100644 index 0000000000..5f323c7bca --- /dev/null +++ b/examples/views/new_components.py @@ -0,0 +1,81 @@ +from io import BytesIO + +from discord import ( + ApplicationContext, + Bot, + ButtonStyle, + Color, + File, + Interaction, + SeparatorSpacingSize, + User, +) +from discord.ui import ( + Button, + Container, + MediaGallery, + Section, + Select, + Separator, + TextDisplay, + Thumbnail, + View, + button, +) + + +class MyView(View): + def __init__(self, user: User): + super().__init__(timeout=30) + text1 = TextDisplay("### This is a sample `TextDisplay` in a `Section`.") + text2 = TextDisplay( + "This section is contained in a `Container`.\nTo the right, you can see a `Thumbnail`." + ) + thumbnail = Thumbnail(user.display_avatar.url) + + section = Section(text1, text2, accessory=thumbnail) + section.add_text("-# Small text") + + container = Container( + section, + TextDisplay("Another `TextDisplay` separate from the `Section`."), + color=Color.blue(), + ) + container.add_separator(divider=True, spacing=SeparatorSpacingSize.large) + container.add_item(Separator()) + container.add_file("attachment://sample.png") + container.add_text("Above is two `Separator`s followed by a `File`.") + + gallery = MediaGallery() + gallery.add_item(user.default_avatar.url) + gallery.add_item(user.avatar.url) + + self.add_item(container) + self.add_item(gallery) + self.add_item( + TextDisplay("Above is a `MediaGallery` containing two `MediaGalleryItem`s.") + ) + + @button(label="Delete Message", style=ButtonStyle.red, id=200) + async def delete_button(self, button: Button, interaction: Interaction): + await interaction.response.defer(invisible=True) + await interaction.message.delete() + + async def on_timeout(self): + self.get_item(200).disabled = True + await self.message.edit(view=self) + + +bot = Bot() + + +@bot.command() +async def show_view(ctx: ApplicationContext): + """Display a sample View showcasing various new components.""" + + f = await ctx.author.display_avatar.read() + file = File(BytesIO(f), filename="sample.png") + await ctx.respond(view=MyView(ctx.author), files=[file]) + + +bot.run("TOKEN")