diff --git a/CHANGELOG-NEXT.md b/CHANGELOG-NEXT.md index ab61bed6a2..4395a6e179 100644 --- a/CHANGELOG-NEXT.md +++ b/CHANGELOG-NEXT.md @@ -5,16 +5,37 @@ release. ### Added +- `ModalInteraction` provided to modal listeners during modal submit interactions +- `ComponentInteraction` provided to component listeners during component interactions +- `discord.components` module and its items + ### Fixed ### Changed - Removed the custom `enums.Enum` implementation in favor of a stdlib `enum.Enum` subclass. +- `InputText` use `TextInput` instead +- `ComponentType.input_text` use `ComponentType.text_input` instead +- `InputTextStyle` use `TextInputStyle` instead +- `TextInputStyle.singleline` use `TextInputStyle.short` instead +- `TextInputStyle.multiline` and `TextInputStyle.long` use `TextInputStyle.paragraph` instead +- `ComponentType.select` use `ComponentType.string_select` instead ### Deprecated ### Removed +- `Interaction.original_message` use `Interaction.original_response` instead +- `Interaction.edit_original_message` use `Interaction.edit_original_response` instead +- `Interaction.delete_original_message` use `Interaction.delete_original_response` + instead +- `Interaction.premium_required` use a `Button` with type `ButtonType.premium` instead +- `Interaction.cached_channel` use `Interaction.channel` instead +- `Message.interaction` use `Message.interaction_metadata` instead +- `MessageInteraction` see `InteractionMetadata` instead + +#### `discord.utils` + - `utils.filter_params` - `utils.sleep_until` use `asyncio.sleep` combined with `datetime.datetime` instead - `utils.compute_timedelta` use the `datetime` module instead @@ -28,3 +49,17 @@ release. - `AsyncIterator.get` use `AsyncIterator.find` with `lambda i: i.attr == val` instead - `utils.as_chunks` use `itertools.batched` on Python 3.12+ or your own implementation instead + +#### `discord.ui` + +Removed everything under `discord.ui`. Instead, use the new `discord.components` module +which provides a more flexible and powerful way to create interactive components. You +can read more in the migration guide. + + + +#### `discord.ext.pages` + +Removed the `discord.ext.pages` module. Instead, use the new `discord.components` module +with your own pagination logic. + diff --git a/discord/__init__.py b/discord/__init__.py index 8a613ad558..c123ad14df 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -24,7 +24,7 @@ # isort: on -from . import abc, opus, sinks, ui, utils +from . import abc, components, opus, sinks, utils from .activity import * from .appinfo import * from .application_role_connection import * @@ -38,7 +38,6 @@ from .collectibles import * from .colour import * from .commands import * -from .components import * from .embeds import * from .emoji import * from .enums import * diff --git a/discord/abc.py b/discord/abc.py index 3a40dbf4ea..056271b97e 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -28,13 +28,13 @@ import asyncio import copy import time +from collections.abc import Sequence from typing import ( TYPE_CHECKING, Any, Callable, Iterable, Protocol, - Sequence, TypeVar, Union, overload, @@ -84,6 +84,7 @@ VoiceChannel, ) from .client import Client + from .components import AnyComponent from .embeds import Embed from .enums import InviteTarget from .guild import Guild @@ -96,7 +97,6 @@ from .types.channel import GuildChannel as GuildChannelPayload from .types.channel import OverwriteType from .types.channel import PermissionOverwrite as PermissionOverwritePayload - from .ui.view import View from .user import ClientUser PartialMessageableChannel = TextChannel | VoiceChannel | StageChannel | Thread | DMChannel | PartialMessageable @@ -1295,7 +1295,7 @@ async def send( allowed_mentions: AllowedMentions = ..., reference: Message | MessageReference | PartialMessage = ..., mention_author: bool = ..., - view: View = ..., + components: Sequence[AnyComponent] = ..., poll: Poll = ..., suppress: bool = ..., silent: bool = ..., @@ -1316,7 +1316,7 @@ async def send( allowed_mentions: AllowedMentions = ..., reference: Message | MessageReference | PartialMessage = ..., mention_author: bool = ..., - view: View = ..., + components: Sequence[AnyComponent] = ..., poll: Poll = ..., suppress: bool = ..., silent: bool = ..., @@ -1337,7 +1337,7 @@ async def send( allowed_mentions: AllowedMentions = ..., reference: Message | MessageReference | PartialMessage = ..., mention_author: bool = ..., - view: View = ..., + components: Sequence[AnyComponent] = ..., poll: Poll = ..., suppress: bool = ..., silent: bool = ..., @@ -1358,7 +1358,7 @@ async def send( allowed_mentions: AllowedMentions = ..., reference: Message | MessageReference | PartialMessage = ..., mention_author: bool = ..., - view: View = ..., + components: Sequence[AnyComponent] = ..., poll: Poll = ..., suppress: bool = ..., silent: bool = ..., @@ -1380,7 +1380,7 @@ async def send( allowed_mentions=None, reference=None, mention_author=None, - view=None, + components=None, poll=None, suppress=None, silent=None, @@ -1449,8 +1449,10 @@ async def send( If set, overrides the :attr:`~discord.AllowedMentions.replied_user` attribute of ``allowed_mentions``. .. versionadded:: 1.6 - view: :class:`discord.ui.View` - A Discord UI View to add to the message. + components: :class:`Sequence[AnyComponent]` + A sequence of components to add to the message. + + .. versionadded:: 3.0 embeds: List[:class:`~discord.Embed`] A list of embeds to upload. Must be a maximum of 10. @@ -1541,17 +1543,15 @@ async def send( "reference parameter must be Message, MessageReference, or PartialMessage" ) from None - if view: - if not hasattr(view, "__discord_ui_view__"): - raise InvalidArgument(f"view parameter must be View not {view.__class__!r}") - - 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 components is not None: + components_p = [] + if components: + for c in components: + components_p.append(c.to_dict()) + if c.any_is_v2(): + flags.is_components_v2 = True else: - components = None + components_p = None if poll: poll = poll.to_dict() @@ -1584,7 +1584,7 @@ async def send( allowed_mentions=allowed_mentions, message_reference=_reference, stickers=stickers, - components=components, + components=components_p, flags=flags.value, poll=poll, ) @@ -1603,17 +1603,12 @@ async def send( allowed_mentions=allowed_mentions, message_reference=_reference, stickers=stickers, - components=components, + components=components_p, flags=flags.value, poll=poll, ) ret = state.create_message(channel=channel, data=data) - if view: - 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 41b126674a..114a08e8e3 100644 --- a/discord/bot.py +++ b/discord/bot.py @@ -34,6 +34,7 @@ import sys import traceback from abc import ABC, abstractmethod +from collections.abc import Awaitable from typing import ( TYPE_CHECKING, Any, @@ -42,9 +43,12 @@ Generator, Literal, Mapping, + TypeAlias, TypeVar, ) +from typing_extensions import Protocol + from .client import Client from .cog import CogMixin from .commands import ( @@ -64,9 +68,12 @@ from .types import interactions from .user import User from .utils import MISSING, find -from .utils.private import async_all +from .utils.private import async_all, maybe_awaitable if TYPE_CHECKING: + from typing import Unpack + + from .interactions import ComponentInteraction, ModalInteraction from .member import Member CoroFunc = Callable[..., Coroutine[Any, Any, Any]] @@ -1098,7 +1105,247 @@ async def invoke_application_command(self, ctx: ApplicationContext) -> None: def _bot(self) -> Bot | AutoShardedBot: ... -class BotBase(ApplicationCommandMixin, CogMixin, ABC): +class ComponentListener(Protocol): + async def __call__(self, interaction: ComponentInteraction[Any]) -> Any: ... + + +CL_t = TypeVar("CL_t", bound=ComponentListener) + + +class ComponentMixin(ABC): + """A mixin that provides component handling for the bot. + + This mixin is used to handle components such as buttons, select menus, and other interactive elements. + It is not intended to be used directly, but rather to implement component interactions to bots. + """ + + def __init__(self, *args: Any, **kwargs: Any): # pyright: ignore[reportExplicitAny] + super().__init__(*args, **kwargs) + # We map the listener to the predicate instead of the other way around + # so that removing a listener can be done directly and is more efficient + self.components: dict[ComponentListener, Callable[[str], bool | Awaitable[bool]]] = {} + self._bot.add_listener(self.handle_component_interaction, "on_interaction") + + async def handle_component_interaction(self, interaction: Interaction): + if interaction.type != InteractionType.component or not interaction.custom_id: + return + + callbacks: list[Coroutine[Any, Any, Any]] = [] # pyright: ignore[reportExplicitAny] + + for callback, predicate in self.components.items(): + if await maybe_awaitable(predicate, interaction.custom_id): + callbacks.append(callback(interaction)) + + if callbacks: + results = await asyncio.gather(*callbacks, return_exceptions=True) + for result in results: + if isinstance(result, Exception): + _log.error(f"Error while handling component interaction", exc_info=result) + else: + _log.debug(f"No component handler found for {interaction.custom_id}") + + def add_component_listener( + self, predicate: Callable[[str], bool | Awaitable[bool]] | str, listener: ComponentListener + ) -> None: + """Registers a component interaction listener. + + This method can be used to register a function that will be called + when a component interaction occurs that matches the provided predicate. + + .. versionadded:: 3.0 + + Parameters + ---------- + predicate: + A (potentially async) function that takes a string (the component's custom ID) and returns a boolean indicating whether the + function should be called for that component. Alternatively, a string can be provided, which will match + the component's custom ID exactly. + + listener: + The interaction callback to call when a component interaction occurs that matches the predicate. + """ + if isinstance(predicate, str): + real_predicate: Callable[[str], bool | Awaitable[bool]] = lambda s: s == predicate + else: + real_predicate = predicate + self.components[listener] = real_predicate + + def remove_component_listener(self, listener: ComponentListener) -> None: + """Unregisters a component interaction listener. + + This method can be used to unregister a function that was previously + registered as a component interaction listener. + + .. versionadded:: 3.0 + + Parameters + ---------- + listener: + The interaction callback to unregister. + + Raises + ------ + ValueError + The listener was not found. + """ + try: + del self.components[listener] + except KeyError as e: + raise ValueError("Listener not found") from e + + def component_listener(self, predicate: Callable[[str], bool | Awaitable[bool]] | str) -> Callable[[CL_t], CL_t]: + """A shortcut decorator that registers a component interaction listener. + + This decorator can be used to register a function that will be called + when a component interaction occurs that matches the provided predicate. + + .. versionadded:: 3.0 + + Parameters + ---------- + predicate: + A (potentially async) function that takes a string (the component's custom ID) and returns a boolean indicating whether the + function should be called for that component. Alternatively, a string can be provided, which will match + the component's custom ID exactly. + + Returns + ------- + Callable[[CI], CI] + A decorator that registers the function as a component interaction listener. + """ + + def wrapper(func: CL_t) -> CL_t: + self.add_component_listener(predicate, func) + return func + + return wrapper + + @property + @abstractmethod + def _bot(self) -> Bot | AutoShardedBot: ... + + +class ModalListener(Protocol): + async def __call__(self, interaction: ModalInteraction[Unpack[tuple[Any, ...]]]) -> Any: ... + + +ML_t = TypeVar("ML_t", bound=ModalListener) + + +class ModalMixin(ABC): + """A mixin that provides modal handling for the bot. + + This mixin is used to handle modals interactions. + It is not intended to be used directly, but rather to implement component interactions to bots. + """ + + def __init__(self, *args: Any, **kwargs: Any): # pyright: ignore[reportExplicitAny] + super().__init__(*args, **kwargs) + # We map the listener to the predicate instead of the other way around + # so that removing a listener can be done directly and is more efficient + self.modals: dict[ModalListener, Callable[[str], bool | Awaitable[bool]]] = {} + self._bot.add_listener(self.handle_modal_interaction, "on_interaction") + + async def handle_modal_interaction(self, interaction: Interaction): + if interaction.type != InteractionType.modal_submit or not interaction.custom_id: + return + + callbacks: list[Coroutine[Any, Any, Any]] = [] # pyright: ignore[reportExplicitAny] + + for callback, predicate in self.modals.items(): + if await maybe_awaitable(predicate, interaction.custom_id): + callbacks.append(callback(interaction)) + + if callbacks: + results = await asyncio.gather(*callbacks, return_exceptions=True) + for result in results: + if isinstance(result, Exception): + _log.error(f"Error while handling modal interaction", exc_info=result) + else: + _log.debug(f"No modal handler found for {interaction.custom_id}") + + def add_modal_listener( + self, predicate: Callable[[str], bool | Awaitable[bool]] | str, listener: ModalListener + ) -> None: + """Registers a modal interaction listener. + + This method can be used to register a function that will be called + when a modal interaction occurs that matches the provided predicate. + + .. versionadded:: 3.0 + + Parameters + ---------- + predicate: + A (potentially async) function that takes a string (the modal's custom ID) and returns a boolean indicating whether the + function should be called for that modal. Alternatively, a string can be provided, which will match + the modal's custom ID exactly. + + listener: + The interaction callback to call when a modal interaction occurs that matches the predicate. + """ + if isinstance(predicate, str): + real_predicate: Callable[[str], bool | Awaitable[bool]] = lambda s: s == predicate + else: + real_predicate = predicate + self.modals[listener] = real_predicate + + def remove_modal_listener(self, listener: ModalListener) -> None: + """Unregisters a modal interaction listener. + + This method can be used to unregister a function that was previously + registered as a modal interaction listener. + + .. versionadded:: 3.0 + + Parameters + ---------- + listener: + The interaction callback to unregister. + + Raises + ------ + ValueError + The listener was not found. + """ + try: + del self.modals[listener] + except KeyError as e: + raise ValueError("Listener not found") from e + + def modal_listener(self, predicate: Callable[[str], bool | Awaitable[bool]] | str) -> Callable[[ML_t], ML_t]: + """A shortcut decorator that registers a component interaction listener. + + This decorator can be used to register a function that will be called + when a component interaction occurs that matches the provided predicate. + + .. versionadded:: 3.0 + + Parameters + ---------- + predicate: + A (potentially async) function that takes a string (the component's custom ID) and returns a boolean indicating whether the + function should be called for that component. Alternatively, a string can be provided, which will match + the component's custom ID exactly. + + Returns + ------- + Callable[[CI], CI] + A decorator that registers the function as a component interaction listener. + """ + + def wrapper(func: ML_t) -> ML_t: + self.add_modal_listener(predicate, func) + return func + + return wrapper + + @property + @abstractmethod + def _bot(self) -> Bot | AutoShardedBot: ... + + +class BotBase(ApplicationCommandMixin, CogMixin, ComponentMixin, ModalMixin, ABC): _supports_prefixed_commands = False def __init__(self, description=None, *args, **options): diff --git a/discord/channel.py b/discord/channel.py index fb8b196265..2eaa7b2115 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -26,6 +26,7 @@ from __future__ import annotations import datetime +from collections.abc import Sequence from typing import ( TYPE_CHECKING, Any, @@ -42,6 +43,7 @@ from . import utils from .asset import Asset +from .components import AnyComponent from .emoji import GuildEmoji from .enums import ( ChannelType, @@ -1168,7 +1170,7 @@ async def create_thread( delete_message_after: float | None = None, nonce: int | str | None = None, allowed_mentions: AllowedMentions | None = None, - view: View | None = None, + components: Sequence[AnyComponent] | None = None, applied_tags: list[ForumTag] | None = None, suppress: bool = False, silent: bool = False, @@ -1213,8 +1215,8 @@ async def create_thread( to the object, otherwise it uses the attributes set in :attr:`~discord.Client.allowed_mentions`. If no object is passed at all then the defaults given by :attr:`~discord.Client.allowed_mentions` are used instead. - view: :class:`discord.ui.View` - A Discord UI View to add to the message. + components: Sequence[AnyComponent] + A sequence of components to add to the message. applied_tags: List[:class:`discord.ForumTag`] A list of tags to apply to the new thread. auto_archive_duration: :class:`int` @@ -1270,17 +1272,15 @@ async def create_thread( suppress_notifications=bool(silent), ) - if view: - if not hasattr(view, "__discord_ui_view__"): - raise InvalidArgument(f"view parameter must be View not {view.__class__!r}") - - 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 components is not None: + components_p = [] + if components: + for c in components: + components_p.append(c.to_dict()) + if c.any_is_v2(): + flags.is_components_v2 = True else: - components = None + components_p = None if applied_tags is not None: applied_tags = [str(tag.id) for tag in applied_tags] @@ -1310,7 +1310,7 @@ async def create_thread( nonce=nonce, allowed_mentions=allowed_mentions, stickers=stickers, - components=components, + components=components_p, auto_archive_duration=auto_archive_duration or self.default_auto_archive_duration, rate_limit_per_user=slowmode_delay or self.slowmode_delay, applied_tags=applied_tags, @@ -1324,8 +1324,6 @@ 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 and view.is_dispatchable(): - state.store_view(view, msg.id) if delete_message_after is not None: await msg.delete(delay=delete_message_after) diff --git a/discord/client.py b/discord/client.py index 225904bd50..4944cc16e1 100644 --- a/discord/client.py +++ b/discord/client.py @@ -61,7 +61,6 @@ from .sticker import GuildSticker, StandardSticker, StickerPack, _sticker_factory from .template import Template from .threads import Thread -from .ui.view import View from .user import ClientUser, User from .utils import MISSING from .utils.private import ( @@ -234,6 +233,7 @@ def __init__( self, *, loop: asyncio.AbstractEventLoop | None = None, + discord_api_url: str = "https://discord.com/api/v10", **options: Any, ): self._flavor = options.get("flavor", logging.INFO) @@ -256,6 +256,7 @@ def __init__( proxy_auth=proxy_auth, unsync_clock=unsync_clock, loop=self.loop, + discord_api_url=discord_api_url, ) self._handlers: dict[str, Callable] = {"ready": self._handle_ready} @@ -2009,48 +2010,6 @@ async def create_dm(self, user: Snowflake) -> DMChannel: data = await state.http.start_private_message(user.id) return state.add_dm_channel(data) - def add_view(self, view: View, *, message_id: int | None = None) -> None: - """Registers a :class:`~discord.ui.View` for persistent listening. - - This method should be used for when a view is comprised of components - that last longer than the lifecycle of the program. - - .. versionadded:: 2.0 - - Parameters - ---------- - view: :class:`discord.ui.View` - The view to register for dispatching. - message_id: Optional[:class:`int`] - The message ID that the view is attached to. This is currently used to - refresh the view's state during message update events. If not given - then message update events are not propagated for the view. - - Raises - ------ - TypeError - A view was not passed. - ValueError - The view is not persistent. A persistent view has no timeout - and all their components have an explicitly provided ``custom_id``. - """ - - if not isinstance(view, View): - raise TypeError(f"expected an instance of View not {view.__class__!r}") - - if not view.is_persistent(): - raise ValueError("View is not persistent. Items need to have a custom_id set and View must have no timeout") - - self._connection.store_view(view, message_id) - - @property - def persistent_views(self) -> Sequence[View]: - """A sequence of persistent views added to the client. - - .. versionadded:: 2.0 - """ - return self._connection.persistent_views - async def fetch_role_connection_metadata_records( self, ) -> list[ApplicationRoleConnectionMetadata]: diff --git a/discord/components.py b/discord/components.py deleted file mode 100644 index 3e74caae58..0000000000 --- a/discord/components.py +++ /dev/null @@ -1,1044 +0,0 @@ -""" -The MIT License (MIT) - -Copyright (c) 2015-2021 Rapptz -Copyright (c) 2021-present Pycord Development - -Permission is hereby granted, free of charge, to any person obtaining a -copy of this software and associated documentation files (the "Software"), -to deal in the Software without restriction, including without limitation -the rights to use, copy, modify, merge, publish, distribute, sublicense, -and/or sell copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Any, 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, Undefined -from .utils.private import 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", - "ActionRow", - "Button", - "SelectMenu", - "SelectOption", - "InputText", - "Section", - "TextDisplay", - "Thumbnail", - "MediaGallery", - "MediaGalleryItem", - "UnfurledMediaItem", - "FileComponent", - "Separator", - "Container", -) - -C = TypeVar("C", bound="Component") - - -class Component: - """Represents a Discord Bot UI Kit Component. - - 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. - - .. versionadded:: 2.0 - - Attributes - ---------- - 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", "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__) - return f"<{self.__class__.__name__} {attrs}>" - - @classmethod - def _raw_construct(cls: type[C], **kwargs) -> C: - self: C = cls.__new__(cls) - for slot in get_slots(cls): - try: - value = kwargs[slot] - except KeyError: - pass - else: - setattr(self, slot, value) - return self - - 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. - - This is a component that holds up to 5 children components in a row. - - This inherits from :class:`Component`. - - .. versionadded:: 2.0 - - Attributes - ---------- - type: :class:`ComponentType` - The type of component. - children: List[:class:`Component`] - The children components that this holds, if any. - """ - - __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. - This inherits from :class:`Component`. - - Attributes - ---------- - style: :class:`.InputTextStyle` - The style of the input text field. - custom_id: Optional[:class:`str`] - 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`] - The placeholder text that is shown if nothing is selected, if any. - min_length: Optional[:class:`int`] - The minimum number of characters that must be entered - Defaults to 0 - max_length: Optional[:class:`int`] - The maximum number of characters that can be entered - required: Optional[:class:`bool`] - 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, ...] = ( - "type", - "style", - "custom_id", - "label", - "placeholder", - "min_length", - "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) - self.placeholder: str | None = data.get("placeholder", None) - self.min_length: int | None = data.get("min_length", None) - self.max_length: int | None = data.get("max_length", None) - self.required: bool = data.get("required", True) - self.value: str | None = data.get("value", None) - - def to_dict(self) -> InputTextComponentPayload: - payload = { - "type": 4, - "id": self.id, - "style": self.style.value, - "label": self.label, - } - if self.custom_id: - payload["custom_id"] = self.custom_id - - if self.placeholder: - payload["placeholder"] = self.placeholder - - if self.min_length: - payload["min_length"] = self.min_length - - if self.max_length: - payload["max_length"] = self.max_length - - if not self.required: - payload["required"] = self.required - - if self.value: - payload["value"] = self.value - - return payload # type: ignore - - -class Button(Component): - """Represents a button from the Discord Bot UI Kit. - - This inherits from :class:`Component`. - - .. note:: - - This class is not useable by end-users; see :class:`discord.ui.Button` instead. - - .. versionadded:: 2.0 - - Attributes - ---------- - style: :class:`.ButtonStyle` - The style of the button. - custom_id: Optional[:class:`str`] - The ID of the button that gets received during an interaction. - If this button is for a URL, it does not have a custom ID. - url: Optional[:class:`str`] - The URL this button sends you to. - disabled: :class:`bool` - Whether the button is disabled or not. - label: Optional[:class:`str`] - The label of the button, if any. - emoji: Optional[:class:`PartialEmoji`] - The emoji of the button, if available. - sku_id: Optional[:class:`int`] - The ID of the SKU this button refers to. - """ - - __slots__: tuple[str, ...] = ( - "style", - "custom_id", - "url", - "disabled", - "label", - "emoji", - "sku_id", - ) - - __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 - 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, - } - if self.custom_id: - payload["custom_id"] = self.custom_id - - if self.url: - payload["url"] = self.url - - if self.emoji: - payload["emoji"] = self.emoji.to_dict() - - if self.sku_id: - payload["sku_id"] = self.sku_id - - return payload # type: ignore - - -class SelectMenu(Component): - """Represents a select menu from the Discord Bot UI Kit. - - A select menu is functionally the same as a dropdown, however - on mobile it renders a bit differently. - - .. note:: - - This class is not useable by end-users; see :class:`discord.ui.Select` instead. - - .. versionadded:: 2.0 - - .. versionchanged:: 2.3 - - Added support for :attr:`ComponentType.user_select`, :attr:`ComponentType.role_select`, - :attr:`ComponentType.mentionable_select`, and :attr:`ComponentType.channel_select`. - - Attributes - ---------- - type: :class:`ComponentType` - The select menu's type. - custom_id: Optional[:class:`str`] - The ID of the select menu that gets received during an interaction. - placeholder: Optional[:class:`str`] - The placeholder text that is shown if nothing is selected, if any. - min_values: :class:`int` - The minimum number of items that must be chosen for this select menu. - Defaults to 1 and must be between 0 and 25. - max_values: :class:`int` - The maximum number of items that must be chosen for this select menu. - Defaults to 1 and must be between 1 and 25. - options: List[:class:`SelectOption`] - A list of options that can be selected in this menu. - Will be an empty list for all component types - except for :attr:`ComponentType.string_select`. - channel_types: List[:class:`ChannelType`] - A list of channel types that can be selected. - Will be an empty list for all component types - except for :attr:`ComponentType.channel_select`. - disabled: :class:`bool` - Whether the select is disabled or not. - """ - - __slots__: tuple[str, ...] = ( - "custom_id", - "placeholder", - "min_values", - "max_values", - "options", - "channel_types", - "disabled", - ) - - __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) - self.max_values: int = data.get("max_values", 1) - self.disabled: bool = data.get("disabled", False) - self.options: list[SelectOption] = [SelectOption.from_dict(option) for option in data.get("options", [])] - self.channel_types: list[ChannelType] = [try_enum(ChannelType, ct) for ct in data.get("channel_types", [])] - - 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, - "disabled": self.disabled, - } - - if self.type is ComponentType.string_select: - payload["options"] = [op.to_dict() for op in self.options] - if self.type is ComponentType.channel_select and self.channel_types: - payload["channel_types"] = [ct.value for ct in self.channel_types] - if self.placeholder: - payload["placeholder"] = self.placeholder - - return payload - - -class SelectOption: - """Represents a :class:`discord.SelectMenu`'s option. - - These can be created by users. - - .. versionadded:: 2.0 - - Attributes - ---------- - label: :class:`str` - The label of the option. This is displayed to users. - Can only be up to 100 characters. - value: :class:`str` - The value of the option. This is not displayed to users. - If not provided when constructed then it defaults to the - label. Can only be up to 100 characters. - description: Optional[:class:`str`] - An additional description of the option, if any. - Can only be up to 100 characters. - default: :class:`bool` - Whether this option is selected by default. - """ - - __slots__: tuple[str, ...] = ( - "label", - "value", - "description", - "_emoji", - "default", - ) - - def __init__( - self, - *, - label: str, - value: str | Undefined = MISSING, - description: str | None = None, - emoji: str | GuildEmoji | AppEmoji | PartialEmoji | None = None, - default: bool = False, - ) -> None: - if len(label) > 100: - raise ValueError("label must be 100 characters or fewer") - - if value is not MISSING and len(value) > 100: - raise ValueError("value must be 100 characters or fewer") - - if description is not None and len(description) > 100: - raise ValueError("description must be 100 characters or fewer") - - self.label = label - self.value = label if value is MISSING else value - self.description = description - self.emoji = emoji - self.default = default - - def __repr__(self) -> str: - return ( - "" - ) - - def __str__(self) -> str: - base = f"{self.emoji} {self.label}" if self.emoji else self.label - if self.description: - return f"{base}\n{self.description}" - return base - - @property - def emoji(self) -> str | GuildEmoji | AppEmoji | PartialEmoji | None: - """The emoji of the option, if available.""" - return self._emoji - - @emoji.setter - def emoji(self, value) -> None: - if value is not None: - if isinstance(value, str): - value = PartialEmoji.from_str(value) - elif isinstance(value, _EmojiTag): - value = value._to_partial() - else: - raise TypeError( - f"expected emoji to be str, GuildEmoji, AppEmoji, or PartialEmoji, not {value.__class__}" - ) - - self._emoji = value - - @classmethod - def from_dict(cls, data: SelectOptionPayload) -> SelectOption: - if e := data.get("emoji"): - emoji = PartialEmoji.from_dict(e) - else: - emoji = None - - return cls( - label=data["label"], - value=data["value"], - description=data.get("description"), - emoji=emoji, - default=data.get("default", False), - ) - - def to_dict(self) -> SelectOptionPayload: - payload: SelectOptionPayload = { - "label": self.label, - "value": self.value, - "default": self.default, - } - - if self.emoji: - payload["emoji"] = self.emoji.to_dict() # type: ignore - - if self.description: - payload["description"] = self.description - - return payload - - -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._static_url: str | None = url if url and url.startswith("attachment://") else None - 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 - - def __repr__(self) -> str: - return f"" - - def __str__(self) -> str: - return self.url or self.__repr__() - - @property - def url(self) -> str: - """The URL of this media item. This can either be an arbitrary URL or an ``attachment://`` URL to work with local files.""" - return self._url - - @url.setter - def url(self, value: str) -> None: - self._url = value - self._static_url = value if value and value.startswith("attachment://") else None - - @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._static_url or 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: - """The URL of this gallery item. - - This can either be an arbitrary URL or an ``attachment://`` URL to work with local files. - """ - 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 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/components/__init__.py b/discord/components/__init__.py new file mode 100644 index 0000000000..e1d1a7022d --- /dev/null +++ b/discord/components/__init__.py @@ -0,0 +1,132 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from ._component_factory import _component_factory # pyright: ignore[reportPrivateUsage] +from .action_row import ActionRow +from .button import Button +from .channel_select_menu import ChannelSelect +from .component import Component, ModalComponentMixin, StateComponentMixin, WalkableComponentMixin +from .components_holder import ComponentsHolder +from .container import Container +from .default_select_option import DefaultSelectOption +from .file_component import FileComponent +from .file_upload import FileUpload +from .label import Label +from .media_gallery import MediaGallery +from .media_gallery_item import MediaGalleryItem +from .mentionable_select_menu import MentionableSelect +from .modal import Modal +from .partial_components import ( + PartialButton, + PartialChannelSelect, + PartialComponent, + PartialFileUpload, + PartialLabel, + PartialMentionableSelect, + PartialRoleSelect, + PartialSelect, + PartialStringSelect, + PartialTextDisplay, + PartialTextInput, + PartialUserSelect, + PartialWalkableComponentMixin, + UnknownPartialComponent, + _partial_component_factory, # pyright: ignore[reportPrivateUsage] +) +from .role_select_menu import RoleSelect +from .section import Section +from .select_menu import Select +from .select_option import SelectOption +from .separator import Separator +from .string_select_menu import StringSelect +from .text_display import TextDisplay +from .text_input import TextInput +from .thumbnail import Thumbnail + +# Don't change the import order +from .type_aliases import ( + AnyComponent, + AnyMessagePartialComponent, + AnyPartialComponent, + AnyTopLevelMessageComponent, + AnyTopLevelModalComponent, + AnyTopLevelModalPartialComponent, +) +from .unfurled_media_item import UnfurledMediaItem +from .unknown_component import UnknownComponent +from .user_select_menu import UserSelect + +__all__ = ( + "Component", + "StateComponentMixin", + "WalkableComponentMixin", + "ModalComponentMixin", + "ComponentsHolder", + "ActionRow", + "Button", + "Select", + "StringSelect", + "UserSelect", + "RoleSelect", + "MentionableSelect", + "ChannelSelect", + "AnyMessagePartialComponent", + "SelectOption", + "DefaultSelectOption", + "TextInput", + "Section", + "TextDisplay", + "Thumbnail", + "MediaGallery", + "MediaGalleryItem", + "UnfurledMediaItem", + "FileComponent", + "FileUpload", + "Separator", + "Container", + "Label", + "Modal", + "UnknownComponent", + "_component_factory", + "PartialLabel", + "PartialComponent", + "PartialSelect", + "PartialStringSelect", + "PartialUserSelect", + "PartialButton", + "PartialRoleSelect", + "PartialMentionableSelect", + "PartialChannelSelect", + "PartialTextInput", + "PartialTextDisplay", + "UnknownPartialComponent", + "PartialFileUpload", + "_partial_component_factory", + "AnyComponent", + "AnyTopLevelModalComponent", + "AnyTopLevelMessageComponent", + "AnyPartialComponent", + "AnyTopLevelModalPartialComponent", + "PartialWalkableComponentMixin", +) diff --git a/discord/components/_component_factory.py b/discord/components/_component_factory.py new file mode 100644 index 0000000000..eea6721139 --- /dev/null +++ b/discord/components/_component_factory.py @@ -0,0 +1,89 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-2021 Rapptz +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, TypeVar + +from .action_row import ActionRow +from .button import Button +from .channel_select_menu import ChannelSelect +from .component import Component, StateComponentMixin +from .container import Container +from .file_component import FileComponent +from .file_upload import FileUpload +from .label import Label +from .media_gallery import MediaGallery +from .mentionable_select_menu import MentionableSelect +from .role_select_menu import RoleSelect +from .section import Section +from .separator import Separator +from .string_select_menu import StringSelect +from .text_display import TextDisplay +from .text_input import TextInput +from .thumbnail import Thumbnail +from .unknown_component import UnknownComponent +from .user_select_menu import UserSelect + +if TYPE_CHECKING: + from ..state import ConnectionState + from ..types.component_types import Component as ComponentPayload + +P = TypeVar("P", bound="ComponentPayload") + + +COMPONENT_MAPPINGS = { + 1: ActionRow, + 2: Button, + 3: StringSelect, + 4: TextInput, + 5: UserSelect, + 6: RoleSelect, + 7: MentionableSelect, + 8: ChannelSelect, + 9: Section, + 10: TextDisplay, + 11: Thumbnail, + 12: MediaGallery, + 13: FileComponent, + 14: Separator, + 17: Container, + 18: Label, + 19: FileUpload, +} + + +def _component_factory(data: P, state: ConnectionState | None = None) -> Component[P]: + component_type = data["type"] + if cls := COMPONENT_MAPPINGS.get(component_type): + if issubclass(cls, StateComponentMixin): + return cls.from_payload(data, state=state) # pyright: ignore[ reportReturnType, reportArgumentType] + else: + return cls.from_payload(data) # pyright: ignore[reportArgumentType, reportReturnType] + else: + return UnknownComponent.from_payload(data) # pyright: ignore[reportReturnType] + + +__all__ = ("_component_factory",) diff --git a/discord/components/action_row.py b/discord/components/action_row.py new file mode 100644 index 0000000000..8b73a2122a --- /dev/null +++ b/discord/components/action_row.py @@ -0,0 +1,119 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-2021 Rapptz +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import TYPE_CHECKING, ClassVar, Literal, TypeAlias, TypeVar, cast + +from typing_extensions import override + +from ..enums import ComponentType +from ..types.component_types import ActionRow as ActionRowPayload +from .component import Component, WalkableComponentMixin + +if TYPE_CHECKING: + from typing_extensions import Self + + from .button import Button + from .channel_select_menu import ChannelSelect + from .mentionable_select_menu import MentionableSelect + from .role_select_menu import RoleSelect + from .string_select_menu import StringSelect + from .text_input import TextInput + from .user_select_menu import UserSelect + +AllowedActionRowComponents: TypeAlias = ( + "Button | TextInput | StringSelect | UserSelect | RoleSelect | MentionableSelect | ChannelSelect" +) + + +class ActionRow(Component["ActionRowPayload"], WalkableComponentMixin["AllowedActionRowComponents"]): + """Represents a Discord Bot UI Kit Action Row. + + This is a component that holds up to 5 children components in a row. + + This inherits from :class:`Component`. + + .. versionadded:: 2.0 + .. versionchanged:: 3.0 + + Attributes + ---------- + type: Literal[:data:`ComponentType.action_row`] + The type of component. + components: list[:class:`AllowedActionRowComponents`] + The components that this ActionRow holds, if any. + id: :class:`int` | :class:`None` + The action row's ID. If not provided, it is set sequentially by Discord. + The ID `0` is treated as if no ID was provided. + + Parameters + ---------- + components: + The components that this ActionRow holds, if any. + This can be a sequence of up to 5 components. + Has to be passed unpacked (e.g. ``*components``). + id: + The action row's ID. If not provided, it is set sequentially by Discord. + The ID `0` is treated as if no ID was provided. + """ + + __slots__: tuple[str, ...] = ("components",) + + __repr_info__: ClassVar[tuple[str, ...]] = __slots__ + versions: tuple[int, ...] = (1, 2) + type: Literal[ComponentType.action_row] = ComponentType.action_row # pyright: ignore[reportIncompatibleVariableOverride] + + def __init__(self, *components: AllowedActionRowComponents, id: int | None = None) -> None: + self.components: list[AllowedActionRowComponents] = list(components) + super().__init__(id=id) + + @override + def walk_components(self) -> Iterator[AllowedActionRowComponents]: + yield from self.components + + @classmethod + @override + def from_payload(cls, payload: ActionRowPayload) -> Self: + from ._component_factory import _component_factory # noqa: PLC0415 # pyright: ignore[reportPrivateUsage] + + components: list[AllowedActionRowComponents] = cast( + "list[AllowedActionRowComponents]", [_component_factory(d) for d in payload.get("components", [])] + ) + return cls(*components, id=payload.get("id")) + + @property + def width(self): + """Return the sum of the components' widths.""" + return sum(getattr(c, "width", 0) for c in self.components) + + @override + def to_dict(self) -> ActionRowPayload: + return { # pyright: ignore[reportReturnType] + "type": int(self.type), + "id": self.id, + "components": [component.to_dict() for component in self.components], + } # type: ignore diff --git a/discord/components/button.py b/discord/components/button.py new file mode 100644 index 0000000000..d2bdd08ddb --- /dev/null +++ b/discord/components/button.py @@ -0,0 +1,252 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-2021 Rapptz +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar, Literal, TypeAlias, overload + +from typing_extensions import override + +from ..enums import ButtonStyle, ComponentType, try_enum +from ..partial_emoji import PartialEmoji, _EmojiTag # pyright: ignore[reportPrivateUsage] +from ..types.component_types import ButtonComponent as ButtonComponentPayload +from .component import Component + +if TYPE_CHECKING: + from typing_extensions import Self + + from ..emoji import AppEmoji, GuildEmoji + from ..partial_emoji import PartialEmoji + +AnyEmoji: TypeAlias = "GuildEmoji | AppEmoji | PartialEmoji" + + +class Button(Component[ButtonComponentPayload]): + """Represents a button from the Discord Bot UI Kit. + + This inherits from :class:`Component`. + + .. versionadded:: 2.0 + + Attributes + ---------- + type: Literal[:data:`ComponentType.button`] + The type of component. + style: :class:`ButtonStyle` + The style of the button. + custom_id: :class:`str` | :data:`None` + The ID of the button that gets received during an interaction. + If this button is for a URL, it does not have a custom ID. + url: :class:`str` | :data:`None` + The URL this button sends you to. + disabled: :class:`bool` + Whether the button is disabled or not. + label: :class:`str` | :data:`None` + The label of the button, if any. + emoji: :class:`PartialEmoji`] | :data:`None` + The emoji of the button, if available. + sku_id: :class:`int` | :data:`None` + The ID of the SKU this button refers to. + id: :class:`int` | :data:`None` + The button's ID. If not provided, it is set sequentially by Discord. + The ID `0` is treated as if no ID was provided. + + Parameters + ---------- + style: + The style of the button. + custom_id: + The ID of the button that gets received during an interaction. + Cannot be used with :class:`ButtonStyle.url` or :class:`ButtonStyle.premium`. + label: + The label of the button, if any. + Cannot be used with :class:`ButtonStyle.premium`. + emoji: + The emoji of the button, if available. + Cannot be used with :class:`ButtonStyle.premium`. + disabled: + Whether the button is disabled or not. + url: + The URL this button sends you to. + Can only be used with :class:`ButtonStyle.url`. + id: + The button's ID. If not provided, it is set sequentially by Discord. + The ID `0` is treated as if no ID was provided. + sku_id: + The ID of the SKU this button refers to. + Can only be used with :class:`ButtonStyle.premium`. + """ + + __slots__: tuple[str, ...] = ( + "style", + "custom_id", + "url", + "disabled", + "label", + "emoji", + "sku_id", + ) + + __repr_info__: ClassVar[tuple[str, ...]] = __slots__ + versions: tuple[int, ...] = (1, 2) + type: Literal[ComponentType.button] = ComponentType.button # pyright: ignore[reportIncompatibleVariableOverride] + width: Literal[1] = 1 + + # Premium button + @overload + def __init__( + self, + style: Literal[ButtonStyle.premium], + *, + sku_id: int, + disabled: bool = False, + id: int | None = None, + ) -> None: ... + + # URL button with label + @overload + def __init__( + self, + style: Literal[ButtonStyle.url], + *, + label: str, + emoji: str | AnyEmoji | None = None, + disabled: bool = False, + url: str, + id: int | None = None, + ) -> None: ... + + # URL button with emoji + @overload + def __init__( + self, + style: Literal[ButtonStyle.url], + *, + emoji: str | AnyEmoji, + label: str | None = None, + disabled: bool = False, + url: str, + id: int | None = None, + ) -> None: ... + + # Interactive button with label + @overload + def __init__( + self, + style: Literal[ButtonStyle.primary, ButtonStyle.secondary, ButtonStyle.success, ButtonStyle.danger], + *, + custom_id: str, + label: str, + emoji: str | AnyEmoji | None = None, + disabled: bool = False, + id: int | None = None, + ) -> None: ... + + # Interactive button with emoji + @overload + def __init__( + self, + style: Literal[ButtonStyle.primary, ButtonStyle.secondary, ButtonStyle.success, ButtonStyle.danger], + *, + custom_id: str, + emoji: str | AnyEmoji, + label: str | None = None, + disabled: bool = False, + id: int | None = None, + ) -> None: ... + + def __init__( + self, + style: int | ButtonStyle, + custom_id: str | None = None, + label: str | None = None, + emoji: str | AnyEmoji | None = None, + disabled: bool = False, + url: str | None = None, + id: int | None = None, + sku_id: int | None = None, + ) -> None: + self.style: ButtonStyle = try_enum(ButtonStyle, style) + self.custom_id: str | None = custom_id + self.url: str | None = url + self.disabled: bool = disabled + self.label: str | None = label + self.emoji: PartialEmoji | None + if isinstance(emoji, _EmojiTag): + self.emoji = emoji._to_partial() # pyright: ignore[reportPrivateUsage] + elif isinstance(emoji, str): + self.emoji = PartialEmoji.from_str(emoji) + else: + self.emoji = emoji + self.sku_id: int | None = sku_id + super().__init__(id=id) + + @classmethod + @override + def from_payload(cls, payload: ButtonComponentPayload) -> Self: + style = try_enum(ButtonStyle, payload["style"]) + custom_id = payload.get("custom_id") + label = payload.get("label") + emoji = payload.get("emoji") + disabled = payload.get("disabled", False) + url = payload.get("url") + sku_id = payload.get("sku_id") + + if emoji is not None: + emoji = PartialEmoji.from_dict(emoji) + + return cls( # pyright: ignore[reportCallIssue] + style=style, + custom_id=custom_id, + label=label, + emoji=emoji, + disabled=disabled, + url=url, + id=payload.get("id"), + sku_id=int(sku_id) if sku_id is not None else None, + ) + + @override + def to_dict(self) -> ButtonComponentPayload: + payload: ButtonComponentPayload = { # pyright: ignore[reportAssignmentType] + "type": 2, + "id": self.id, + "style": int(self.style), + "label": self.label, + "disabled": self.disabled, + } + if self.custom_id: + payload["custom_id"] = self.custom_id + + if self.url: + payload["url"] = self.url + + if self.emoji: + payload["emoji"] = self.emoji.to_dict() # pyright: ignore[reportGeneralTypeIssues] + + if self.sku_id: + payload["sku_id"] = self.sku_id + + return payload # type: ignore diff --git a/discord/components/channel_select_menu.py b/discord/components/channel_select_menu.py new file mode 100644 index 0000000000..adcb1242d8 --- /dev/null +++ b/discord/components/channel_select_menu.py @@ -0,0 +1,160 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING, Literal + +from typing_extensions import override + +from ..enums import ComponentType +from ..types.component_types import ChannelSelect as ChannelSelectPayload +from .default_select_option import DefaultSelectOption +from .select_menu import Select + +if TYPE_CHECKING: + from typing_extensions import Self + + +class ChannelSelect(Select[ChannelSelectPayload]): + """Represents a channel select menu from the Discord Bot UI Kit. + + This inherits from :class:`SelectMenu`. + + .. versionadded:: 3.0 + + Attributes + ---------- + type: Literal[:data:`ComponentType.channel_select`] + The type of component. + default_values: List[:class:`DefaultSelectOption`] + The default selected values of the select menu. + custom_id: :class:`str` + The custom ID of the select menu that gets received during an interaction. + placeholder: :class:`str` | :data:`None` + The placeholder text that is shown if nothing is selected, if any. + min_values: :class:`int` + The minimum number of values that must be selected. + Defaults to 1. + max_values: :class:`int` + The maximum number of values that can be selected. + Defaults to 1. + disabled: :class:`bool` + Whether the select menu is disabled or not. + Defaults to ``False``. + id: :class:`int` | :data:`None` + The channel select menu's ID. + required: :class:`bool` + Whether the channel select is required or not. + Only applicable when used in a :class:`discord.Modal`. + + Parameters + ---------- + default_values: + The default selected values of the select menu. + custom_id: + The custom ID of the select menu that gets received during an interaction. + placeholder: + The placeholder text that is shown if nothing is selected, if any. + min_values: + The minimum number of values that must be selected. + Defaults to 1. + max_values: + The maximum number of values that can be selected. + Defaults to 1. + disabled: + Whether the select menu is disabled or not. Defaults to ``False``. + id: + The select menu's ID. If not provided, it is set sequentially by Discord. + The ID `0` is treated as if no ID was provided. + required: + Whether the channel select is required or not. Defaults to `True`. + Only applicable when used in a :class:`discord.Modal`. + """ + + __slots__: tuple[str, ...] = ("default_values",) + type: Literal[ComponentType.channel_select] = ComponentType.channel_select # pyright: ignore[reportIncompatibleVariableOverride] + + def __init__( + self, + *, + default_values: Sequence[DefaultSelectOption[Literal["channel"]]] | None = None, + custom_id: str, + placeholder: str | None = None, + min_values: int = 1, + max_values: int = 1, + disabled: bool = False, + id: int | None = None, + required: bool = True, + ): + super().__init__( + custom_id=custom_id, + placeholder=placeholder, + min_values=min_values, + max_values=max_values, + disabled=disabled, + id=id, + required=required, + ) + self.default_values: list[DefaultSelectOption[Literal["channel"]]] = ( + list(default_values) if default_values is not None else [] + ) + + @classmethod + @override + def from_payload(cls, payload: ChannelSelectPayload) -> Self: + default_values: list[DefaultSelectOption[Literal["channel"]]] = [ + DefaultSelectOption.from_payload(value) for value in payload.get("default_values", []) + ] + return cls( + custom_id=payload["custom_id"], + placeholder=payload.get("placeholder"), + min_values=payload.get("min_values", 1), + max_values=payload.get("max_values", 1), + disabled=payload.get("disabled", False), + id=payload.get("id"), + default_values=default_values, + ) + + @override + def to_dict(self, modal: bool = False) -> ChannelSelectPayload: + payload: ChannelSelectPayload = { # pyright: ignore[reportAssignmentType] + "type": int(self.type), + "id": self.id, + "custom_id": self.custom_id, + "min_values": self.min_values, + "max_values": self.max_values, + "required": self.required, + } + if self.placeholder: + payload["placeholder"] = self.placeholder + + if self.disabled and not modal: + payload["disabled"] = self.disabled + + if self.default_values: + payload["default_values"] = [value.to_dict() for value in self.default_values] + + return payload diff --git a/discord/components/component.py b/discord/components/component.py new file mode 100644 index 0000000000..9f708b0e9e --- /dev/null +++ b/discord/components/component.py @@ -0,0 +1,181 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-2021 Rapptz +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Iterator +from typing import TYPE_CHECKING, Callable, ClassVar, Generic, TypeVar + +from typing_extensions import override + +from ..enums import ComponentType + +if TYPE_CHECKING: + from typing_extensions import Self + + from ..state import ConnectionState + from ..types.component_types import Component as ComponentPayload + from .type_aliases import AnyComponent + +P = TypeVar("P", bound="ComponentPayload") + + +class Component(ABC, Generic[P]): + """Represents a Discord Bot UI Kit Component. + + This class is abstract and cannot be instantiated. + + .. versionadded:: 2.0 + .. versionchanged:: 3.0 + + Attributes + ---------- + type: :class:`ComponentType` + The type of component. + id: :class:`int` + The component's ID. + + Parameters + ---------- + id: + 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", "id") # pyright: ignore[reportIncompatibleUnannotatedOverride] + + __repr_info__: ClassVar[tuple[str, ...]] + type: ComponentType + versions: tuple[int, ...] + id: int | None + + def __init__(self, id: int | None = None) -> None: + self.id = id + + @override + def __repr__(self) -> str: + attrs = " ".join(f"{key}={getattr(self, key)!r}" for key in self.__repr_info__) + return f"<{self.__class__.__name__} {attrs}>" + + @abstractmethod + def to_dict(self) -> P: ... + + @classmethod + @abstractmethod + def from_payload(cls, payload: P) -> Self: ... # pyright: ignore[reportGeneralTypeIssues] + + def is_v2(self) -> bool: + """Whether this component was introduced in Components V2.""" + return bool(self.versions and 1 not in self.versions) + + def any_is_v2(self) -> bool: + """Whether this component or any of its children were introduced in Components V2.""" + return self.is_v2() + + def is_dispatchable(self) -> bool: + """Wether this component can be interacted with and lead to a :class:`Interaction`""" + return False + + def any_is_dispatchable(self) -> bool: + """Whether this component or any of its children can be interacted with and lead to a :class:`Interaction`""" + return self.is_dispatchable() + + +class StateComponentMixin(Component[P], ABC): + @classmethod + @abstractmethod + @override + def from_payload(cls, payload: P, state: ConnectionState | None = None) -> Self: # pyright: ignore[reportGeneralTypeIssues] + ... + + +C = TypeVar("C", bound="AnyComponent") + + +class WalkableComponentMixin(ABC, Generic[C]): + """A component that can be walked through. + + This is an abstract class and cannot be instantiated directly. + It is used to represent components that can be walked through, such as :class:`ActionRow`, :class:`Container` and :class:`Section`. + """ + + @abstractmethod + def walk_components(self) -> Iterator[C]: ... + + if TYPE_CHECKING: + __iter__: Iterator[C] + else: + + def __iter__(self) -> Iterator[C]: + yield from self.walk_components() + + @abstractmethod + def is_v2(self) -> bool: ... + + @abstractmethod + def is_dispatchable(self) -> bool: ... + + def any_is_v2(self) -> bool: + """Whether this component or any of its children were introduced in Components V2.""" + return self.is_v2() or any(c.any_is_v2() for c in self.walk_components()) + + def any_is_dispatchable(self) -> bool: + """Whether this component or any of its children can be interacted with and lead to a :class:`Interaction`""" + return self.is_dispatchable() or any(c.any_is_dispatchable() for c in self.walk_components()) + + def get_by_id(self, component_id: str | int) -> C | None: + """Gets a component by its ID or custom ID. + + Parameters + ---------- + component_id: + The ID (int) or custom ID (str) of the component to get. + + Returns + ------- + :class:`AllowedComponents` | :class:`None` + The children component with the given ID or custom ID, or :data:`None` if not found. + """ + for component in self.walk_components(): + if isinstance(component_id, str) and getattr(component, "custom_id", None) == component_id: + return component + elif isinstance(component_id, int) and getattr(component, "id", None) == component_id: + return component + + return None + + +class ModalComponentMixin(ABC, Generic[P]): + """A component that can be used in a modal. + + This is an abstract class and cannot be instantiated directly. + It is used to represent components that can be used in a modal. + + This does NOT mean that the component cannot be used elsewhere. + """ + + @abstractmethod + def to_dict(self, modal: bool = False) -> P: ... diff --git a/discord/components/components_holder.py b/discord/components/components_holder.py new file mode 100644 index 0000000000..ff7ac60f44 --- /dev/null +++ b/discord/components/components_holder.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from typing import Generic, cast + +from typing_extensions import TypeVarTuple, Unpack, override + +from .component import Component, WalkableComponentMixin +from .partial_components import PartialComponent, PartialWalkableComponentMixin +from .type_aliases import AnyComponent, AnyPartialComponent + +Ts = TypeVarTuple( + "Ts", default=Unpack[tuple[AnyComponent | AnyPartialComponent]] +) # Unforntunately, we cannot use `TypeVarTuple` with upper bounds yet. + + +class ComponentsHolder(tuple[Unpack[Ts]], Generic[Unpack[Ts]]): + """A sequence of components that can be used in Discord Bot UI Kit. + + This holder that is used to represent a collection of components, notably in a message. + + .. versionadded:: 3.0 + """ + + __slots__: tuple[str, ...] = () + + def __new__(cls, *components: Unpack[Ts]) -> ComponentsHolder[Unpack[Ts]]: + return super().__new__(cls, components) + + def get_by_id(self, component_id: str | int) -> AnyComponent | AnyPartialComponent | None: + """Get a component by its custom ID.""" + for maybe_component in self: + if not isinstance(maybe_component, (Component, PartialComponent)): + raise TypeError(f"Expected {Component} or {PartialComponent} but got {maybe_component}") + component = cast(AnyComponent | AnyPartialComponent, maybe_component) + if isinstance(component_id, str) and getattr(component, "custom_id", None) == component_id: + return component + elif isinstance(component_id, int) and getattr(component, "id", None) == component_id: + return component + + if isinstance(component, (WalkableComponentMixin, PartialWalkableComponentMixin)): + if found := component.get_by_id(component_id): + return found + return None + + @override + def __repr__(self) -> str: + return f"" diff --git a/discord/components/container.py b/discord/components/container.py new file mode 100644 index 0000000000..06abfa7cae --- /dev/null +++ b/discord/components/container.py @@ -0,0 +1,149 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar, Iterator, Literal, TypeAlias, cast + +from typing_extensions import override + +from ..colour import Colour +from ..enums import ComponentType +from ..types.component_types import ContainerComponent as ContainerComponentPayload +from .component import Component, WalkableComponentMixin + +if TYPE_CHECKING: + from typing_extensions import Self + + from ..state import ConnectionState + from .action_row import ActionRow + from .file_component import FileComponent + from .media_gallery import MediaGallery + from .section import Section + from .separator import Separator + from .text_display import TextDisplay + + +AllowedContainerComponents: TypeAlias = "ActionRow | TextDisplay | Section | MediaGallery | Separator | FileComponent" + + +class Container(Component["ContainerComponentPayload"], WalkableComponentMixin["AllowedContainerComponents"]): + """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`. + + .. versionadded:: 2.7 + .. versionchanged:: 3.0 + + Attributes + ---------- + type: Literal[:data:`ComponentType.container`] + The type of component. + components: List[:class:`AllowedContainerComponents`] + The components contained in this container. + accent_color: :class:`Colour` | :data:`None` + The accent color of the container. + spoiler: :class:`bool` | :data:`None` + Whether the entire container has a spoiler overlay. + id: :class:`int` | :data:`None` + The container's ID. + + Parameters + ---------- + components: + The components to include in this container. Has to be passed unpacked (e.g. ``*components``). + accent_color: + The accent color of the container. If not provided, it defaults to :data:`None`. + spoiler: + Whether the entire container has the spoiler overlay. If not provided, it defaults to :data:`False`. + id: + The container's ID. If not provided, it is set sequentially by Discord. + The ID `0` is treated as if no ID was provided. + """ + + __slots__: tuple[str, ...] = ( + "accent_color", + "spoiler", + "components", + ) + + __repr_info__: ClassVar[tuple[str, ...]] = __slots__ + versions: tuple[int, ...] = (2,) + type: Literal[ComponentType.container] = ComponentType.container # pyright: ignore[reportIncompatibleVariableOverride] + + def __init__( + self, + *components: AllowedContainerComponents, + accent_color: Colour | None = None, + spoiler: bool | None = False, + id: int | None = None, + ) -> None: + self.accent_color: Colour | None = accent_color + self.spoiler: bool | None = spoiler + self.components: list[AllowedContainerComponents] = list(components) + super().__init__(id=id) + + @override + def walk_components(self) -> Iterator[AllowedContainerComponents]: + yield from self.components + + @override + def to_dict(self) -> ContainerComponentPayload: + payload: ContainerComponentPayload = { + "type": int(self.type), # pyright: ignore[reportAssignmentType] + "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 + + @classmethod + @override + def from_payload(cls, payload: ContainerComponentPayload, state: ConnectionState | None = None) -> Self: + from ._component_factory import _component_factory # noqa: PLC0415 # pyright: ignore[reportPrivateUsage] + + components: list[AllowedContainerComponents] = cast( + "list[AllowedContainerComponents]", + [_component_factory(d, state=state) for d in payload.get("components", [])], + ) + accent_color = Colour(c) if (c := payload.get("accent_color") is not None) else None + return cls( + *components, + accent_color=accent_color, + spoiler=payload.get("spoiler"), + id=payload.get("id"), + ) diff --git a/discord/components/default_select_option.py b/discord/components/default_select_option.py new file mode 100644 index 0000000000..e19b887310 --- /dev/null +++ b/discord/components/default_select_option.py @@ -0,0 +1,86 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from typing import Generic, Literal, TypeVar + +from typing_extensions import override + +from ..types.component_types import SelectDefaultValue + +DT = TypeVar("DT", bound='Literal["user", "role", "channel"]') + + +class DefaultSelectOption(Generic[DT]): + """ + Represents a default select menu option. + Can only be used :class:`UserSelect`, :class:`RoleSelect`, and :class:`MentionableSelect`. + + .. versionadded:: 3.0 + + Attributes + ---------- + id: :class:`int` + The ID of the default option. + type: :class:`str` + The type of the default option. This can be either "user", "role", or "channel". + This is used to determine which type of select menu this option belongs to. + + Parameters + ---------- + id: + The ID of the default option. + type: + The type of the default option. This can be either "user", "role", or "channel". + """ + + __slots__: tuple[str, ...] = ("id", "type") + + def __init__( + self, + id: int, + type: DT, + ) -> None: + self.id: int = id + self.type: DT = type + + @override + def __repr__(self) -> str: + return f"" + + @classmethod + def from_payload(cls, payload: SelectDefaultValue[DT]) -> DefaultSelectOption[DT]: + """Creates a DefaultSelectOption from a dictionary.""" + return cls( + id=payload["id"], + type=payload["type"], + ) + + def to_dict(self) -> SelectDefaultValue[DT]: + """Converts the DefaultSelectOption to a dictionary.""" + return { + "id": self.id, + "type": self.type, + } diff --git a/discord/components/file_component.py b/discord/components/file_component.py new file mode 100644 index 0000000000..ad75ee4d5b --- /dev/null +++ b/discord/components/file_component.py @@ -0,0 +1,127 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar, Literal + +from typing_extensions import override + +from ..enums import ComponentType +from ..types.component_types import FileComponent as FileComponentPayload +from .component import Component, StateComponentMixin +from .unfurled_media_item import UnfurledMediaItem + +if TYPE_CHECKING: + from typing_extensions import Self + + from ..state import ConnectionState + + +class FileComponent(StateComponentMixin[FileComponentPayload], Component[FileComponentPayload]): + """Represents a File from Components V2. + + This component displays a downloadable file in a message. + + This inherits from :class:`Component`. + + .. versionadded:: 2.7 + .. versionchanged:: 3.0 + + Attributes + ---------- + type: Literal[:data:`ComponentType.file`] + The type of component. + 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: :class:`bool` | :data:`None` + Whether the file has the spoiler overlay. + + Parameters + ---------- + url: :class:`str` + The URL of this media gallery item. This HAS to be an ``attachment://`` URL to work with local files. + spoiler: + Whether the file has the spoiler overlay. Defaults to :data:`False`. + id: + 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. + size: + The file's size in bytes. If not provided, it is set to :data:`None`. + name: + The file's name. If not provided, it is set to :data:`None`. + """ + + __slots__: tuple[str, ...] = ( + "file", + "spoiler", + "name", + "size", + ) + + __repr_info__: ClassVar[tuple[str, ...]] = __slots__ + versions: tuple[int, ...] = (2,) + type: Literal[ComponentType.file] = ComponentType.file # pyright: ignore[reportIncompatibleVariableOverride] + + def __init__( + self, + url: str | UnfurledMediaItem, + *, + spoiler: bool | None = False, + id: int | None = None, + size: int | None = None, + name: str | None = None, + ) -> None: + self.file: UnfurledMediaItem = url if isinstance(url, UnfurledMediaItem) else UnfurledMediaItem(url) + self.spoiler: bool | None = bool(spoiler) if spoiler is not None else None + self.size: int | None = size + self.name: str | None = name + super().__init__(id=id) + + @classmethod + @override + def from_payload(cls, payload: FileComponentPayload, state: ConnectionState | None = None) -> Self: + file = UnfurledMediaItem.from_dict(payload.get("file", {}), state=state) + return cls( + file, spoiler=payload.get("spoiler"), id=payload.get("id"), size=payload["size"], name=payload["name"] + ) + + @override + 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 # type: ignore # pyright: ignore[reportReturnType] + + @property + def url(self) -> str: + return self.file.url + + @url.setter + def url(self, url: str) -> None: + self.file = UnfurledMediaItem(url) diff --git a/discord/components/file_upload.py b/discord/components/file_upload.py new file mode 100644 index 0000000000..671c563dd5 --- /dev/null +++ b/discord/components/file_upload.py @@ -0,0 +1,116 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar, Literal + +from typing_extensions import override + +from ..enums import ComponentType +from ..types.component_types import FileUpload as FileUploadPayload +from .component import Component, ModalComponentMixin + +if TYPE_CHECKING: + from typing_extensions import Self + + +class FileUpload(ModalComponentMixin[FileUploadPayload], Component[FileUploadPayload]): + """Represents a File Upload Component. + + This component displays a file upload box in a :class:`Modal`. + + This inherits from :class:`Component`. + + .. versionadded:: 3.0 + + Attributes + ---------- + type: Literal[:data:`ComponentType.file_upload`] + The type of component. + custom_id: :class:`str` + The custom ID of the file upload component that gets received during an interaction. + min_values: :class:`int` + The minimum number of files that must be uploaded. + max_values: :class:`int` + The maximum number of files that can be uploaded. + required: :class:`bool` + Whether the file upload is required to submit the modal. + id: :class:`int` | :data:`None` + The section's ID. + + Parameters + ---------- + id: + 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, ...] = ( + "file", + "spoiler", + "name", + "size", + ) + + __repr_info__: ClassVar[tuple[str, ...]] = __slots__ + versions: tuple[int, ...] = (2,) + type: Literal[ComponentType.file_upload] = ComponentType.file_upload # pyright: ignore[reportIncompatibleVariableOverride] + + def __init__( + self, + custom_id: str, + id: int | None = None, + min_values: int = 1, + max_values: int = 1, + required: bool = True, + ) -> None: + self.custom_id: str = custom_id + self.min_values: int = min_values + self.max_values: int = max_values + self.required: bool = required + super().__init__(id=id) + + @classmethod + @override + def from_payload(cls, payload: FileUploadPayload) -> Self: + return cls( + custom_id=payload["custom_id"], + id=payload["id"], + min_values=payload.get("min_values", 1), + max_values=payload.get("max_values", 1), + required=payload.get("required", True), + ) + + @override + def to_dict(self, modal: bool = True) -> FileUploadPayload: + payload: FileUploadPayload = { + "type": int(self.type), + "custom_id": self.custom_id, + "min_values": self.min_values, + "max_values": self.max_values, + "required": self.required, + "id": self.id, + } + return payload diff --git a/discord/components/label.py b/discord/components/label.py new file mode 100644 index 0000000000..6b7e9c1677 --- /dev/null +++ b/discord/components/label.py @@ -0,0 +1,141 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-2021 Rapptz +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import TYPE_CHECKING, ClassVar, Literal, TypeAlias, cast + +from typing_extensions import override + +from ..enums import ComponentType +from ..types.component_types import LabelComponent as LabelComponentPayload +from .component import Component, ModalComponentMixin, WalkableComponentMixin + +if TYPE_CHECKING: + from typing_extensions import Self + + from discord.state import ConnectionState + + from .channel_select_menu import ChannelSelect + from .file_upload import FileUpload + from .mentionable_select_menu import MentionableSelect + from .string_select_menu import StringSelect + from .text_input import TextInput + from .user_select_menu import UserSelect + + AllowedLabelComponents: TypeAlias = ( + StringSelect | UserSelect | TextInput | FileUpload | MentionableSelect | ChannelSelect + ) + + +class Label( + Component["LabelComponentPayload"], + WalkableComponentMixin["AllowedLabelComponents"], + ModalComponentMixin["LabelComponentPayload"], +): + """Represents a Label component. + + This is a component used exclusively within a :class:`Modal` to hold :class:`InputText` components. + + This inherits from :class:`Component`. + + .. versionadded:: 3.0 + + Attributes + ---------- + type: Literal[:data:`ComponentType.label`] + The type of component. + component: :class:`list` of :class:`Component` + The components contained in this label. + label: :class:`str` + The text of the label. + description: :class:`str` | :data:`None` + The description of the label. + id: :class:`int` | :data:`None` + The label's ID. + + Parameters + ---------- + component: + The component held by this label. Currently supports :class:`TextDisplay` and :class:`StringSelect`. + label: + The text of the label. + description: + The description of the label. This is optional. + id: + The label's ID. If not provided, it is set sequentially by Discord. + The ID `0` is treated as if no ID was provided. + """ + + __slots__: tuple[str, ...] = ("label", "description", "component") # pyright: ignore[reportIncompatibleUnannotatedOverride] + + __repr_info__: ClassVar[tuple[str, ...]] = __slots__ + versions: tuple[int, ...] = (2,) + type: Literal[ComponentType.label] = ComponentType.label # pyright: ignore[reportIncompatibleVariableOverride] + + def __init__( + self, + component: AllowedLabelComponents, + label: str, + description: str | None = None, + id: int | None = None, + ): + self.label: str = label + self.description: str | None = description + self.component: AllowedLabelComponents = component + super().__init__(id=id) + + @classmethod + @override + def from_payload(cls, payload: LabelComponentPayload, state: ConnectionState | None = None) -> Self: + from ._component_factory import _component_factory # noqa: PLC0415 # pyright: ignore[reportPrivateUsage] + + # self.id: int = data.get("id") + component: AllowedLabelComponents = cast( + "AllowedLabelComponents", _component_factory(payload["component"], state=state) + ) + return cls( + component=component, + label=payload["label"], + description=payload.get("description"), + id=payload.get("id"), + ) + + @override + def to_dict(self, modal: bool = True) -> LabelComponentPayload: + payload: LabelComponentPayload = { # pyright: ignore[reportAssignmentType] + "type": int(self.type), + "id": self.id, + "component": self.component.to_dict(modal=modal), + "label": self.label, + } + if self.description: + payload["description"] = self.description + return payload + + @override + def walk_components(self) -> Iterator[AllowedLabelComponents]: + yield self.component diff --git a/discord/components/media_gallery.py b/discord/components/media_gallery.py new file mode 100644 index 0000000000..59d32c083b --- /dev/null +++ b/discord/components/media_gallery.py @@ -0,0 +1,94 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING, ClassVar, Literal + +from typing_extensions import override + +from ..enums import ComponentType +from ..types.component_types import MediaGalleryComponent as MediaGalleryComponentPayload +from .component import Component, StateComponentMixin +from .media_gallery_item import MediaGalleryItem + +if TYPE_CHECKING: + from typing_extensions import Self + + from ..state import ConnectionState + + +class MediaGallery(StateComponentMixin[MediaGalleryComponentPayload], Component[MediaGalleryComponentPayload]): + """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`. + + .. versionadded:: 2.7 + .. versionchanged:: 3.0 + + Attributes + ---------- + type: Literal[:data:`ComponentType.media_gallery`] + The type of component. + items: List[:class:`MediaGalleryItem`] + The media this gallery contains. + id: :class:`int` | :data:`None` + The media gallery's ID. + + Parameters + ---------- + items: + The media gallery items this gallery contains. + Has to be passed unpacked (e.g. ``*items``). + id: + 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, ...] = ("items",) + + __repr_info__: ClassVar[tuple[str, ...]] = __slots__ + versions: tuple[int, ...] = (2,) + type: Literal[ComponentType.media_gallery] = ComponentType.media_gallery # pyright: ignore[reportIncompatibleVariableOverride] + + def __init__(self, *items: MediaGalleryItem, id: int | None = None): + self.items: list[MediaGalleryItem] = list(items) + super().__init__(id=id) + + @classmethod + @override + def from_payload(cls, payload: MediaGalleryComponentPayload, state: ConnectionState | None = None) -> Self: + items = [MediaGalleryItem.from_payload(d, state=state) for d in payload.get("items", [])] + return cls(*items, id=payload.get("id")) + + @override + def to_dict(self) -> MediaGalleryComponentPayload: + return { # pyright: ignore[reportReturnType] + "type": int(self.type), + "id": self.id, + "items": [i.to_dict() for i in self.items], + } diff --git a/discord/components/media_gallery_item.py b/discord/components/media_gallery_item.py new file mode 100644 index 0000000000..f0dfedc50e --- /dev/null +++ b/discord/components/media_gallery_item.py @@ -0,0 +1,94 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ..types.component_types import MediaGalleryItem as MediaGalleryItemPayload +from .unfurled_media_item import UnfurledMediaItem + +if TYPE_CHECKING: + from ..state import ConnectionState + + +class MediaGalleryItem: + """Represents an item used in the :class:`MediaGallery` component. + + .. versionadded:: 2.7 + .. versionchanged:: 3.0 + + Attributes + ---------- + media: :class:`UnfurledMediaItem` + The :class:`UnfurledMediaItem` associated with this media gallery item. + description: :class:`str` | :class:`None` + The gallery item's description, up to 1024 characters. + spoiler: :class:`bool` + Whether the gallery item is a spoiler. + + Parameters + ---------- + url: :class:`str` + The URL of this media gallery item. This can either be an arbitrary URL or an ``attachment://`` URL to work with local files. + description: + The description of this media gallery item, up to 1024 characters. Defaults to :data:`None`. + spoiler: + Whether this media gallery item has a spoiler overlay. Defaults to :data:`False`. + """ + + def __init__(self, url: str | UnfurledMediaItem, *, description: str | None = None, spoiler: bool = False): + self._state: ConnectionState | None = None + self.media: UnfurledMediaItem = UnfurledMediaItem(url) if isinstance(url, str) else 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_payload(cls, data: MediaGalleryItemPayload, state: ConnectionState | None = 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, + description=description, + spoiler=spoiler, + ) + r._state = state + return r + + def to_dict(self) -> MediaGalleryItemPayload: + payload: MediaGalleryItemPayload = {"media": self.media.to_dict()} + if self.description: + payload["description"] = self.description + payload["spoiler"] = self.spoiler + return payload diff --git a/discord/components/mentionable_select_menu.py b/discord/components/mentionable_select_menu.py new file mode 100644 index 0000000000..e025ddefcf --- /dev/null +++ b/discord/components/mentionable_select_menu.py @@ -0,0 +1,157 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING, Literal + +from typing_extensions import override + +from ..enums import ComponentType +from ..types.component_types import MentionableSelect as MentionableSelectPayload +from .default_select_option import DefaultSelectOption +from .select_menu import Select + +if TYPE_CHECKING: + from typing_extensions import Self + + +class MentionableSelect(Select[MentionableSelectPayload]): + """Represents a mentionable select menu from the Discord Bot UI Kit. + + This inherits from :class:`SelectMenu`. + + .. versionadded:: 3.0 + + Attributes + ---------- + type: Literal[:data:`ComponentType.mentionable_select`] + The type of component. + default_values: List[:class:`DefaultSelectOption`] + The default selected values of the select menu. + custom_id: :class:`str` + The custom ID of the select menu that gets received during an interaction. + placeholder: :class:`str` | :data:`None` + The placeholder text that is shown if nothing is selected, if any. + min_values: :class:`int` + The minimum number of values that must be selected. + max_values: :class:`int` + The maximum number of values that can be selected. + disabled: :class:`bool` + Whether the select menu is disabled or not. + id: :class:`int` | :data:`None` + The mentionable select menu's ID. + required: :class:`bool` + Whether the mentionable select is required or not. + Only applicable when used in a :class:`discord.Modal`. + + Parameters + ---------- + default_values: + The default selected values of the select menu. + custom_id: + The custom ID of the select menu that gets received during an interaction. + placeholder: + The placeholder text that is shown if nothing is selected, if any. + min_values: + The minimum number of values that must be selected. + Defaults to 1. + max_values: + The maximum number of values that can be selected. + Defaults to 1. + disabled: + Whether the select menu is disabled or not. Defaults to ``False``. + id: + The mentionable select menu's ID. If not provided, it is set sequentially by Discord. + The ID `0` is treated as if no ID was provided. + required: + Whether the mentionable select is required or not. Defaults to `True`. + Only applicable when used in a :class:`discord.Modal`. + """ + + __slots__: tuple[str, ...] = ("default_values",) + type: Literal[ComponentType.mentionable_select] = ComponentType.mentionable_select # pyright: ignore[reportIncompatibleVariableOverride] + + def __init__( + self, + *, + default_values: Sequence[DefaultSelectOption[Literal["role", "user"]]] | None = None, + custom_id: str, + placeholder: str | None = None, + min_values: int = 1, + max_values: int = 1, + disabled: bool = False, + id: int | None = None, + required: bool = True, + ): + super().__init__( + custom_id=custom_id, + placeholder=placeholder, + min_values=min_values, + max_values=max_values, + disabled=disabled, + id=id, + required=required, + ) + self.default_values: list[DefaultSelectOption[Literal["role", "user"]]] = ( + list(default_values) if default_values is not None else [] + ) + + @classmethod + @override + def from_payload(cls, payload: MentionableSelectPayload) -> Self: + default_values: list[DefaultSelectOption[Literal["role", "user"]]] = [ + DefaultSelectOption.from_payload(value) for value in payload.get("default_values", []) + ] + return cls( + custom_id=payload["custom_id"], + placeholder=payload.get("placeholder"), + min_values=payload.get("min_values", 1), + max_values=payload.get("max_values", 1), + disabled=payload.get("disabled", False), + id=payload.get("id"), + default_values=default_values, + ) + + @override + def to_dict(self, modal: bool = False) -> MentionableSelectPayload: + payload: MentionableSelectPayload = { # pyright: ignore[reportAssignmentType] + "type": int(self.type), + "id": self.id, + "custom_id": self.custom_id, + "min_values": self.min_values, + "max_values": self.max_values, + "required": self.required, + } + if self.placeholder: + payload["placeholder"] = self.placeholder + + if self.disabled and not modal: + payload["disabled"] = self.disabled + + if self.default_values: + payload["default_values"] = [value.to_dict() for value in self.default_values] + + return payload diff --git a/discord/components/modal.py b/discord/components/modal.py new file mode 100644 index 0000000000..9cb48f0790 --- /dev/null +++ b/discord/components/modal.py @@ -0,0 +1,79 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, TypeAlias + +from ..types.component_types import Modal as ModalPayload + +if TYPE_CHECKING: + from .label import Label + from .text_display import TextDisplay + +AllowedModalComponents: TypeAlias = "Label | TextDisplay" + + +class Modal: + """ + Represents a modal. Used when sending modals with :meth:`InteractionResponse.send_modal` + + ..versionadded:: 3.0 + + Attributes + ---------- + title: :class:`str` + The title of the modal. This is shown at the top of the modal. + custom_id: :class:`str` + The custom ID of the modal. This is received during an interaction. + components: List[:class:`Label` | :class:`TextDisplay`] + The components in the modal. + + Parameters + ---------- + components: + The components this modal holds. + Has to be passed unpacked (e.g. ``*components``). + title: + The title of the modal. This is shown at the top of the modal. + custom_id: + The custom ID of the modal. This is received during an interaction. + """ + + def __init__( + self, + *components: AllowedModalComponents, + title: str, + custom_id: str, + ) -> None: + self.title: str = title + self.custom_id: str = custom_id + self.components: list[AllowedModalComponents] = list(components) + + def to_dict(self) -> ModalPayload: + return { + "title": self.title, + "custom_id": self.custom_id, + "components": [component.to_dict(modal=True) for component in self.components], + } diff --git a/discord/components/partial_components.py b/discord/components/partial_components.py new file mode 100644 index 0000000000..277064d471 --- /dev/null +++ b/discord/components/partial_components.py @@ -0,0 +1,454 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Iterator +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Generic, Literal, TypeAlias, cast + +from typing_extensions import TypeVar, override + +from ..enums import ComponentType, try_enum +from ..types.partial_components import PartialButton as PartialButtonPayload +from ..types.partial_components import PartialChannelSelectMenu as PartialChannelSelectPayload +from ..types.partial_components import PartialComponent as PartialComponentPayload +from ..types.partial_components import PartialFileUpload as PartialFileUploadPayload +from ..types.partial_components import PartialLabel as PartialLabelPayload +from ..types.partial_components import PartialMentionableSelectMenu as PartialMentionableSelectPayload +from ..types.partial_components import PartialRoleSelectMenu as PartialRoleSelectPayload +from ..types.partial_components import PartialStringSelectMenu as PartialStringSelectPayload +from ..types.partial_components import PartialTextDisplay as PartialTextDisplayPayload +from ..types.partial_components import PartialTextInput as PartialTextInputPayload +from ..types.partial_components import PartialUserSelectMenu as PartialUserSelectPayload + +if TYPE_CHECKING: + from typing_extensions import Self + + from .type_aliases import AnyPartialComponent + +AllowedPartialLabelComponents: TypeAlias = "PartialStringSelect | PartialUserSelect | PartialChannelSelect | PartialRoleSelect | PartialMentionableSelect | PartialTextInput | PartialFileUpload" + +# Below, the usage of field with kw_only=True is used to push the attribute at the end of the __init__ signature and +# avoid issues with optional arguments order during class inheritance. +# Reference: https://stackoverflow.com/questions/51575931/class-inheritance-in-python-3-7-dataclasses + + +T = TypeVar("T", bound="ComponentType") +P = TypeVar("P", bound="PartialComponentPayload") + + +@dataclass +class PartialComponent(ABC, Generic[T, P]): + """Base class for all partial components returned by Discord during an :class:`Interaction` of type :data:`InteractionType.modal_submit`. + + .. versionadded:: 3.0 + """ + + id: int + type: T + + @classmethod + @abstractmethod + def from_payload(cls, payload: P) -> Self: ... + + +C = TypeVar("C", bound="AnyPartialComponent", covariant=True) + + +class PartialWalkableComponentMixin(ABC, Generic[C]): + @abstractmethod + def walk_components(self) -> Iterator[C]: ... + + if TYPE_CHECKING: + __iter__: Iterator[C] + else: + + def __iter__(self) -> Iterator[C]: + yield from self.walk_components() + + def get_by_id(self, component_id: str | int) -> C | None: + for component in self.walk_components(): + if isinstance(component_id, str) and getattr(component, "custom_id", None) == component_id: + return component + elif isinstance(component_id, int) and getattr(component, "id", None) == component_id: + return component + return None + + +V = TypeVar("V", bound="str | int") + + +@dataclass +class PartialButton(PartialComponent[Literal[ComponentType.button], PartialButtonPayload]): + """Represents a :class:`Button` component as returned by Discord during a :class:`Interaction` of type :data:`InteractionType.modal_submit`. + + .. versionadded:: 3.0 + + Attributes + ---------- + type: Literal[:data:`ComponentType.button`] + The type of component. + id: :class:`int` + The ID of this button component. + custom_id: :class:`str` | :class:`None` + The custom ID of this button component. This can be ``None`` for link buttons. + """ + + id: int + custom_id: str | None + type: Literal[ComponentType.button] = field(default=ComponentType.button, kw_only=True) + + @classmethod + @override + def from_payload(cls, payload: PartialButtonPayload) -> Self: + return cls(id=payload["id"], custom_id=payload.get("custom_id")) + + +@dataclass +class PartialSelect(PartialComponent[T, P], ABC, Generic[T, V, P]): + """Base class for all select menu interaction components returned by Discord during an :class:`Interaction` of type :data:`InteractionType.modal_submit`. + + .. versionadded:: 3.0 + """ + + id: int + custom_id: str + values: list[V] + type: T + + +@dataclass +class PartialStringSelect(PartialSelect[Literal[ComponentType.string_select], str, PartialStringSelectPayload]): + """Represents a :class:`StringSelect` component as returned by Discord during a :class:`Interaction` of type :data:`InteractionType.modal_submit`. + + .. versionadded:: 3.0 + + Attributes + ---------- + type: Literal[:data:`ComponentType.string_select`] + The type of component. + values: :class:`list` of :class:`str` + The values selected in the string select menu. + id: :class:`int` + The ID of this string select menu component. + custom_id: :class:`str` + The custom ID of this string select menu component. + """ + + type: Literal[ComponentType.string_select] = field(default=ComponentType.string_select, kw_only=True) + + @classmethod + @override + def from_payload(cls, payload: PartialStringSelectPayload) -> Self: + return cls( + id=payload["id"], + custom_id=payload["custom_id"], + values=payload["values"], + ) + + +P_int_select = TypeVar( + "P_int_select", + bound=PartialUserSelectPayload + | PartialRoleSelectPayload + | PartialChannelSelectPayload + | PartialMentionableSelectPayload, +) + + +@dataclass +class PartialSnowflakeSelect(PartialSelect[T, int, P_int_select], ABC, Generic[T, P_int_select]): + type: T + + @classmethod + @override + def from_payload(cls, payload: P_int_select) -> Self: + return cls( # pyright: ignore[reportCallIssue] + id=payload["id"], + custom_id=payload["custom_id"], + values=[int(value) for value in payload["values"]], + ) + + +@dataclass +class PartialUserSelect(PartialSnowflakeSelect[Literal[ComponentType.user_select], PartialUserSelectPayload]): + """Represents a :class:`UserSelect` component as returned by Discord during a :class:`Interaction` of type :data:`InteractionType.modal_submit`. + + .. versionadded:: 3.0 + + Attributes + ---------- + type: Literal[:data:`ComponentType.user_select`] + The type of component. + values: :class:`list` of :class:`int` + The user IDs selected in the user select menu. + id: :class:`int` + The ID of this user select menu component. + custom_id: :class:`str` + The custom ID of this user select menu component. + """ + + type: Literal[ComponentType.user_select] = field(default=ComponentType.user_select, kw_only=True) + + +@dataclass +class PartialRoleSelect(PartialSnowflakeSelect[Literal[ComponentType.role_select], PartialRoleSelectPayload]): + """Represents a :class:`RoleSelect` component as returned by Discord during a :class:`Interaction` of type :data:`InteractionType.modal_submit`. + + .. versionadded:: 3.0 + + Attributes + ---------- + type: Literal[:data:`ComponentType.role_select`] + The type of component. + values: :class:`list` of :class:`int` + The role IDs selected in the role select menu. + id: :class:`int` + The ID of this role select menu component. + custom_id: :class:`str` + The custom ID of this role select menu component. + """ + + type: Literal[ComponentType.role_select] = field(default=ComponentType.role_select, kw_only=True) + + +@dataclass +class PartialChannelSelect(PartialSnowflakeSelect[Literal[ComponentType.channel_select], PartialChannelSelectPayload]): + """Represents a :class:`ChannelSelect` component as returned by Discord during a :class:`Interaction` of type :data:`InteractionType.modal_submit`. + + .. versionadded:: 3.0 + + Attributes + ---------- + type: Literal[:data:`ComponentType.channel_select`] + The type of component. + values: :class:`list` of :class:`int` + The channel IDs selected in the channel select menu. + id: :class:`int` + The ID of this channel select menu component. + custom_id: :class:`str` + The custom ID of this channel select menu component. + """ + + type: Literal[ComponentType.channel_select] = field(default=ComponentType.channel_select, kw_only=True) + + +@dataclass +class PartialMentionableSelect( + PartialSnowflakeSelect[Literal[ComponentType.mentionable_select], PartialMentionableSelectPayload] +): + """Represents a :class:`MentionableSelect` component as returned by Discord during a :class:`Interaction` of type :data:`InteractionType.modal_submit`. + + .. versionadded:: 3.0 + + Attributes + ---------- + type: Literal[:data:`ComponentType.mentionable_select`] + The type of component. + values: :class:`list` of :class:`int` + The IDs selected in the mentionable select menu. + id: :class:`int` + The ID of this mentionable select menu component. + custom_id: :class:`str` + The custom ID of this mentionable select menu component. + """ + + type: Literal[ComponentType.mentionable_select] = field(default=ComponentType.mentionable_select, kw_only=True) + + +@dataclass +class PartialTextInput(PartialComponent[Literal[ComponentType.text_input], PartialTextInputPayload]): + """Represents a :class:`TextInput` component as returned by Discord during a :class:`Interaction` of type :data:`InteractionType.modal_submit`. + + .. versionadded:: 3.0 + + Attributes + ---------- + type: Literal[:data:`ComponentType.text_input`] + The type of component. + value: :class:`str` + The value of the text input. + id: :class:`int` + The ID of this text input component. + custom_id: :class:`str` + The custom ID of this text input component. + """ + + id: int + custom_id: str + value: str + type: Literal[ComponentType.text_input] = field(default=ComponentType.text_input, kw_only=True) + + @classmethod + @override + def from_payload(cls, payload: PartialTextInputPayload) -> Self: + return cls(id=payload["id"], custom_id=payload["custom_id"], value=payload["value"]) + + +L_c = TypeVar("L_c", bound=AllowedPartialLabelComponents, default=AllowedPartialLabelComponents) + + +@dataclass +class PartialLabel( + PartialComponent[Literal[ComponentType.label], PartialLabelPayload], + PartialWalkableComponentMixin[AllowedPartialLabelComponents], + Generic[L_c], +): + """Represents a :class:`Label` component as returned by Discord during a :class:`Interaction` of type :data:`InteractionType.modal_submit`. + + This is a component used exclusively within a :class:`Modal` to hold other components. + + .. versionadded:: 3.0 + + Attributes + ---------- + type: Literal[:data:`ComponentType.label`] + The type of component. + component: :class:`PartialTextInput` | :class:`PartialStringSelect` + The component contained in this label. + id: :class:`int` + The ID of this label component. + """ + + id: int + component: L_c + type: Literal[ComponentType.label] = field(default=ComponentType.label, kw_only=True) + + @classmethod + @override + def from_payload(cls, payload: PartialLabelPayload) -> Self: + return cls( + id=payload["id"], + component=cast("AllowedPartialLabelComponents", _partial_component_factory(payload["component"])), + ) + + @override + def walk_components(self) -> Iterator[AllowedPartialLabelComponents]: + yield self.component + if isinstance(self.component, PartialWalkableComponentMixin): + yield from self.component.walk_components() # pyright: ignore[reportReturnType] + + +@dataclass +class PartialTextDisplay(PartialComponent[Literal[ComponentType.text_display], PartialTextDisplayPayload]): + """Represents a :class:`TextDisplay` component as returned by Discord during a :class:`Interaction` of type :data:`InteractionType.modal_submit`. + + .. versionadded:: 3.0 + + Attributes + ---------- + type: Literal[:data:`ComponentType.text_display`] + The type of component. + id: :class:`int` + The ID of this text display component. + """ + + id: int + type: Literal[ComponentType.text_display] = field(default=ComponentType.text_display, kw_only=True) + + @classmethod + @override + def from_payload(cls, payload: PartialTextDisplayPayload) -> Self: + return cls(id=payload["id"]) + + +@dataclass +class PartialFileUpload(PartialComponent[Literal[ComponentType.file_upload], PartialFileUploadPayload]): + """Represents a :class:`TextDisplay` component as returned by Discord during a :class:`Interaction` of type :data:`InteractionType.modal_submit`. + + .. versionadded:: 3.0 + + Attributes + ---------- + type: Literal[:data:`ComponentType.file_upload`] + The type of component. + id: :class:`int` + The ID of this file upload component. + custom_id: :class:`str` + The custom ID of this file upload component. + values: :class:`list` of :class:`int` + The attachment IDs uploaded in the file upload component. + """ + + id: int + custom_id: str + values: list[int] + type: Literal[ComponentType.file_upload] = field(default=ComponentType.file_upload, kw_only=True) + + @classmethod + @override + def from_payload(cls, payload: PartialFileUploadPayload) -> Self: + return cls(id=payload["id"], custom_id=payload["custom_id"], values=[int(value) for value in payload["values"]]) + + +@dataclass +class UnknownPartialComponent(PartialComponent[ComponentType, PartialComponentPayload]): + """A class representing an unknown interaction component. + + This class is used when an interaction component with an unrecognized type is encountered. + + Attributes + ---------- + type: :class:`int` + The type of the unknown component. + id: :class:`int` + The ID of the unknown component. + payload: dict[str, Any] + The original raw payload of the unknown component. + """ + + type: ComponentType + id: int + payload: dict[str, Any] # pyright: ignore[reportExplicitAny] + + @classmethod + @override + def from_payload(cls, payload: PartialComponentPayload) -> Self: + return cls( + id=payload["id"], + type=try_enum(ComponentType, payload["type"]), + payload=payload, # pyright: ignore[reportArgumentType] + ) + + +COMPONENT_MAPPINGS = { + 2: PartialButton, + 3: PartialStringSelect, + 4: PartialTextInput, + 5: PartialUserSelect, + 6: PartialRoleSelect, + 7: PartialMentionableSelect, + 8: PartialChannelSelect, + 10: PartialTextDisplay, + 18: PartialLabel, + 19: PartialFileUpload, +} + + +def _partial_component_factory(payload: PartialComponentPayload, key: str = "type") -> AnyPartialComponent: + component_type: int = cast("int", payload[key]) + component_class = COMPONENT_MAPPINGS.get(component_type, UnknownPartialComponent) + return component_class.from_payload(payload) # pyright: ignore[reportArgumentType] diff --git a/discord/components/role_select_menu.py b/discord/components/role_select_menu.py new file mode 100644 index 0000000000..e0e56ef926 --- /dev/null +++ b/discord/components/role_select_menu.py @@ -0,0 +1,160 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING, Literal + +from typing_extensions import override + +from ..enums import ComponentType +from ..types.component_types import RoleSelect as RoleSelectPayload +from .default_select_option import DefaultSelectOption +from .select_menu import Select + +if TYPE_CHECKING: + from typing_extensions import Self + + +class RoleSelect(Select[RoleSelectPayload]): + """Represents a role select menu from the Discord Bot UI Kit. + + This inherits from :class:`SelectMenu`. + + .. versionadded:: 3.0 + + Attributes + ---------- + type: Literal[:data:`ComponentType.role_select`] + The type of component. + default_values: List[:class:`DefaultSelectOption`] + The default selected values of the select menu. + custom_id: :class:`str` + The custom ID of the select menu that gets received during an interaction. + placeholder: Optional[:class:`str`] + The placeholder text that is shown if nothing is selected, if any. + min_values: :class:`int` + The minimum number of values that must be selected. + Defaults to 1. + max_values: :class:`int` + The maximum number of values that can be selected. + Defaults to 1. + disabled: :class:`bool` + Whether the select menu is disabled or not. + Defaults to ``False``. + id: :class:`int` | :data:`None` + The role select menu's ID. + required: :class:`bool` + Whether the role select is required or not. + Only applicable when used in a :class:`discord.Modal`. + + Parameters + ---------- + default_values: + The default selected values of the select menu. + custom_id: + The custom ID of the select menu that gets received during an interaction. + placeholder: + The placeholder text that is shown if nothing is selected, if any. + min_values: + The minimum number of values that must be selected. + Defaults to 1. + max_values: + The maximum number of values that can be selected. + Defaults to 1. + disabled: + Whether the select menu is disabled or not. Defaults to ``False``. + id: + The select menu's ID. If not provided, it is set sequentially by Discord. + The ID `0` is treated as if no ID was provided. + required: + Whether the role select is required or not. Defaults to `True`. + Only applicable when used in a :class:`discord.Modal`. + """ + + __slots__: tuple[str, ...] = ("default_values",) + type: Literal[ComponentType.role_select] = ComponentType.role_select # pyright: ignore[reportIncompatibleVariableOverride] + + def __init__( + self, + *, + default_values: Sequence[DefaultSelectOption[Literal["role"]]] | None = None, + custom_id: str, + placeholder: str | None = None, + min_values: int = 1, + max_values: int = 1, + disabled: bool = False, + id: int | None = None, + required: bool = True, + ): + super().__init__( + custom_id=custom_id, + placeholder=placeholder, + min_values=min_values, + max_values=max_values, + disabled=disabled, + id=id, + required=required, + ) + self.default_values: list[DefaultSelectOption[Literal["role"]]] = ( + list(default_values) if default_values is not None else [] + ) + + @classmethod + @override + def from_payload(cls, payload: RoleSelectPayload) -> Self: + default_values: list[DefaultSelectOption[Literal["role"]]] = [ + DefaultSelectOption.from_payload(value) for value in payload.get("default_values", []) + ] + return cls( + custom_id=payload["custom_id"], + placeholder=payload.get("placeholder"), + min_values=payload.get("min_values", 1), + max_values=payload.get("max_values", 1), + disabled=payload.get("disabled", False), + id=payload.get("id"), + default_values=default_values, + ) + + @override + def to_dict(self, modal: bool = False) -> RoleSelectPayload: + payload: RoleSelectPayload = { # pyright: ignore[reportAssignmentType] + "type": int(self.type), + "id": self.id, + "custom_id": self.custom_id, + "min_values": self.min_values, + "max_values": self.max_values, + "required": self.required, + } + if self.placeholder: + payload["placeholder"] = self.placeholder + + if self.disabled and not modal: + payload["disabled"] = self.disabled + + if self.default_values: + payload["default_values"] = [value.to_dict() for value in self.default_values] + + return payload diff --git a/discord/components/section.py b/discord/components/section.py new file mode 100644 index 0000000000..8f3935cebf --- /dev/null +++ b/discord/components/section.py @@ -0,0 +1,134 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-2021 Rapptz +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import TYPE_CHECKING, ClassVar, Literal, TypeAlias, cast + +from typing_extensions import override + +from ..enums import ComponentType +from ..types.component_types import SectionComponent as SectionComponentPayload +from .component import Component, WalkableComponentMixin + +if TYPE_CHECKING: + from typing_extensions import Self + + from discord.state import ConnectionState + + from .button import Button + from .text_display import TextDisplay + from .thumbnail import Thumbnail + + AllowedSectionComponents: TypeAlias = TextDisplay + AllowedSectionAccessoryComponents: TypeAlias = Button | Thumbnail + + +class Section( + Component["SectionComponentPayload"], + WalkableComponentMixin["AllowedSectionComponents | AllowedSectionAccessoryComponents"], +): + """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`. + + .. versionadded:: 2.7 + + Attributes + ---------- + type: Literal[:data:`ComponentType.section`] + The type of component. + components: List[:class:`Component`] + The components contained in this section. Currently supports :class:`TextDisplay`. + accessory: :class:`Component` | :data:`None` + The accessory attached to this Section. Currently supports :class:`Button` and :class:`Thumbnail`. + id: :class:`int` | :data:`None` + The section's ID. + + Parameters + ---------- + components: + The components contained in this section. Currently supports :class:`TextDisplay`. + accessory: + The accessory attached to this Section. Currently supports :class:`Button` and :class:`Thumbnail`. + id: + The section's ID. If not provided, it is set sequentially by Discord. + The ID `0` is treated as if no ID was provided. + """ + + __slots__: tuple[str, ...] = ("components", "accessory") + + __repr_info__: ClassVar[tuple[str, ...]] = __slots__ + versions: tuple[int, ...] = (2,) + type: Literal[ComponentType.section] = ComponentType.section # pyright: ignore[reportIncompatibleVariableOverride] + + def __init__( + self, + components: Sequence[AllowedSectionComponents], + accessory: AllowedSectionAccessoryComponents | None = None, + id: int | None = None, + ): + self.components: list[AllowedSectionComponents] = list(components) # pyright: ignore[reportIncompatibleVariableOverride] + self.accessory: AllowedSectionAccessoryComponents | None = accessory + super().__init__(id=id) + + @classmethod + @override + def from_payload(cls, payload: SectionComponentPayload, state: ConnectionState | None = None) -> Self: + from ._component_factory import _component_factory # noqa: PLC0415 # pyright: ignore[reportPrivateUsage] + + # self.id: int = data.get("id") + components: list[AllowedSectionComponents] = cast( + "list[AllowedSectionComponents]", + [_component_factory(d, state=state) for d in payload.get("", [])], + ) + accessory: AllowedSectionAccessoryComponents | None = None + if _accessory := payload.get("accessory"): + accessory = cast("AllowedSectionAccessoryComponents", _component_factory(_accessory, state=state)) + return cls( + components=components, + accessory=accessory, + id=payload.get("id"), + ) + + @override + def to_dict(self) -> SectionComponentPayload: + payload: SectionComponentPayload = { # pyright: ignore[reportAssignmentType] + "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 + + @override + def walk_components(self) -> Iterator[AllowedSectionComponents | AllowedSectionAccessoryComponents]: + yield from self.components + if self.accessory: + yield self.accessory diff --git a/discord/components/select_menu.py b/discord/components/select_menu.py new file mode 100644 index 0000000000..1b033036d0 --- /dev/null +++ b/discord/components/select_menu.py @@ -0,0 +1,101 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from abc import ABC +from typing import ClassVar, Generic, Literal, TypeAlias, TypeVar + +from ..enums import ComponentType +from ..types.component_types import ( + ChannelSelect as ChannelSelectPayload, +) +from ..types.component_types import ( + MentionableSelect as MentionableSelectPayload, +) +from ..types.component_types import ( + RoleSelect as RoleSelectPayload, +) +from ..types.component_types import ( + StringSelect as StringSelectPayload, +) +from ..types.component_types import ( + UserSelect as UserSelectPayload, +) +from .component import Component, ModalComponentMixin + +SelectMenuTypes: TypeAlias = ( + StringSelectPayload | ChannelSelectPayload | RoleSelectPayload | MentionableSelectPayload | UserSelectPayload +) + +T = TypeVar("T", bound="SelectMenuTypes") + + +class Select(ModalComponentMixin[T], Component[T], ABC, Generic[T]): + """Represents a select menu from the Discord Bot UI Kit. + + This inherits from :class:`Component`. + + This is an abstract class and cannot be instantiated directly. + + .. versionadded:: 3.0 + """ + + __slots__: tuple[str, ...] = ( # pyright: ignore[reportIncompatibleUnannotatedOverride] + "custom_id", + "placeholder", + "min_values", + "max_values", + "disabled", + ) + + __repr_info__: ClassVar[tuple[str, ...]] = __slots__ + versions: tuple[int, ...] = (1, 2) + type: Literal[ # pyright: ignore[reportIncompatibleVariableOverride] + ComponentType.string_select, + ComponentType.channel_select, + ComponentType.role_select, + ComponentType.mentionable_select, + ComponentType.user_select, + ] + width: Literal[5] = 5 + + def __init__( + self, + custom_id: str, + *, + placeholder: str | None = None, + min_values: int = 1, + max_values: int = 1, + disabled: bool = False, + id: int | None = None, + required: bool = True, + ): + self.custom_id: str = custom_id + self.placeholder: str | None = placeholder + self.min_values: int = min_values + self.max_values: int = max_values + self.disabled: bool = disabled + self.required: bool = required + super().__init__(id=id) diff --git a/discord/components/select_option.py b/discord/components/select_option.py new file mode 100644 index 0000000000..8f720e16bb --- /dev/null +++ b/discord/components/select_option.py @@ -0,0 +1,174 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-2021 Rapptz +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, TypeAlias + +from typing_extensions import override + +from ..partial_emoji import PartialEmoji, _EmojiTag # pyright: ignore[reportPrivateUsage] +from ..types.component_types import SelectOption as SelectOptionPayload +from ..utils import MISSING, Undefined + +if TYPE_CHECKING: + from ..emoji import AppEmoji, GuildEmoji + from ..partial_emoji import PartialEmoji + +AnyEmoji: TypeAlias = "GuildEmoji | AppEmoji | PartialEmoji" + + +class SelectOption: + """Represents a :class:`discord.SelectMenu`'s option. + + These can be created by users. + + .. versionadded:: 2.0 + + Attributes + ---------- + label: :class:`str` + The label of the option. This is displayed to users. + value: :class:`str` + The value of the option. This is not displayed to users. + description: Optional[:class:`str`] + An additional description of the option, if any. + Can only be up to 100 characters. + default: :class:`bool` + Whether this option is selected by default. + emoji: :class:`str` | :class:`PartialEmoji` | :class:`GuildEmoji` | :class:`AppEmoji` | :data:`None` + The emoji of the option, if any. + + Parameters + ---------- + label: + The label of the option. This is displayed to users. + Can only be up to 100 characters. + value: + The value of the option. This is not displayed to users. + Can only be up to 100 characters. If not provided when constructed then it defaults to the + label. + description: + An additional description of the option, if any. + Can only be up to 100 characters. + emoji: + The emoji of the option, if any. + """ + + __slots__: tuple[str, ...] = ( + "label", + "value", + "description", + "_emoji", + "default", + ) + + def __init__( + self, + *, + label: str, + value: str | Undefined = MISSING, + description: str | None = None, + emoji: str | AnyEmoji | None = None, + default: bool = False, + ) -> None: + if len(label) > 100: + raise ValueError("label must be 100 characters or fewer") + + if value is not MISSING and len(value) > 100: + raise ValueError("value must be 100 characters or fewer") + + if description is not None and len(description) > 100: + raise ValueError("description must be 100 characters or fewer") + + self.label: str = label + self.value: str = label if value is MISSING else value + self.description: str | None = description + self.emoji = emoji + self.default: bool = default + + @override + def __repr__(self) -> str: + return ( + "" + ) + + @override + def __str__(self) -> str: + base = f"{self.emoji} {self.label}" if self.emoji else self.label + if self.description: + return f"{base}\n{self.description}" + return base + + @property + def emoji(self) -> PartialEmoji | None: + """The emoji of the option, if available.""" + return self._emoji + + @emoji.setter + def emoji(self, value: str | AnyEmoji | None) -> None: # pyright: ignore[reportPropertyTypeMismatch] + if value is not None: + if isinstance(value, str): + value = PartialEmoji.from_str(value) + elif isinstance(value, _EmojiTag): # pyright: ignore[reportUnnecessaryIsInstance] + value = value._to_partial() # pyright: ignore[reportPrivateUsage] + else: + raise TypeError( # pyright: ignore[reportUnreachable] + f"expected emoji to be None, str, GuildEmoji, AppEmoji, or PartialEmoji, not {value.__class__}" + ) + + self._emoji: PartialEmoji | None = value + + @classmethod + def from_dict(cls, data: SelectOptionPayload) -> SelectOption: + if e := data.get("emoji"): + emoji = PartialEmoji.from_dict(e) + else: + emoji = None + + return cls( + label=data["label"], + value=data["value"], + description=data.get("description"), + emoji=emoji, + default=data.get("default", False), + ) + + def to_dict(self) -> SelectOptionPayload: + payload: SelectOptionPayload = { + "label": self.label, + "value": self.value, + "default": self.default, + } + + if self.emoji: + payload["emoji"] = self.emoji.to_dict() # type: ignore # pyright: ignore[reportGeneralTypeIssues] + + if self.description: + payload["description"] = self.description + + return payload diff --git a/discord/components/separator.py b/discord/components/separator.py new file mode 100644 index 0000000000..dabb21f686 --- /dev/null +++ b/discord/components/separator.py @@ -0,0 +1,105 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar, Literal + +from typing_extensions import override + +from ..enums import ComponentType, SeparatorSpacingSize, try_enum +from ..types.component_types import SeparatorComponent as SeparatorComponentPayload +from .component import Component + +if TYPE_CHECKING: + from typing_extensions import Self + + +class Separator(Component[SeparatorComponentPayload]): + """Represents a Separator from Components V2. + + This is a component that visually separates components. + + This inherits from :class:`Component`. + + .. versionadded:: 2.7 + .. versionchanged:: 3.0 + + Attributes + ---------- + type: Literal[:data:`ComponentType.separator`] + The type of component. + divider: :class:`bool` + Whether the separator will show a horizontal line in addition to vertical spacing. + spacing: :class:`SeparatorSpacingSize` | :data:`None` + The separator's spacing size. + id: :class:`int` | :data:`None` + The separator's ID. + + Parameters + ---------- + divider: + Whether the separator will show a horizontal line in addition to vertical spacing. + Defaults to :data:`True`. + spacing: + The separator's spacing size. + Defaults to :attr:`SeparatorSpacingSize.small`. + id: + The separator'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, ...] = ( + "divider", + "spacing", + ) + + __repr_info__: ClassVar[tuple[str, ...]] = __slots__ + versions: tuple[int, ...] = (2,) + type: Literal[ComponentType.separator] = ComponentType.separator # pyright: ignore[reportIncompatibleVariableOverride] + + def __init__( + self, divider: bool = True, spacing: SeparatorSpacingSize = SeparatorSpacingSize.small, id: int | None = None + ) -> None: + self.divider: bool = divider + self.spacing: SeparatorSpacingSize = spacing + super().__init__(id=id) + + @classmethod + @override + def from_payload(cls, payload: SeparatorComponentPayload) -> Self: + self = cls( + divider=payload.get("divider", False), spacing=try_enum(SeparatorSpacingSize, payload.get("spacing", 1)) + ) + self.id = payload.get("id") + return self + + @override + def to_dict(self) -> SeparatorComponentPayload: + return { # pyright: ignore[reportReturnType] + "type": int(self.type), + "id": self.id, + "divider": self.divider, + "spacing": int(self.spacing), + } # type: ignore diff --git a/discord/components/string_select_menu.py b/discord/components/string_select_menu.py new file mode 100644 index 0000000000..b0c7b309fa --- /dev/null +++ b/discord/components/string_select_menu.py @@ -0,0 +1,154 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING, Literal + +from typing_extensions import override + +from ..enums import ComponentType +from ..types.component_types import StringSelect as StringSelectPayload +from .select_menu import Select +from .select_option import SelectOption + +if TYPE_CHECKING: + from typing_extensions import Self + + +class StringSelect(Select[StringSelectPayload]): + """Represents a string select menu from the Discord Bot UI Kit. + + This inherits from :class:`SelectMenu`. + + .. versionadded:: 3.0 + + Attributes + ---------- + type: Literal[:data:`ComponentType.string_select`] + The type of component. + options: List[:class:`SelectOption`] + The options available in this select menu. + custom_id: :class:`str` + The custom ID of the select menu that gets received during an interaction. + placeholder: Optional[:class:`str`] + The placeholder text that is shown if nothing is selected, if any. + min_values: :class:`int` + The minimum number of values that must be selected. + Defaults to 1. + max_values: :class:`int` + The maximum number of values that can be selected. + Defaults to 1. + disabled: :class:`bool` + Whether the select menu is disabled or not. + Defaults to ``False``. + id: :class:`int` | :data:`None` + The string select menu's ID. + required: :class:`bool` + Whether the string select is required or not. + Only applicable when used in a :class:`discord.Modal`. + + Parameters + ---------- + custom_id: + The custom ID of the select menu that gets received during an interaction. + options: + The options available in this select menu. + placeholder: + The placeholder text that is shown if nothing is selected, if any. + min_values: + The minimum number of values that must be selected. + Defaults to 1. + max_values: + The maximum number of values that can be selected. + Defaults to 1. + disabled: + Whether the select menu is disabled or not. Defaults to ``False``. + id: + The string select menu's ID. If not provided, it is set sequentially by Discord. + The ID `0` is treated as if no ID was provided. + required: + Whether the string select is required or not. Defaults to `True`. + Only applicable when used in a :class:`discord.Modal`. + """ + + __slots__: tuple[str, ...] = ("options",) + type: Literal[ComponentType.string_select] = ComponentType.string_select # pyright: ignore[reportIncompatibleVariableOverride] + + def __init__( + self, + custom_id: str, + options: Sequence[SelectOption], + *, + placeholder: str | None = None, + min_values: int = 1, + max_values: int = 1, + disabled: bool = False, + id: int | None = None, + required: bool = True, + ): + super().__init__( + custom_id=custom_id, + placeholder=placeholder, + min_values=min_values, + max_values=max_values, + disabled=disabled, + id=id, + required=required, + ) + self.options: list[SelectOption] = list(options) + + @classmethod + @override + def from_payload(cls, payload: StringSelectPayload) -> Self: + options = [SelectOption.from_dict(option) for option in payload["options"]] + return cls( + custom_id=payload["custom_id"], + options=options, + placeholder=payload.get("placeholder"), + min_values=payload.get("min_values", 1), + max_values=payload.get("max_values", 1), + disabled=payload.get("disabled", False), + id=payload.get("id"), + ) + + @override + def to_dict(self, modal: bool = False) -> StringSelectPayload: + payload: StringSelectPayload = { # pyright: ignore[reportAssignmentType] + "type": int(self.type), + "id": self.id, + "custom_id": self.custom_id, + "options": [option.to_dict() for option in self.options], + "min_values": self.min_values, + "max_values": self.max_values, + "required": self.required, + } + if self.placeholder: + payload["placeholder"] = self.placeholder + + if self.disabled and not modal: + payload["disabled"] = self.disabled + + return payload diff --git a/discord/components/text_display.py b/discord/components/text_display.py new file mode 100644 index 0000000000..8f2e430e78 --- /dev/null +++ b/discord/components/text_display.py @@ -0,0 +1,87 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar, Literal + +from typing_extensions import override + +from ..enums import ComponentType +from ..types.component_types import TextDisplayComponent as TextDisplayComponentPayload +from .component import Component, ModalComponentMixin + +if TYPE_CHECKING: + from typing_extensions import Self + + +class TextDisplay(ModalComponentMixin[TextDisplayComponentPayload], Component[TextDisplayComponentPayload]): + """Represents a Text Display from Components V2. + + This is a component that displays text. + + This inherits from :class:`Component`. + + .. versionadded:: 2.7 + .. versionchanged:: 3.0 + + Attributes + ---------- + type: Literal[:data:`ComponentType.text_display`] + The type of component. + content: :class:`str` + The component's text content. + id: :class:`int` | :data:`None` + The text display's ID. + + Parameters + ---------- + content: + The text content of the component. Can be markdown formatted. + id: + The text display's ID. If not provided, it is set sequentially by Discord. + The ID `0` is treated as if no ID was provided. + """ + + __slots__: tuple[str, ...] = ("content",) # pyright: ignore[reportIncompatibleUnannotatedOverride] + + __repr_info__: ClassVar[tuple[str, ...]] = __slots__ + versions: tuple[int, ...] = (2,) + type: Literal[ComponentType.text_display] = ComponentType.text_display # pyright: ignore[reportIncompatibleVariableOverride] + + def __init__(self, content: str, id: int | None = None): + self.content: str = content + super().__init__(id=id) + + @classmethod + @override + def from_payload(cls, payload: TextDisplayComponentPayload) -> Self: + return cls( + content=payload["content"], + id=payload.get("id"), + ) + + @override + def to_dict(self, modal: bool = False) -> TextDisplayComponentPayload: + return {"type": int(self.type), "id": self.id, "content": self.content} # pyright: ignore[reportReturnType] diff --git a/discord/components/text_input.py b/discord/components/text_input.py new file mode 100644 index 0000000000..7a7373a285 --- /dev/null +++ b/discord/components/text_input.py @@ -0,0 +1,172 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-2021 Rapptz +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar, Literal + +from typing_extensions import override + +from ..enums import ComponentType, TextInputStyle, try_enum +from ..types.component_types import TextInput as TextInputComponentPayload +from .component import Component, ModalComponentMixin + +if TYPE_CHECKING: + from typing_extensions import Self + + +class TextInput(Component[TextInputComponentPayload], ModalComponentMixin[TextInputComponentPayload]): + """Represents an Input Text field from the Discord Bot UI Kit. + + This inherits from :class:`Component`. + + .. versionchanged:: 3.0 + + Attributes + ---------- + type: Literal[:data:`ComponentType.input_text`] + The type of component. + style: :class:`TextInputStyle` + The style of the input text field. + custom_id: :class:`str` | :data:`None` + The custom ID of the input text field that gets received during an interaction. + placeholder: class:`str` | :data:`None` + The placeholder text that is shown if nothing is selected, if any. + min_length: :class:`int` | :data:`None` + The minimum number of characters that must be entered + Defaults to 0 + max_length: :class:`int` | :data:`None` + The maximum number of characters that can be entered + required: :class:`bool` | :data:`None` + Whether the input text field is required or not. Defaults to `True`. + value: :class:`str` | :data:`None` + The value that has been entered in the input text field. + id: :class:`int` | :data:`None` + The input text's ID. + + Parameters + ---------- + style: :class:`TextInputStyle` + The style of the input text field. + custom_id: + The custom ID of the input text field that gets received during an interaction. + min_length: + The minimum number of characters that must be entered. + Defaults to 0. + max_length: + The maximum number of characters that can be entered. + placeholder: + The placeholder text that is shown if nothing is selected, if any. + required: + Whether the input text field is required or not. Defaults to `True`. + value: + The value that has been entered in the input text field. + id: + The input text's ID. If not provided, it is set sequentially by Discord. + The ID `0` is treated as if no ID was provided. + """ + + __slots__: tuple[str, ...] = ( # pyright: ignore[reportIncompatibleUnannotatedOverride] + "style", + "custom_id", + "placeholder", + "min_length", + "max_length", + "required", + "value", + ) + + __repr_info__: ClassVar[tuple[str, ...]] = __slots__ + versions: tuple[int, ...] = (1, 2) + type: Literal[ComponentType.text_input] = ComponentType.text_input # pyright: ignore[reportIncompatibleVariableOverride] + + def __init__( + self, + style: int | TextInputStyle, + custom_id: str, + min_length: int | None = None, + max_length: int | None = None, + placeholder: str | None = None, + required: bool = True, + value: str | None = None, + id: int | None = None, + ) -> None: + self.style: TextInputStyle = style # pyright: ignore[reportAttributeAccessIssue] + self.custom_id: str = custom_id + self.min_length: int | None = min_length + self.max_length: int | None = max_length + self.placeholder: str | None = placeholder + self.required: bool = required + self.value: str | None = value + super().__init__(id=id) + + @classmethod + @override + def from_payload(cls, payload: TextInputComponentPayload) -> Self: + style = try_enum(TextInputStyle, payload["style"]) + custom_id = payload["custom_id"] + min_length = payload.get("min_length") + max_length = payload.get("max_length") + placeholder = payload.get("placeholder") + required = payload.get("required", True) + value = payload.get("value") + + return cls( + style=style, + custom_id=custom_id, + min_length=min_length, + max_length=max_length, + placeholder=placeholder, + required=required, + value=value, + id=payload.get("id"), + ) + + @override + def to_dict(self, modal: bool = False) -> TextInputComponentPayload: + payload: TextInputComponentPayload = { # pyright: ignore[reportAssignmentType] + "type": int(self.type), + "id": self.id, + "style": self.style.value, + } + if self.custom_id: + payload["custom_id"] = self.custom_id + + if self.placeholder: + payload["placeholder"] = self.placeholder + + if self.min_length: + payload["min_length"] = self.min_length + + if self.max_length: + payload["max_length"] = self.max_length + + if not self.required: + payload["required"] = self.required + + if self.value: + payload["value"] = self.value + + return payload # type: ignore diff --git a/discord/components/thumbnail.py b/discord/components/thumbnail.py new file mode 100644 index 0000000000..198a6eda19 --- /dev/null +++ b/discord/components/thumbnail.py @@ -0,0 +1,124 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar, Literal + +from typing_extensions import override + +from ..enums import ComponentType +from ..types.component_types import ThumbnailComponent as ThumbnailComponentPayload +from .component import Component, StateComponentMixin +from .unfurled_media_item import UnfurledMediaItem + +if TYPE_CHECKING: + from typing_extensions import Self + + from ..state import ConnectionState + + +class Thumbnail(StateComponentMixin[ThumbnailComponentPayload], Component[ThumbnailComponentPayload]): + """Represents a Thumbnail from Components V2. + + This is a component that displays media, such as images and videos. + + This inherits from :class:`Component`. + + .. versionadded:: 2.7 + .. versionchanged:: 3.0 + + Attributes + ---------- + type: Literal[:data:`ComponentType.thumbnail`] + The type of component. + media: :class:`UnfurledMediaItem` + The component's underlying media object. + description: :class:`str` | :data:`None` + The thumbnail's description, up to 1024 characters. + spoiler: :class:`bool` | :data:`None` + Whether the thumbnail has the spoiler overlay. + id: :class:`int` | :data:`None` + The thumbnail's ID. + + Parameters + ---------- + url: + The URL of the thumbnail. This can either be an arbitrary URL or an ``attachment://`` URL to work with local files. + id: + The thumbnail's ID. If not provided, it is set sequentially by Discord. + The ID `0` is treated as if no ID was provided. + description: + The thumbnail's description, up to 1024 characters. + spoiler: + Whether the thumbnail has the spoiler overlay. Defaults to ``False``. + """ + + __slots__: tuple[str, ...] = ( + "file", + "description", + "spoiler", + ) + + __repr_info__: ClassVar[tuple[str, ...]] = __slots__ + versions: tuple[int, ...] = (2,) + type: Literal[ComponentType.thumbnail] = ComponentType.thumbnail # pyright: ignore[reportIncompatibleVariableOverride] + + def __init__( + self, + url: str | UnfurledMediaItem, + *, + id: int | None = None, + description: str | None = None, + spoiler: bool | None = False, + ): + self.file: UnfurledMediaItem = url if isinstance(url, UnfurledMediaItem) else UnfurledMediaItem(url) + self.description: str | None = description + self.spoiler: bool | None = spoiler + super().__init__(id=id) + + @property + def url(self) -> str: + """Returns the URL of this thumbnail's underlying media item.""" + return self.file.url + + @classmethod + @override + def from_payload(cls, payload: ThumbnailComponentPayload, state: ConnectionState | None = None) -> Self: + file = UnfurledMediaItem.from_dict(payload.get("file", {}), state=state) + return cls( + url=file, + id=payload.get("id"), + description=payload.get("description"), + spoiler=payload.get("spoiler", False), + ) + + @override + def to_dict(self) -> ThumbnailComponentPayload: + payload: ThumbnailComponentPayload = {"type": self.type, "id": self.id, "media": self.file.to_dict()} # pyright: ignore[reportAssignmentType] + if self.description: + payload["description"] = self.description + if self.spoiler is not None: + payload["spoiler"] = self.spoiler + return payload diff --git a/discord/components/type_aliases.py b/discord/components/type_aliases.py new file mode 100644 index 0000000000..e013a07284 --- /dev/null +++ b/discord/components/type_aliases.py @@ -0,0 +1,95 @@ +from typing import TypeAlias + +from .action_row import ActionRow +from .button import Button +from .channel_select_menu import ChannelSelect +from .container import Container +from .file_component import FileComponent +from .file_upload import FileUpload +from .label import Label +from .media_gallery import MediaGallery +from .mentionable_select_menu import MentionableSelect +from .partial_components import ( + PartialButton, + PartialChannelSelect, + PartialFileUpload, + PartialLabel, + PartialMentionableSelect, + PartialRoleSelect, + PartialStringSelect, + PartialTextDisplay, + PartialTextInput, + PartialUserSelect, + UnknownPartialComponent, +) +from .role_select_menu import RoleSelect +from .section import Section +from .separator import Separator +from .string_select_menu import StringSelect +from .text_display import TextDisplay +from .text_input import TextInput +from .thumbnail import Thumbnail +from .unknown_component import UnknownComponent +from .user_select_menu import UserSelect + +AnyComponent: TypeAlias = ( + ActionRow + | Button + | StringSelect + | TextInput + | UserSelect + | RoleSelect + | MentionableSelect + | ChannelSelect + | Section + | TextDisplay + | Thumbnail + | MediaGallery + | FileComponent + | Separator + | Container + | Label + | FileUpload + | UnknownComponent +) + +AnyTopLevelMessageComponent: TypeAlias = ( + ActionRow | Section | TextDisplay | MediaGallery | FileComponent | Separator | Container +) + +AnyTopLevelModalComponent: TypeAlias = TextDisplay | Label + +AnyPartialComponent: TypeAlias = ( + PartialLabel + | PartialTextInput + | PartialStringSelect + | PartialTextDisplay + | PartialUserSelect + | PartialRoleSelect + | PartialMentionableSelect + | PartialChannelSelect + | PartialFileUpload + | UnknownPartialComponent + | PartialButton +) + +AnyTopLevelModalPartialComponent: TypeAlias = PartialLabel | PartialTextDisplay | UnknownPartialComponent + +AnyMessagePartialComponent: TypeAlias = ( + PartialStringSelect + | PartialUserSelect + | PartialRoleSelect + | PartialMentionableSelect + | PartialButton + | PartialChannelSelect + | UnknownPartialComponent +) + +__all__ = ( + "AnyComponent", + "AnyTopLevelMessageComponent", + "AnyTopLevelModalComponent", + "AnyPartialComponent", + "AnyTopLevelModalPartialComponent", + "AnyMessagePartialComponent", +) diff --git a/discord/components/unfurled_media_item.py b/discord/components/unfurled_media_item.py new file mode 100644 index 0000000000..8b44f5cfc6 --- /dev/null +++ b/discord/components/unfurled_media_item.py @@ -0,0 +1,78 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from typing_extensions import override + +from ..asset import AssetMixin +from ..flags import AttachmentFlags +from ..types.component_types import UnfurledMediaItem as UnfurledMediaItemPayload + +if TYPE_CHECKING: + from ..state import ConnectionState + + +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`. + + This should normally not be created directly. + + .. versionadded:: 2.7 + """ + + def __init__(self, url: str): + self._state: ConnectionState | None = 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 + @override + def url(self) -> str: # pyright: ignore[reportIncompatibleVariableOverride] + """Returns this media item's url.""" + return self._url + + @classmethod + def from_dict(cls, data: UnfurledMediaItemPayload, state: ConnectionState | None = 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)) # pyright: ignore[reportPrivateUsage] + r.attachment_id = data.get("attachment_id") # pyright: ignore[reportAttributeAccessIssue] + r._state = state + return r + + def to_dict(self) -> UnfurledMediaItemPayload: + return {"url": self.url} # pyright: ignore[reportReturnType] diff --git a/discord/components/unknown_component.py b/discord/components/unknown_component.py new file mode 100644 index 0000000000..5cacaf97d5 --- /dev/null +++ b/discord/components/unknown_component.py @@ -0,0 +1,72 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from typing_extensions import override + +from ..enums import ComponentType, try_enum +from ..types.component_types import Component as ComponentPayload +from .component import Component + +if TYPE_CHECKING: + from typing_extensions import Self + + +class UnknownComponent(Component[ComponentPayload]): + """Represents an unknown component. + + This is used when the component type is not recognized by the library, + for example if a new component is introduced by Discord. + + .. versionadded:: 3.0 + + Attributes + ---------- + type: :class:`ComponentType` + The type of the unknown component. + id: :class:`int` | :data:`None` + The component's ID. + """ + + __slots__: tuple[str, ...] = ("type",) + + def __init__(self, type: ComponentType, id: int | None = None) -> None: + self.type: ComponentType = type + super().__init__(id=id) + + @override + def to_dict(self) -> ComponentPayload: + return {"type": int(self.type)} # pyright: ignore[reportReturnType] + + @classmethod + @override + def from_payload(cls, payload: ComponentPayload) -> Self: + type_ = try_enum(ComponentType, payload.pop("type", 0)) + self = cls(type_, id=payload.pop("id", None)) + for key, value in payload.items(): + setattr(self, key, value) + return self diff --git a/discord/components/user_select_menu.py b/discord/components/user_select_menu.py new file mode 100644 index 0000000000..38d27e717a --- /dev/null +++ b/discord/components/user_select_menu.py @@ -0,0 +1,157 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING, Literal + +from typing_extensions import override + +from ..enums import ComponentType +from ..types.component_types import UserSelect as UserSelectPayload +from .default_select_option import DefaultSelectOption +from .select_menu import Select + +if TYPE_CHECKING: + from typing_extensions import Self + + +class UserSelect(Select[UserSelectPayload]): + """Represents a user select menu from the Discord Bot UI Kit. + + This inherits from :class:`SelectMenu`. + + .. versionadded:: 3.0 + + Attributes + ---------- + type: Literal[:data:`ComponentType.user_select`] + The type of component. + default_values: List[:class:`DefaultSelectOption`] + The default selected values of the select menu. + custom_id: :class:`str` + The custom ID of the select menu that gets received during an interaction. + placeholder: :class:`str` | :data:`None` + The placeholder text that is shown if nothing is selected, if any. + min_values: :class:`int` + The minimum number of values that must be selected. + max_values: :class:`int` + The maximum number of values that can be selected. + disabled: :class:`bool` + Whether the select menu is disabled or not. + id: :class:`int` | :data:`None` + The user select menu's ID. + required: :class:`bool` + Whether the user select is required or not. + Only applicable when used in a :class:`discord.Modal`. + + Parameters + ---------- + default_values: + The default selected values of the select menu. + custom_id: + The custom ID of the select menu that gets received during an interaction. + options: + The options available in this select menu. + placeholder: + The placeholder text that is shown if nothing is selected, if any. + min_values: + The minimum number of values that must be selected. + Defaults to 1. + max_values: + The maximum number of values that can be selected. + Defaults to 1. + disabled: + Whether the select menu is disabled or not. Defaults to ``False``. + id: + The user select menu's ID. If not provided, it is set sequentially by Discord. + The ID `0` is treated as if no ID was provided. + required: + Whether the user select is required or not. Defaults to `True`. + Only applicable when used in a :class:`discord.Modal`. + """ + + __slots__: tuple[str, ...] = ("default_values",) + type: Literal[ComponentType.user_select] = ComponentType.user_select # pyright: ignore[reportIncompatibleVariableOverride] + + def __init__( + self, + *, + default_values: Sequence[DefaultSelectOption[Literal["user"]]] | None = None, + custom_id: str, + placeholder: str | None = None, + min_values: int = 1, + max_values: int = 1, + disabled: bool = False, + id: int | None = None, + required: bool = True, + ): + super().__init__( + custom_id=custom_id, + placeholder=placeholder, + min_values=min_values, + max_values=max_values, + disabled=disabled, + id=id, + ) + self.default_values: list[DefaultSelectOption[Literal["user"]]] = ( + list(default_values) if default_values is not None else [] + ) + + @classmethod + @override + def from_payload(cls, payload: UserSelectPayload) -> Self: + default_values: list[DefaultSelectOption[Literal["user"]]] = [ + DefaultSelectOption.from_payload(value) for value in payload.get("default_values", []) + ] + return cls( + custom_id=payload["custom_id"], + placeholder=payload.get("placeholder"), + min_values=payload.get("min_values", 1), + max_values=payload.get("max_values", 1), + disabled=payload.get("disabled", False), + id=payload.get("id"), + default_values=default_values, + ) + + @override + def to_dict(self, modal: bool = False) -> UserSelectPayload: + payload: UserSelectPayload = { # pyright: ignore[reportAssignmentType] + "type": int(self.type), + "id": self.id, + "custom_id": self.custom_id, + "min_values": self.min_values, + "max_values": self.max_values, + } + if self.placeholder: + payload["placeholder"] = self.placeholder + + if self.disabled and not modal: + payload["disabled"] = self.disabled + + if self.default_values: + payload["default_values"] = [value.to_dict() for value in self.default_values] + + return payload diff --git a/discord/enums.py b/discord/enums.py index 5d144d0a07..0d70a0f71a 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -66,7 +66,7 @@ "ScheduledEventStatus", "ScheduledEventPrivacyLevel", "ScheduledEventLocationType", - "InputTextStyle", + "TextInputStyle", "SlashCommandOptionType", "AutoModTriggerType", "AutoModEventType", @@ -644,8 +644,7 @@ class ComponentType(Enum): action_row = 1 button = 2 string_select = 3 - select = string_select # (deprecated) alias for string_select - input_text = 4 + text_input = 4 user_select = 5 role_select = 6 mentionable_select = 7 @@ -658,6 +657,8 @@ class ComponentType(Enum): separator = 14 content_inventory_entry = 16 container = 17 + label = 18 + file_upload = 19 def __int__(self): return self.value @@ -681,18 +682,15 @@ class ButtonStyle(Enum): red = 4 url = 5 - def __int__(self): - return self.value + def __int__(self) -> int: + return int(self.value) -class InputTextStyle(Enum): +class TextInputStyle(Enum): """Input text style""" short = 1 - singleline = 1 paragraph = 2 - multiline = 2 - long = 2 class ApplicationType(Enum): diff --git a/discord/ext/bridge/core.py b/discord/ext/bridge/core.py index 25f13fb196..77b089ee56 100644 --- a/discord/ext/bridge/core.py +++ b/discord/ext/bridge/core.py @@ -40,7 +40,6 @@ SlashCommandOptionType, ) from discord.utils import MISSING, find -from discord.utils.private import warn_deprecated from ..commands import ( BadArgument, diff --git a/discord/ext/pages/__init__.py b/discord/ext/pages/__init__.py deleted file mode 100644 index 3ad7783499..0000000000 --- a/discord/ext/pages/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -""" -discord.ext.pages -~~~~~~~~~~~~~~~~~~~~~ -An extension module to provide useful menu options. - -:copyright: 2021-present Pycord-Development -:license: MIT, see LICENSE for more details. -""" - -from .pagination import * diff --git a/discord/ext/pages/pagination.py b/discord/ext/pages/pagination.py deleted file mode 100644 index f3c1a6866f..0000000000 --- a/discord/ext/pages/pagination.py +++ /dev/null @@ -1,1216 +0,0 @@ -""" -The MIT License (MIT) - -Copyright (c) 2021-present Pycord Development - -Permission is hereby granted, free of charge, to any person obtaining a -copy of this software and associated documentation files (the "Software"), -to deal in the Software without restriction, including without limitation -the rights to use, copy, modify, merge, publish, distribute, sublicense, -and/or sell copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. -""" - -from __future__ import annotations - -from typing import List - -import discord -from discord.errors import DiscordException -from discord.ext.bridge import BridgeContext -from discord.ext.commands import Context -from discord.file import File -from discord.member import Member -from discord.user import User - -__all__ = ( - "PaginatorButton", - "Paginator", - "PageGroup", - "PaginatorMenu", - "Page", -) - - -class PaginatorButton(discord.ui.Button): - """Creates a button used to navigate the paginator. - - Parameters - ---------- - button_type: :class:`str` - The type of button being created. - Must be one of ``first``, ``prev``, ``next``, ``last``, or ``page_indicator``. - label: :class:`str` - The label shown on the button. - Defaults to a capitalized version of ``button_type`` (e.g. "Next", "Prev", etc.) - emoji: Union[:class:`str`, :class:`discord.GuildEmoji`, :class:`discord.AppEmoji`, :class:`discord.PartialEmoji`] - The emoji shown on the button in front of the label. - disabled: :class:`bool` - Whether to initially show the button as disabled. - loop_label: :class:`str` - The label shown on the button when ``loop_pages`` is set to ``True`` in the Paginator class. - - Attributes - ---------- - paginator: :class:`Paginator` - The paginator class where this button is being used. - Assigned to the button when ``Paginator.add_button`` is called. - """ - - def __init__( - self, - button_type: str, - label: str = None, - emoji: (str | discord.GuildEmoji | discord.AppEmoji | discord.PartialEmoji) = None, - style: discord.ButtonStyle = discord.ButtonStyle.green, - disabled: bool = False, - custom_id: str = None, - row: int = 0, - loop_label: str = None, - ): - super().__init__( - label=label if label or emoji else button_type.capitalize(), - emoji=emoji, - style=style, - disabled=disabled, - custom_id=custom_id, - row=row, - ) - self.button_type = button_type - self.label = label if label or emoji else button_type.capitalize() - self.emoji: str | discord.GuildEmoji | discord.AppEmoji | discord.PartialEmoji = emoji - self.style = style - self.disabled = disabled - self.loop_label = self.label if not loop_label else loop_label - self.paginator = None - - async def callback(self, interaction: discord.Interaction): - """|coro| - - The coroutine that is called when the navigation button is clicked. - - Parameters - ---------- - interaction: :class:`discord.Interaction` - The interaction created by clicking the navigation button. - """ - new_page = self.paginator.current_page - if self.button_type == "first": - new_page = 0 - elif self.button_type == "prev": - if self.paginator.loop_pages and self.paginator.current_page == 0: - new_page = self.paginator.page_count - else: - new_page -= 1 - elif self.button_type == "next": - if self.paginator.loop_pages and self.paginator.current_page == self.paginator.page_count: - new_page = 0 - else: - new_page += 1 - elif self.button_type == "last": - new_page = self.paginator.page_count - await self.paginator.goto_page(page_number=new_page, interaction=interaction) - - -class Page: - """Represents a page shown in the paginator. - - Allows for directly referencing and modifying each page as a class instance. - - Parameters - ---------- - content: :class:`str` - The content of the page. Corresponds to the :class:`discord.Message.content` attribute. - embeds: Optional[List[Union[List[:class:`discord.Embed`], :class:`discord.Embed`]]] - The embeds of the page. Corresponds to the :class:`discord.Message.embeds` attribute. - files: Optional[List[:class:`discord.File`]] - A list of local files to be shown with the page. - custom_view: Optional[:class:`discord.ui.View`] - The custom view shown when the page is visible. Overrides the `custom_view` attribute of the main paginator. - """ - - def __init__( - self, - content: str | None = None, - embeds: list[list[discord.Embed] | discord.Embed] | None = None, - custom_view: discord.ui.View | None = None, - files: list[discord.File] | None = None, - **kwargs, - ): - if content is None and embeds is None and custom_view is None: - raise discord.InvalidArgument("A page must at least have content, embeds, or custom_view set.") - self._content = content - self._embeds = embeds or [] - self._custom_view = custom_view - self._files = files or [] - - async def callback(self, interaction: discord.Interaction | None = None): - """|coro| - - The coroutine associated to a specific page. If `Paginator.page_action()` is used, this coroutine is called. - - Parameters - ---------- - interaction: Optional[:class:`discord.Interaction`] - The interaction associated with the callback, if any. - """ - - def update_files(self) -> list[discord.File] | None: - """Updates :class:`discord.File` objects so that they can be sent multiple - times. This is called internally each time the page is sent. - """ - for file in self._files: - if file.fp.closed and (fn := getattr(file.fp, "name", None)): - file.fp = open(fn, "rb") - file.reset() - file.fp.close = lambda: None - return self._files - - @property - def content(self) -> str | None: - """Gets the content for the page.""" - return self._content - - @content.setter - def content(self, value: str | None): - """Sets the content for the page.""" - self._content = value - - @property - def embeds(self) -> list[list[discord.Embed] | discord.Embed] | None: - """Gets the embeds for the page.""" - return self._embeds - - @embeds.setter - def embeds(self, value: list[list[discord.Embed] | discord.Embed] | None): - """Sets the embeds for the page.""" - self._embeds = value - - @property - def custom_view(self) -> discord.ui.View | None: - """Gets the custom view assigned to the page.""" - return self._custom_view - - @custom_view.setter - def custom_view(self, value: discord.ui.View | None): - """Assigns a custom view to be shown when the page is displayed.""" - self._custom_view = value - - @property - def files(self) -> list[discord.File] | None: - """Gets the files associated with the page.""" - return self._files - - @files.setter - def files(self, value: list[discord.File] | None): - """Sets the files associated with the page.""" - self._files = value - - -class PageGroup: - """Creates a group of pages which the user can switch between. - - Each group of pages can have its own options, custom buttons, custom views, etc. - - .. note:: - - If multiple :class:`PageGroup` objects have different options, - they should all be set explicitly when creating each instance. - - Parameters - ---------- - pages: Union[List[:class:`str`], List[:class:`Page`], List[Union[List[:class:`discord.Embed`], :class:`discord.Embed`]]] - The list of :class:`Page` objects, strings, embeds, or list of embeds to include in the page group. - label: :class:`str` - The label shown on the corresponding PaginatorMenu dropdown option. - Also used as the SelectOption value. - description: Optional[:class:`str`] - The description shown on the corresponding PaginatorMenu dropdown option. - emoji: Union[:class:`str`, :class:`discord.GuildEmoji`, :class:`discord.AppEmoji`, :class:`discord.PartialEmoji`] - The emoji shown on the corresponding PaginatorMenu dropdown option. - default: Optional[:class:`bool`] - Whether the page group should be the default page group initially shown when the paginator response is sent. - Only one ``PageGroup`` can be the default page group. - show_disabled: :class:`bool` - Whether to show disabled buttons. - show_indicator: :class:`bool` - Whether to show the page indicator when using the default buttons. - author_check: :class:`bool` - Whether only the original user of the command can change pages. - disable_on_timeout: :class:`bool` - Whether the buttons get disabled when the paginator view times out. - use_default_buttons: :class:`bool` - Whether to use the default buttons (i.e. ``first``, ``prev``, ``page_indicator``, ``next``, ``last``) - default_button_row: :class:`int` - The row where the default paginator buttons are displayed. Has no effect if custom buttons are used. - loop_pages: :class:`bool` - Whether to loop the pages when clicking prev/next while at the first/last page in the list. - custom_view: Optional[:class:`discord.ui.View`] - A custom view whose items are appended below the pagination buttons. - timeout: Optional[:class:`float`] - Timeout in seconds from last interaction with the paginator before no longer accepting input. - custom_buttons: Optional[List[:class:`PaginatorButton`]] - A list of PaginatorButtons to initialize the Paginator with. - If ``use_default_buttons`` is ``True``, this parameter is ignored. - trigger_on_display: :class:`bool` - Whether to automatically trigger the callback associated with a `Page` whenever it is displayed. - Has no effect if no callback exists for a `Page`. - """ - - def __init__( - self, - pages: list[str] | list[Page] | list[list[discord.Embed] | discord.Embed], - label: str, - description: str | None = None, - emoji: (str | discord.GuildEmoji | discord.AppEmoji | discord.PartialEmoji) = None, - default: bool | None = None, - show_disabled: bool | None = None, - show_indicator: bool | None = None, - author_check: bool | None = None, - disable_on_timeout: bool | None = None, - use_default_buttons: bool | None = None, - default_button_row: int = 0, - loop_pages: bool | None = None, - custom_view: discord.ui.View | None = None, - timeout: float | None = None, - custom_buttons: list[PaginatorButton] | None = None, - trigger_on_display: bool | None = None, - ): - self.label = label - self.description: str | None = description - self.emoji: str | discord.GuildEmoji | discord.AppEmoji | discord.PartialEmoji = emoji - self.pages: list[str] | list[list[discord.Embed] | discord.Embed] = pages - self.default: bool | None = default - self.show_disabled = show_disabled - self.show_indicator = show_indicator - self.author_check = author_check - self.disable_on_timeout = disable_on_timeout - self.use_default_buttons = use_default_buttons - self.default_button_row = default_button_row - self.loop_pages = loop_pages - self.custom_view: discord.ui.View = custom_view - self.timeout: float = timeout - self.custom_buttons: list = custom_buttons - self.trigger_on_display = trigger_on_display - - -class Paginator(discord.ui.View): - """Creates a paginator which can be sent as a message and uses buttons for navigation. - - Parameters - ---------- - pages: Union[List[:class:`PageGroup`], List[:class:`Page`], List[:class:`str`], List[Union[List[:class:`discord.Embed`], :class:`discord.Embed`]]] - The list of :class:`PageGroup` objects, :class:`Page` objects, strings, embeds, or list of embeds to paginate. - If a list of :class:`PageGroup` objects is provided and `show_menu` is ``False``, - only the first page group will be displayed. - show_disabled: :class:`bool` - Whether to show disabled buttons. - show_indicator: :class:`bool` - Whether to show the page indicator when using the default buttons. - show_menu: :class:`bool` - Whether to show a select menu that allows the user to switch between groups of pages. - menu_placeholder: :class:`str` - The placeholder text to show in the page group menu when no page group has been selected yet. - Defaults to "Select Page Group" if not provided. - author_check: :class:`bool` - Whether only the original user of the command can change pages. - disable_on_timeout: :class:`bool` - Whether the buttons get disabled when the paginator view times out. - use_default_buttons: :class:`bool` - Whether to use the default buttons (i.e. ``first``, ``prev``, ``page_indicator``, ``next``, ``last``) - default_button_row: :class:`int` - The row where the default paginator buttons are displayed. Has no effect if custom buttons are used. - loop_pages: :class:`bool` - Whether to loop the pages when clicking prev/next while at the first/last page in the list. - custom_view: Optional[:class:`discord.ui.View`] - A custom view whose items are appended below the pagination components. - If the currently displayed page has a `custom_view` assigned, it will replace these - view components when that page is displayed. - timeout: Optional[:class:`float`] - Timeout in seconds from last interaction with the paginator before no longer accepting input. - custom_buttons: Optional[List[:class:`PaginatorButton`]] - A list of PaginatorButtons to initialize the Paginator with. - If ``use_default_buttons`` is ``True``, this parameter is ignored. - trigger_on_display: :class:`bool` - Whether to automatically trigger the callback associated with a `Page` whenever it is displayed. - Has no effect if no callback exists for a `Page`. - - Attributes - ---------- - menu: Optional[List[:class:`PaginatorMenu`]] - The page group select menu associated with this paginator. - page_groups: Optional[List[:class:`PageGroup`]] - List of :class:`PageGroup` objects the user can switch between. - default_page_group: Optional[:class:`int`] - The index of the default page group shown when the paginator is initially sent. - Defined by setting ``default`` to ``True`` on a :class:`PageGroup`. - current_page: :class:`int` - A zero-indexed value showing the current page number. - page_count: :class:`int` - A zero-indexed value showing the total number of pages. - buttons: Dict[:class:`str`, Dict[:class:`str`, Union[:class:`~PaginatorButton`, :class:`bool`]]] - A dictionary containing the :class:`~PaginatorButton` objects included in this paginator. - user: Optional[Union[:class:`~discord.User`, :class:`~discord.Member`]] - The user or member that invoked the paginator. - message: Union[:class:`~discord.Message`, :class:`~discord.WebhookMessage`] - The message the paginator is attached to. - """ - - def __init__( - self, - pages: (list[PageGroup] | list[Page] | list[str] | list[list[discord.Embed] | discord.Embed]), - show_disabled: bool = True, - show_indicator=True, - show_menu=False, - menu_placeholder: str = "Select Page Group", - author_check=True, - disable_on_timeout=True, - use_default_buttons=True, - default_button_row: int = 0, - loop_pages=False, - custom_view: discord.ui.View | None = None, - timeout: float | None = 180.0, - custom_buttons: list[PaginatorButton] | None = None, - trigger_on_display: bool | None = None, - ) -> None: - super().__init__(timeout=timeout) - self.timeout: float = timeout - self.pages: list[PageGroup] | list[str] | list[Page] | list[list[discord.Embed] | discord.Embed] = pages - self.current_page = 0 - self.menu: PaginatorMenu | None = None - self.show_menu = show_menu - self.menu_placeholder = menu_placeholder - self.page_groups: list[PageGroup] | None = None - self.default_page_group: int = 0 - - if all(isinstance(pg, PageGroup) for pg in pages): - self.page_groups = self.pages if show_menu else None - if sum(pg.default is True for pg in self.page_groups) > 1: - raise ValueError("Only one PageGroup can be set as the default.") - for pg in self.page_groups: - if pg.default: - self.default_page_group = self.page_groups.index(pg) - break - self.pages: list[Page] = self.get_page_group_content(self.page_groups[self.default_page_group]) - - self.page_count = max(len(self.pages) - 1, 0) - self.buttons = {} - self.custom_buttons: list = custom_buttons - self.show_disabled = show_disabled - self.show_indicator = show_indicator - self.disable_on_timeout = disable_on_timeout - self.use_default_buttons = use_default_buttons - self.default_button_row = default_button_row - self.loop_pages = loop_pages - self.custom_view: discord.ui.View = custom_view - self.trigger_on_display = trigger_on_display - self.message: discord.Message | discord.WebhookMessage | None = None - - if self.custom_buttons and not self.use_default_buttons: - for button in custom_buttons: - self.add_button(button) - elif not self.custom_buttons and self.use_default_buttons: - self.add_default_buttons() - - if self.show_menu: - self.add_menu() - - self.usercheck = author_check - self.user = None - - async def update( - self, - pages: None | (list[PageGroup] | list[Page] | list[str] | list[list[discord.Embed] | discord.Embed]) = None, - show_disabled: bool | None = None, - show_indicator: bool | None = None, - show_menu: bool | None = None, - author_check: bool | None = None, - menu_placeholder: str | None = None, - disable_on_timeout: bool | None = None, - use_default_buttons: bool | None = None, - default_button_row: int | None = None, - loop_pages: bool | None = None, - custom_view: discord.ui.View | None = None, - timeout: float | None = None, - custom_buttons: list[PaginatorButton] | None = None, - trigger_on_display: bool | None = None, - interaction: discord.Interaction | None = None, - current_page: int = 0, - ): - """Updates the existing :class:`Paginator` instance with the provided options. - - Parameters - ---------- - pages: Optional[Union[List[:class:`PageGroup`], List[:class:`Page`], List[:class:`str`], List[Union[List[:class:`discord.Embed`], :class:`discord.Embed`]]]] - The list of :class:`PageGroup` objects, :class:`Page` objects, strings, - embeds, or list of embeds to paginate. - show_disabled: :class:`bool` - Whether to show disabled buttons. - show_indicator: :class:`bool` - Whether to show the page indicator when using the default buttons. - show_menu: :class:`bool` - Whether to show a select menu that allows the user to switch between groups of pages. - author_check: :class:`bool` - Whether only the original user of the command can change pages. - menu_placeholder: :class:`str` - The placeholder text to show in the page group menu when no page group has been selected yet. - Defaults to "Select Page Group" if not provided. - disable_on_timeout: :class:`bool` - Whether the buttons get disabled when the paginator view times out. - use_default_buttons: :class:`bool` - Whether to use the default buttons (i.e. ``first``, ``prev``, ``page_indicator``, ``next``, ``last``) - default_button_row: Optional[:class:`int`] - The row where the default paginator buttons are displayed. Has no effect if custom buttons are used. - loop_pages: :class:`bool` - Whether to loop the pages when clicking prev/next while at the first/last page in the list. - custom_view: Optional[:class:`discord.ui.View`] - A custom view whose items are appended below the pagination components. - timeout: Optional[:class:`float`] - Timeout in seconds from last interaction with the paginator before no longer accepting input. - custom_buttons: Optional[List[:class:`PaginatorButton`]] - A list of PaginatorButtons to initialize the Paginator with. - If ``use_default_buttons`` is ``True``, this parameter is ignored. - trigger_on_display: :class:`bool` - Whether to automatically trigger the callback associated with a `Page` whenever it is displayed. - Has no effect if no callback exists for a `Page`. - interaction: Optional[:class:`discord.Interaction`] - The interaction to use when updating the paginator. If not provided, the paginator will be updated - by using its stored :attr:`message` attribute instead. - current_page: :class:`int` - The initial page number to display when updating the paginator. - """ - - # Update pages and reset current_page to 0 (default) - self.pages: list[PageGroup] | list[str] | list[Page] | list[list[discord.Embed] | discord.Embed] = ( - pages if pages is not None else self.pages - ) - self.show_menu = show_menu if show_menu is not None else self.show_menu - if pages is not None and all(isinstance(pg, PageGroup) for pg in pages): - self.page_groups = self.pages if self.show_menu else None - if sum(pg.default is True for pg in self.page_groups) > 1: - raise ValueError("Only one PageGroup can be set as the default.") - for pg in self.page_groups: - if pg.default: - self.default_page_group = self.page_groups.index(pg) - break - self.pages: list[Page] = self.get_page_group_content(self.page_groups[self.default_page_group]) - self.page_count = max(len(self.pages) - 1, 0) - self.current_page = current_page if current_page <= self.page_count else 0 - # Apply config changes, if specified - self.show_disabled = show_disabled if show_disabled is not None else self.show_disabled - self.show_indicator = show_indicator if show_indicator is not None else self.show_indicator - self.usercheck = author_check if author_check is not None else self.usercheck - self.menu_placeholder = menu_placeholder if menu_placeholder is not None else self.menu_placeholder - self.disable_on_timeout = disable_on_timeout if disable_on_timeout is not None else self.disable_on_timeout - self.use_default_buttons = use_default_buttons if use_default_buttons is not None else self.use_default_buttons - self.default_button_row = default_button_row if default_button_row is not None else self.default_button_row - self.loop_pages = loop_pages if loop_pages is not None else self.loop_pages - self.custom_view: discord.ui.View = None if custom_view is None else custom_view - self.timeout: float = timeout if timeout is not None else self.timeout - self.custom_buttons = custom_buttons if custom_buttons is not None else self.custom_buttons - self.trigger_on_display = trigger_on_display if trigger_on_display is not None else self.trigger_on_display - self.buttons = {} - if self.use_default_buttons: - self.add_default_buttons() - elif self.custom_buttons: - for button in self.custom_buttons: - self.add_button(button) - - await self.goto_page(self.current_page, interaction=interaction) - - async def on_timeout(self) -> None: - """Disables all buttons when the view times out.""" - if self.disable_on_timeout: - 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() - await self.message.edit( - view=self, - files=files or [], - attachments=[], - ) - - async def disable( - self, - include_custom: bool = False, - page: None | (str | Page | list[discord.Embed] | discord.Embed) = None, - ) -> None: - """Stops the paginator, disabling all of its components. - - Parameters - ---------- - include_custom: :class:`bool` - Whether to disable components added via custom views. - page: Optional[Union[:class:`str`, Union[List[:class:`discord.Embed`], :class:`discord.Embed`]]] - The page content to show after disabling the paginator. - """ - page = self.get_page_content(page) - 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( - content=page.content, - embeds=page.embeds, - view=self, - ) - else: - await self.message.edit(view=self) - - async def cancel( - self, - include_custom: bool = False, - page: None | (str | Page | list[discord.Embed] | discord.Embed) = None, - ) -> None: - """Cancels the paginator, removing all of its components from the message. - - Parameters - ---------- - include_custom: :class:`bool` - Whether to remove components added via custom views. - page: Optional[Union[:class:`str`, Union[List[:class:`discord.Embed`], :class:`discord.Embed`]]] - The page content to show after canceling the paginator. - """ - items = self.children.copy() - page = self.get_page_content(page) - for item in items: - if include_custom or not self.custom_view or item not in self.custom_view.children: - self.remove_item(item) - if page: - await self.message.edit( - content=page.content, - embeds=page.embeds, - view=self, - ) - else: - await self.message.edit(view=self) - - def _goto_page(self, page_number: int = 0) -> tuple[Page, list[File] | None]: - self.current_page = page_number - self.update_buttons() - - page = self.pages[page_number] - page = self.get_page_content(page) - - if page.custom_view: - self.update_custom_view(page.custom_view) - - files = page.update_files() - - return page, files - - async def goto_page(self, page_number: int = 0, *, interaction: discord.Interaction | None = None) -> None: - """Updates the paginator message to show the specified page number. - - Parameters - ---------- - page_number: :class:`int` - The page to display. - - .. note:: - - Page numbers are zero-indexed when referenced internally, - but appear as one-indexed when shown to the user. - - interaction: Optional[:class:`discord.Interaction`] - The interaction to use when editing the message. If not provided, the message will be - edited using the paginator's stored :attr:`message` attribute instead. - - Returns - ------- - :class:`~discord.Message` - The message associated with the paginator. - """ - old_page = self.current_page - page, files = self._goto_page(page_number) - - try: - if interaction: - await interaction.response.defer() # needed to force webhook message edit route for files kwarg support - await interaction.followup.edit_message( - message_id=self.message.id, - content=page.content, - embeds=page.embeds, - attachments=[], - files=files or [], - view=self, - ) - else: - await self.message.edit( - content=page.content, - embeds=page.embeds, - attachments=[], - files=files or [], - view=self, - ) - except DiscordException: - # Something went wrong, and the paginator couldn't be updated. - # Revert our changes and propagate the error. - self._goto_page(old_page) - raise - - if self.trigger_on_display: - await self.page_action(interaction=interaction) - - async def interaction_check(self, interaction: discord.Interaction) -> bool: - if self.usercheck: - return self.user == interaction.user - return True - - def add_menu(self): - """Adds the default :class:`PaginatorMenu` instance to the paginator.""" - self.menu = PaginatorMenu(self.page_groups, placeholder=self.menu_placeholder) - self.menu.paginator = self - self.add_item(self.menu) - - def add_default_buttons(self): - """Adds the full list of default buttons that can be used with the paginator. - Includes ``first``, ``prev``, ``page_indicator``, ``next``, and ``last``. - """ - default_buttons = [ - PaginatorButton( - "first", - label="<<", - style=discord.ButtonStyle.blurple, - row=self.default_button_row, - ), - PaginatorButton( - "prev", - label="<", - style=discord.ButtonStyle.red, - loop_label="↪", - row=self.default_button_row, - ), - PaginatorButton( - "page_indicator", - style=discord.ButtonStyle.gray, - disabled=True, - row=self.default_button_row, - ), - PaginatorButton( - "next", - label=">", - style=discord.ButtonStyle.green, - loop_label="↩", - row=self.default_button_row, - ), - PaginatorButton( - "last", - label=">>", - style=discord.ButtonStyle.blurple, - row=self.default_button_row, - ), - ] - for button in default_buttons: - self.add_button(button) - - def add_button(self, button: PaginatorButton): - """Adds a :class:`PaginatorButton` to the paginator.""" - self.buttons[button.button_type] = { - "object": discord.ui.Button( - style=button.style, - label=( - button.label - if button.label or button.emoji - else ( - button.button_type.capitalize() - if button.button_type != "page_indicator" - else f"{self.current_page + 1}/{self.page_count + 1}" - ) - ), - disabled=button.disabled, - custom_id=button.custom_id, - emoji=button.emoji, - row=button.row, - ), - "label": button.label, - "loop_label": button.loop_label, - "hidden": (button.disabled if button.button_type != "page_indicator" else not self.show_indicator), - } - self.buttons[button.button_type]["object"].callback = button.callback - button.paginator = self - - def remove_button(self, button_type: str): - """Removes a :class:`PaginatorButton` from the paginator.""" - if button_type not in self.buttons.keys(): - raise ValueError(f"no button_type {button_type} was found in this paginator.") - self.buttons.pop(button_type) - - def update_buttons(self) -> dict: - """Updates the display state of the buttons (disabled/hidden) - - Returns - ------- - Dict[:class:`str`, Dict[:class:`str`, Union[:class:`~PaginatorButton`, :class:`bool`]]] - The dictionary of buttons that were updated. - """ - for key, button in self.buttons.items(): - if key == "first": - if self.current_page <= 1: - button["hidden"] = True - elif self.current_page >= 1: - button["hidden"] = False - elif key == "last": - if self.current_page >= self.page_count - 1: - button["hidden"] = True - if self.current_page < self.page_count - 1: - button["hidden"] = False - elif key == "next": - if self.current_page == self.page_count: - if not self.loop_pages: - button["hidden"] = True - button["object"].label = button["label"] - else: - button["object"].label = button["loop_label"] - elif self.current_page < self.page_count: - button["hidden"] = False - button["object"].label = button["label"] - elif key == "prev": - if self.current_page <= 0: - if not self.loop_pages: - button["hidden"] = True - button["object"].label = button["label"] - else: - button["object"].label = button["loop_label"] - elif self.current_page >= 0: - button["hidden"] = False - button["object"].label = button["label"] - self.clear_items() - if self.show_indicator: - try: - self.buttons["page_indicator"]["object"].label = f"{self.current_page + 1}/{self.page_count + 1}" - except KeyError: - pass - for key, button in self.buttons.items(): - if key != "page_indicator": - if button["hidden"]: - button["object"].disabled = True - if self.show_disabled: - self.add_item(button["object"]) - else: - button["object"].disabled = False - self.add_item(button["object"]) - elif self.show_indicator: - self.add_item(button["object"]) - - if self.show_menu: - self.add_menu() - - # We're done adding standard buttons and menus, so we can now add any specified custom view items below them - # The bot developer should handle row assignments for their view before passing it to Paginator - if self.custom_view: - self.update_custom_view(self.custom_view) - - return self.buttons - - def update_custom_view(self, custom_view: discord.ui.View): - """Updates the custom view shown on the paginator.""" - if isinstance(self.custom_view, discord.ui.View): - for item in self.custom_view.children: - self.remove_item(item) - for item in custom_view.children: - self.add_item(item) - - def get_page_group_content(self, page_group: PageGroup) -> list[Page]: - """Returns a converted list of `Page` objects for the given page group based on the content of its pages.""" - return [self.get_page_content(page) for page in page_group.pages] - - @staticmethod - def get_page_content( - page: Page | str | discord.Embed | list[discord.Embed], - ) -> Page: - """Converts a page into a :class:`Page` object based on its content.""" - if isinstance(page, Page): - return page - elif isinstance(page, str): - return Page(content=page, embeds=[], files=[]) - elif isinstance(page, discord.Embed): - 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=[]) - if all(isinstance(x, discord.File) for x in page): - return Page(content=None, embeds=[], files=page) - else: - raise TypeError("All list items must be embeds or files.") - else: - raise TypeError( - "Page content must be a Page object, string, an embed, a view, a list of" - " embeds, a file, or a list of files." - ) - - async def page_action(self, interaction: discord.Interaction | None = None) -> None: - """Triggers the callback associated with the current page, if any. - - Parameters - ---------- - interaction: Optional[:class:`discord.Interaction`] - The interaction that was used to trigger the page action. - """ - if self.get_page_content(self.pages[self.current_page]).callback: - await self.get_page_content(self.pages[self.current_page]).callback(interaction=interaction) - - async def send( - self, - ctx: Context, - target: discord.abc.Messageable | None = None, - target_message: str | None = None, - reference: None | (discord.Message | discord.MessageReference | discord.PartialMessage) = None, - allowed_mentions: discord.AllowedMentions | None = None, - mention_author: bool | None = None, - delete_after: float | None = None, - ) -> discord.Message: - """Sends a message with the paginated items. - - Parameters - ---------- - ctx: Union[:class:`~discord.ext.commands.Context`] - A command's invocation context. - target: Optional[:class:`~discord.abc.Messageable`] - A target where the paginated message should be sent, if different from the original :class:`Context` - target_message: Optional[:class:`str`] - An optional message shown when the paginator message is sent elsewhere. - reference: Optional[Union[:class:`discord.Message`, :class:`discord.MessageReference`, :class:`discord.PartialMessage`]] - A reference to the :class:`~discord.Message` to which you are replying with the paginator. - This can be created using :meth:`~discord.Message.to_reference` or passed directly as a - :class:`~discord.Message`. You can control whether this mentions the author of the referenced message - using the :attr:`~discord.AllowedMentions.replied_user` attribute of ``allowed_mentions`` or by - setting ``mention_author``. - allowed_mentions: Optional[:class:`~discord.AllowedMentions`] - Controls the mentions being processed in this message. If this is - passed, then the object is merged with :attr:`~discord.Client.allowed_mentions`. - The merging behaviour only overrides attributes that have been explicitly passed - to the object, otherwise it uses the attributes set in :attr:`~discord.Client.allowed_mentions`. - If no object is passed at all then the defaults given by :attr:`~discord.Client.allowed_mentions` - are used instead. - mention_author: Optional[:class:`bool`] - If set, overrides the :attr:`~discord.AllowedMentions.replied_user` attribute of ``allowed_mentions``. - delete_after: Optional[:class:`float`] - If set, deletes the paginator after the specified time. - - Returns - ------- - :class:`~discord.Message` - The message that was sent with the paginator. - """ - if not isinstance(ctx, Context): - raise TypeError(f"expected Context not {ctx.__class__!r}") - - if target is not None and not isinstance(target, discord.abc.Messageable): - raise TypeError(f"expected abc.Messageable not {target.__class__!r}") - - if reference is not None and not isinstance( - reference, - (discord.Message, discord.MessageReference, discord.PartialMessage), - ): - raise TypeError(f"expected Message, MessageReference, or PartialMessage not {reference.__class__!r}") - - if allowed_mentions is not None and not isinstance(allowed_mentions, discord.AllowedMentions): - raise TypeError(f"expected AllowedMentions not {allowed_mentions.__class__!r}") - - if mention_author is not None and not isinstance(mention_author, bool): - raise TypeError(f"expected bool not {mention_author.__class__!r}") - - self.update_buttons() - page = self.pages[self.current_page] - page_content = self.get_page_content(page) - - if page_content.custom_view: - self.update_custom_view(page_content.custom_view) - - self.user = ctx.author - - if target: - if target_message: - await ctx.send( - target_message, - reference=reference, - allowed_mentions=allowed_mentions, - mention_author=mention_author, - ) - ctx = target - - self.message = await ctx.send( - content=page_content.content, - embeds=page_content.embeds, - files=page_content.files, - view=self, - reference=reference, - allowed_mentions=allowed_mentions, - mention_author=mention_author, - delete_after=delete_after, - ) - - return self.message - - async def edit( - self, - message: discord.Message, - suppress: bool | None = None, - allowed_mentions: discord.AllowedMentions | None = None, - delete_after: float | None = None, - user: User | Member | None = None, - ) -> discord.Message | None: - """Edits an existing message to replace it with the paginator contents. - - .. note:: - - If invoked from an interaction, you will still need to respond to the interaction. - - Parameters - ---------- - message: :class:`discord.Message` - The message to edit with the paginator. - suppress: :class:`bool` - Whether to suppress embeds for the message. This removes - all the embeds if set to ``True``. If set to ``False`` - this brings the embeds back if they were suppressed. - Using this parameter requires :attr:`~.Permissions.manage_messages`. - allowed_mentions: Optional[:class:`~discord.AllowedMentions`] - Controls the mentions being processed in this message. If this is - passed, then the object is merged with :attr:`~discord.Client.allowed_mentions`. - The merging behaviour only overrides attributes that have been explicitly passed - to the object, otherwise it uses the attributes set in :attr:`~discord.Client.allowed_mentions`. - If no object is passed at all then the defaults given by :attr:`~discord.Client.allowed_mentions` - are used instead. - delete_after: Optional[:class:`float`] - If set, deletes the paginator after the specified time. - user: Optional[Union[:class:`~discord.User`, :class:`~discord.Member`]] - If set, changes the user that this paginator belongs to. - - Returns - ------- - Optional[:class:`discord.Message`] - The message that was edited. Returns ``None`` if the operation failed. - """ - if not isinstance(message, discord.Message): - raise TypeError(f"expected Message not {message.__class__!r}") - - self.update_buttons() - - page: Page | str | discord.Embed | list[discord.Embed] = self.pages[self.current_page] - page_content: Page = self.get_page_content(page) - - if page_content.custom_view: - self.update_custom_view(page_content.custom_view) - - self.user = user or self.user - - if not self.user: - self.usercheck = False - - try: - self.message = await message.edit( - content=page_content.content, - embeds=page_content.embeds, - files=page_content.files, - attachments=[], - view=self, - suppress=suppress, - allowed_mentions=allowed_mentions, - delete_after=delete_after, - ) - except (discord.NotFound, discord.Forbidden): - pass - - return self.message - - async def respond( - self, - interaction: discord.Interaction | BridgeContext, - ephemeral: bool = False, - target: discord.abc.Messageable | None = None, - target_message: str = "Paginator sent!", - ) -> discord.Message | discord.WebhookMessage: - """Sends an interaction response or followup with the paginated items. - - Parameters - ---------- - interaction: Union[:class:`discord.Interaction`, :class:`BridgeContext`] - The interaction or BridgeContext which invoked the paginator. - If passing a BridgeContext object, you cannot make this an ephemeral paginator. - ephemeral: :class:`bool` - Whether the paginator message and its components are ephemeral. - If ``target`` is specified, the ephemeral message content will be ``target_message`` instead. - - .. warning:: - - If your paginator is ephemeral, it cannot have a timeout - longer than 15 minutes (and cannot be persistent). - - target: Optional[:class:`~discord.abc.Messageable`] - A target where the paginated message should be sent, - if different from the original :class:`discord.Interaction` - target_message: :class:`str` - The content of the interaction response shown when the paginator message is sent elsewhere. - - Returns - ------- - Union[:class:`~discord.Message`, :class:`~discord.WebhookMessage`] - The :class:`~discord.Message` or :class:`~discord.WebhookMessage` that was sent with the paginator. - """ - - if not isinstance(interaction, (discord.Interaction, BridgeContext)): - raise TypeError(f"expected Interaction or BridgeContext, not {interaction.__class__!r}") - - if target is not None and not isinstance(target, discord.abc.Messageable): - raise TypeError(f"expected abc.Messageable not {target.__class__!r}") - - if ephemeral and (self.timeout is None or self.timeout >= 900): - raise ValueError( - "paginator responses cannot be ephemeral if the paginator timeout is 15 minutes or greater" - ) - - self.update_buttons() - - page: Page | str | discord.Embed | list[discord.Embed] = self.pages[self.current_page] - page_content: Page = self.get_page_content(page) - - if page_content.custom_view: - self.update_custom_view(page_content.custom_view) - - if isinstance(interaction, discord.Interaction): - self.user = interaction.user - - if target: - await interaction.response.send_message(target_message, ephemeral=ephemeral) - msg = await target.send( - content=page_content.content, - embeds=page_content.embeds, - files=page_content.files, - view=self, - ) - elif interaction.response.is_done(): - msg = await interaction.followup.send( - content=page_content.content, - embeds=page_content.embeds, - files=page_content.files, - view=self, - ephemeral=ephemeral, - ) - # convert from WebhookMessage to Message reference to bypass - # 15min webhook token timeout (non-ephemeral messages only) - if not ephemeral and not msg.flags.ephemeral: - msg = await msg.channel.fetch_message(msg.id) - else: - msg = await interaction.response.send_message( - content=page_content.content, - embeds=page_content.embeds, - files=page_content.files, - view=self, - ephemeral=ephemeral, - ) - else: - ctx = interaction - self.user = ctx.author - if target: - await ctx.respond(target_message, ephemeral=ephemeral) - msg = await ctx.send( - content=page_content.content, - embeds=page_content.embeds, - files=page_content.files, - view=self, - ) - else: - msg = await ctx.respond( - content=page_content.content, - embeds=page_content.embeds, - files=page_content.files, - view=self, - ) - if isinstance(msg, (discord.Message, discord.WebhookMessage)): - self.message = msg - elif isinstance(msg, discord.Interaction): - self.message = await msg.original_response() - - return self.message - - -class PaginatorMenu(discord.ui.Select): - """Creates a select menu used to switch between page groups, which can each have their own set of buttons. - - Parameters - ---------- - placeholder: :class:`str` - The placeholder text that is shown if nothing is selected. - - Attributes - ---------- - paginator: :class:`Paginator` - The paginator class where this menu is being used. - Assigned to the menu when ``Paginator.add_menu`` is called. - """ - - def __init__( - self, - page_groups: list[PageGroup], - placeholder: str | None = None, - custom_id: str | None = None, - ): - self.page_groups = page_groups - self.paginator: Paginator | None = None - opts = [ - discord.SelectOption( - label=page_group.label, - value=page_group.label, - description=page_group.description, - emoji=page_group.emoji, - ) - for page_group in self.page_groups - ] - super().__init__( - placeholder=placeholder, - max_values=1, - min_values=1, - options=opts, - custom_id=custom_id, - ) - - async def callback(self, interaction: discord.Interaction): - """|coro| - - The coroutine that is called when a menu option is selected. - - Parameters - ---------- - interaction: :class:`discord.Interaction` - The interaction created by selecting the menu option. - """ - selection = self.values[0] - for page_group in self.page_groups: - if selection == page_group.label: - return await self.paginator.update( - pages=page_group.pages, - show_disabled=page_group.show_disabled, - show_indicator=page_group.show_indicator, - author_check=page_group.author_check, - disable_on_timeout=page_group.disable_on_timeout, - use_default_buttons=page_group.use_default_buttons, - default_button_row=page_group.default_button_row, - loop_pages=page_group.loop_pages, - custom_view=page_group.custom_view, - timeout=page_group.timeout, - custom_buttons=page_group.custom_buttons, - trigger_on_display=page_group.trigger_on_display, - interaction=interaction, - ) diff --git a/discord/http.py b/discord/http.py index 9d015b6be1..b63920baf0 100644 --- a/discord/http.py +++ b/discord/http.py @@ -63,7 +63,7 @@ audit_log, automod, channel, - components, + component_types, embed, emoji, guild, @@ -455,7 +455,7 @@ def send_message( allowed_mentions: message.AllowedMentions | None = None, message_reference: message.MessageReference | None = None, stickers: list[sticker.StickerItem] | None = None, - components: list[components.Component] | None = None, + components: list[component_types.Component] | None = None, flags: int | None = None, poll: poll.Poll | None = None, ) -> Response[message.Message]: @@ -517,7 +517,7 @@ def send_multipart_helper( allowed_mentions: message.AllowedMentions | None = None, message_reference: message.MessageReference | None = None, stickers: list[sticker.StickerItem] | None = None, - components: list[components.Component] | None = None, + components: list[component_types.Component] | None = None, flags: int | None = None, poll: poll.Poll | None = None, ) -> Response[message.Message]: @@ -587,7 +587,7 @@ def send_files( allowed_mentions: message.AllowedMentions | None = None, message_reference: message.MessageReference | None = None, stickers: list[sticker.StickerItem] | None = None, - components: list[components.Component] | None = None, + components: list[component_types.Component] | None = None, flags: int | None = None, poll: poll.Poll | None = None, ) -> Response[message.Message]: @@ -1158,7 +1158,7 @@ def start_forum_thread( nonce: int | str | None = None, allowed_mentions: message.AllowedMentions | None = None, stickers: list[sticker.StickerItem] | None = None, - components: list[components.Component] | None = None, + components: list[component_types.Component] | None = None, flags: int | None = None, ) -> Response[threads.Thread]: payload: dict[str, Any] = { diff --git a/discord/interactions.py b/discord/interactions.py index 455b70b45a..d4361ad653 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -27,16 +27,15 @@ import asyncio import datetime -from typing import TYPE_CHECKING, Any, Coroutine, Union +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any, Coroutine, Generic, cast + +from typing_extensions import TypeVar, TypeVarTuple, Unpack from . import utils -from .channel import ChannelType, PartialMessageable, _threaded_channel_factory -from .enums import ( - InteractionContextType, - InteractionResponseType, - InteractionType, - try_enum, -) +from .channel import PartialMessageable, _threaded_channel_factory +from .components import ComponentsHolder, _partial_component_factory +from .enums import ChannelType, InteractionContextType, InteractionResponseType, InteractionType, try_enum from .errors import ClientException, InteractionResponded, InvalidArgument from .file import File, VoiceMessage from .flags import MessageFlags @@ -46,6 +45,7 @@ from .monetization import Entitlement from .object import Object from .permissions import Permissions +from .role import Role from .user import User from .utils.private import cached_slot_property, delay_task, deprecated, get_as_snowflake from .webhook.async_ import ( @@ -57,9 +57,10 @@ __all__ = ( "Interaction", + "ModalInteraction", + "ComponentInteraction", "InteractionMessage", "InteractionResponse", - "MessageInteraction", "InteractionMetadata", "AuthorizingIntegrationOwners", "InteractionCallback", @@ -79,6 +80,13 @@ ) from .client import Client from .commands import ApplicationCommand, OptionChoice + from .components import ( + AnyComponent, + AnyMessagePartialComponent, + AnyTopLevelModalComponent, + AnyTopLevelModalPartialComponent, + Modal, + ) from .embeds import Embed from .mentions import AllowedMentions from .poll import Poll @@ -88,9 +96,8 @@ from .types.interactions import InteractionCallback as InteractionCallbackPayload from .types.interactions import InteractionCallbackResponse, InteractionData from .types.interactions import InteractionMetadata as InteractionMetadataPayload - from .types.interactions import MessageInteraction as MessageInteractionPayload - from .ui.modal import Modal - from .ui.view import View + from .types.interactions import ModalInteraction as ModalInteractionPayload + from .types.partial_components import PartialComponent InteractionChannel = ( VoiceChannel @@ -164,14 +171,6 @@ class Interaction: 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 attachment_size_limit: :class:`int` The attachment size limit. @@ -199,8 +198,6 @@ class Interaction: "authorizing_integration_owners", "callback", "command", - "view", - "modal", "attachment_size_limit", "_channel_data", "_message_data", @@ -250,8 +247,6 @@ def _from_data(self, data: InteractionPayload): ) self.command: ApplicationCommand | None = None - self.view: View | None = None - self.modal: Modal | None = None self.attachment_size_limit: int = data.get("attachment_size_limit") self.message: Message | None = None @@ -331,23 +326,6 @@ def is_component(self) -> bool: """Indicates whether the interaction is a message component.""" return self.type == InteractionType.component - @cached_slot_property("_cs_channel") - @deprecated("Interaction.channel", "2.7", stacklevel=4) - def cached_channel(self) -> InteractionChannel | None: - """The cached channel from which the interaction was sent. - DM channels are not resolved. These are :class:`PartialMessageable` instead. - - .. deprecated:: 2.7 - """ - guild = self.guild - channel = guild and guild._resolve_channel(self.channel_id) - if channel is None: - if self.channel_id is not None: - type = ChannelType.text if self.guild_id is not None else ChannelType.private - return PartialMessageable(state=self._state, id=self.channel_id, type=type) - return None - return channel - @property def permissions(self) -> Permissions: """The resolved permissions of the member in the channel, including overwrites. @@ -470,24 +448,6 @@ async def original_response(self) -> InteractionMessage: self._original_response = message return message - @deprecated("Interaction.original_response", "2.2") - async def original_message(self): - """An alias for :meth:`original_response`. - - Returns - ------- - InteractionMessage - The original interaction response message. - - Raises - ------ - HTTPException - Fetching the original response message failed. - ClientException - The channel for the message could not be resolved. - """ - return await self.original_response() - async def edit_original_response( self, *, @@ -497,7 +457,7 @@ async def edit_original_response( file: File | utils.Undefined = MISSING, files: list[File] | utils.Undefined = MISSING, attachments: list[Attachment] | utils.Undefined = MISSING, - view: View | None | utils.Undefined = MISSING, + components: Sequence[AnyTopLevelModalComponent] | None | utils.Undefined = MISSING, allowed_mentions: AllowedMentions | None = None, delete_after: float | None = None, suppress: bool = False, @@ -532,9 +492,11 @@ async def edit_original_response( allowed_mentions: :class:`AllowedMentions` Controls the mentions being processed in this message. See :meth:`.abc.Messageable.send` for more information. - view: Optional[:class:`~discord.ui.View`] - The updated view to update this message with. If ``None`` is passed then - the view is removed. + components: + The updated components to update this message with. If ``None`` is passed then + the components are removed. + + .. versionadded:: 3.0 delete_after: Optional[:class:`float`] If provided, the number of seconds to wait in the background before deleting the message we just edited. If the deletion fails, @@ -567,7 +529,7 @@ async def edit_original_response( attachments=attachments, embed=embed, embeds=embeds, - view=view, + components=components, allowed_mentions=allowed_mentions, previous_allowed_mentions=previous_mentions, suppress=suppress, @@ -588,39 +550,12 @@ async def edit_original_response( # The message channel types should always match state = _InteractionMessageState(self, self._state) message = InteractionMessage(state=state, channel=self.channel, data=data) # type: ignore - if view and not view.is_finished(): - view.message = message - 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) return message - @deprecated("Interaction.edit_original_response", "2.2") - async def edit_original_message(self, **kwargs): - """An alias for :meth:`edit_original_response`. - - Returns - ------- - :class:`InteractionMessage` - The newly edited message. - - Raises - ------ - HTTPException - Editing the message failed. - Forbidden - Edited a message that is not yours. - TypeError - You specified both ``embed`` and ``embeds`` or ``file`` and ``files`` - ValueError - The length of ``embeds`` was invalid. - """ - return await self.edit_original_response(**kwargs) - async def delete_original_response(self, *, delay: float | None = None) -> None: """|coro| @@ -657,19 +592,6 @@ async def delete_original_response(self, *, delay: float | None = None) -> None: else: await func - @deprecated("Interaction.delete_original_response", "2.2") - async def delete_original_message(self, **kwargs): - """An alias for :meth:`delete_original_response`. - - Raises - ------ - HTTPException - Deleting the message failed. - Forbidden - Deleted a message that is not yours. - """ - return await self.delete_original_response(**kwargs) - async def respond(self, *args, **kwargs) -> Interaction | WebhookMessage: """|coro| @@ -752,6 +674,55 @@ def to_dict(self) -> dict[str, Any]: return data +Components_t = TypeVarTuple("Components_t", default="Unpack[tuple[AnyTopLevelModalPartialComponent, ...]]") + + +class ModalInteraction(Interaction, Generic[Unpack[Components_t]]): + __slots__ = ("_components", "users", "attachments", "roles") + + def __init__(self, *, data: ModalInteractionPayload, state: ConnectionState): + super().__init__(data=data, state=state) + resolved = data.get("data", {}).get("resolved", {}) + self.users: dict[int, User] = { + int(user_id): User(state=state, data=user_data) for user_id, user_data in resolved.get("users", {}).items() + } + self.attachments: dict[int, Attachment] = { + int(att_id): Attachment(state=state, data=att_data) + for att_id, att_data in resolved.get("attachments", {}).items() + } + self.roles: dict[int, Role] = { + int(role_id): Role(state=state, data=role_data, guild=self.guild) + for role_id, role_data in resolved.get("roles", {}).items() + } + + # TODO: When we have better partial objects, add self.channels and self.members + + @cached_slot_property("_components") + def components(self) -> ComponentsHolder[Unpack[Components_t]]: + if not self.type == InteractionType.modal_submit: + raise TypeError("Only modal submit interactions have components") + if not self.data: + raise TypeError("This interaction has no data. This should never happen, please open an issue on GitHub") + components_payload = cast("list[PartialComponent]", self.data.get("components", [])) + + return ComponentsHolder(*(_partial_component_factory(component) for component in components_payload)) # pyright: ignore[reportReturnType] + + +Component_t = TypeVar("Component_t", bound="AnyMessagePartialComponent", default="AnyMessagePartialComponent") + + +class ComponentInteraction(Interaction, Generic[Component_t]): + __slots__ = ("_component",) + + @cached_slot_property("_component") + def component(self) -> Component_t: + if not self.type == InteractionType.component: + raise TypeError("Only component interactions have a component") + if not self.data: + raise TypeError("This interaction has no data. This should never happen, please open an issue on GitHub") + return _partial_component_factory(self.data, key="component_type") # pyright: ignore[reportArgumentType, reportReturnType] + + class InteractionResponse: """Represents a Discord interaction response. @@ -907,7 +878,7 @@ async def send_message( *, embed: Embed = None, embeds: list[Embed] = None, - view: View = None, + components: Sequence[AnyTopLevelModalComponent] = None, tts: bool = False, ephemeral: bool = False, allowed_mentions: AllowedMentions = None, @@ -932,11 +903,11 @@ async def send_message( ``embeds`` parameter. tts: :class:`bool` Indicates if the message should be sent using text-to-speech. - view: :class:`discord.ui.View` - The view to send with the message. + components: + The components to send with the message. ephemeral: :class:`bool` Indicates if the message should only be visible to the user who started the interaction. - If a view is sent with an ephemeral message, and it has no timeout set then the timeout + If components are sent with an ephemeral message, and it has no timeout set then the timeout is set to 15 minutes. allowed_mentions: :class:`AllowedMentions` Controls the mentions being processed in this message. @@ -992,12 +963,17 @@ async def send_message( flags = MessageFlags(ephemeral=ephemeral) - 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 components is not None: + payload["components"] = [] + if components: + for c in components: + payload["components"].append(c.to_dict()) + if c.any_is_v2(): + flags.is_components_v2 = True + + if flags.is_components_v2: + if embeds or content: + raise TypeError("cannot send embeds or content with components using v2 component logic") if poll is not None: payload["poll"] = poll.to_dict() @@ -1052,14 +1028,6 @@ async def send_message( for file in files: file.close() - if view is not None: - if ephemeral and view.timeout is None: - view.timeout = 15 * 60.0 - - view.parent = self._parent - if view.is_dispatchable(): - self._parent._state.store_view(view) - self._responded = True await self._process_callback_response(callback_response) if delete_after is not None: @@ -1075,7 +1043,7 @@ async def edit_message( file: File | utils.Undefined = MISSING, files: list[File] | utils.Undefined = MISSING, attachments: list[Attachment] | utils.Undefined = MISSING, - view: View | None | utils.Undefined = MISSING, + components: Sequence[AnyComponent] | None | utils.Undefined = MISSING, delete_after: float | None = None, suppress: bool | None | utils.Undefined = MISSING, allowed_mentions: AllowedMentions | None = None, @@ -1102,9 +1070,9 @@ async def edit_message( attachments: List[:class:`Attachment`] A list of attachments to keep in the message. If ``[]`` is passed then all attachments are removed. - view: Optional[:class:`~discord.ui.View`] - The updated view to update this message with. If ``None`` is passed then - the view is removed. + components: + The updated components to update this message with. If ``None`` is passed then + the components are removed. delete_after: Optional[:class:`float`] If provided, the number of seconds to wait in the background before deleting the message we just edited. If the deletion fails, @@ -1134,7 +1102,6 @@ async def edit_message( parent = self._parent msg = parent.message state = parent._state - message_id = msg.id if msg else None if parent.type not in (InteractionType.component, InteractionType.modal_submit): return @@ -1152,9 +1119,8 @@ async def edit_message( if attachments is not MISSING: payload["attachments"] = [a.to_dict() for a in attachments] - if view is not MISSING: - state.prevent_view_updates_for(message_id) - payload["components"] = [] if view is None else view.to_components() + if components is not MISSING: + payload["components"] = [] if components is None else [c.to_dict() for c in components] if file is not MISSING and files is not MISSING: raise InvalidArgument("cannot pass both file and files parameter to edit_message()") @@ -1210,10 +1176,6 @@ async def edit_message( for file in files: file.close() - if view and not view.is_finished(): - view.message = msg - state.store_view(view, message_id) - self._responded = True await self._process_callback_response(callback_response) if delete_after is not None: @@ -1273,7 +1235,7 @@ async def send_modal(self, modal: Modal) -> Interaction: Parameters ---------- - modal: :class:`discord.ui.Modal` + modal: :class:`discord.Modal` The modal dialog to display to the user. Raises @@ -1304,45 +1266,6 @@ async def send_modal(self, modal: Modal) -> Interaction: ) self._responded = True await self._process_callback_response(callback_response) - self._parent._state.store_modal(modal, self._parent.user.id) - return self._parent - - @deprecated("a button with type ButtonType.premium", "2.6") - async def premium_required(self) -> Interaction: - """|coro| - - Responds to this interaction by sending a premium required message. - - .. deprecated:: 2.6 - - A button with type :attr:`ButtonType.premium` should be used instead. - - Raises - ------ - HTTPException - Sending the message failed. - InteractionResponded - This interaction has already been responded to before. - """ - if self._responded: - raise InteractionResponded(self._parent) - - parent = self._parent - - adapter = async_context.get() - http = parent._state.http - callback_response: InteractionCallbackResponse = await self._locked_response( - adapter.create_interaction_response( - parent.id, - parent.token, - session=parent._session, - proxy=http.proxy, - proxy_auth=http.proxy_auth, - type=InteractionResponseType.premium_required.value, - ) - ) - self._responded = True - await self._process_callback_response(callback_response) return self._parent async def _locked_response(self, coro: Coroutine[Any, Any, Any]) -> Any: @@ -1422,7 +1345,7 @@ async def edit( file: File | utils.Undefined = MISSING, files: list[File] | utils.Undefined = MISSING, attachments: list[Attachment] | utils.Undefined = MISSING, - view: View | None | utils.Undefined = MISSING, + components: Sequence[AnyTopLevelModalComponent] | None | utils.Undefined = MISSING, allowed_mentions: AllowedMentions | None = None, delete_after: float | None = None, suppress: bool | None | utils.Undefined = MISSING, @@ -1451,9 +1374,9 @@ async def edit( allowed_mentions: :class:`AllowedMentions` Controls the mentions being processed in this message. See :meth:`.abc.Messageable.send` for more information. - view: Optional[:class:`~discord.ui.View`] - The updated view to update this message with. If ``None`` is passed then - the view is removed. + components: + The updated components to update this message with. If ``None`` is passed then + the components are removed. delete_after: Optional[:class:`float`] If provided, the number of seconds to wait in the background before deleting the message we just edited. If the deletion fails, @@ -1488,7 +1411,7 @@ async def edit( file=file, files=files, attachments=attachments, - view=view, + components=components, allowed_mentions=allowed_mentions, delete_after=delete_after, suppress=suppress, @@ -1517,46 +1440,6 @@ async def delete(self, *, delay: float | None = None) -> None: await self._state._interaction.delete_original_response(delay=delay) -class MessageInteraction: - """Represents a Discord message interaction. - - This is sent on the message object when the message is a response - to an interaction without an existing message e.g. application command. - - .. versionadded:: 2.0 - - .. deprecated:: 2.6 - - See :class:`InteractionMetadata`. - - .. note:: - Responses to message components do not include this property. - - Attributes - ---------- - id: :class:`int` - The interaction's ID. - type: :class:`InteractionType` - The interaction type. - name: :class:`str` - The name of the invoked application command. - user: :class:`User` - The user that sent the interaction. - data: :class:`dict` - The raw interaction data. - """ - - __slots__: tuple[str, ...] = ("id", "type", "name", "user", "data", "_state") - - def __init__(self, *, data: MessageInteractionPayload, state: ConnectionState): - self._state = state - self.data = data - self.id: int = int(data["id"]) - self.type: InteractionType = data["type"] - self.name: str = data["name"] - self.user: User = self._state.store_user(data["user"]) - - class InteractionMetadata: """Represents metadata about an interaction. diff --git a/discord/message.py b/discord/message.py index 9c140538ca..ecffaa2c16 100644 --- a/discord/message.py +++ b/discord/message.py @@ -28,13 +28,13 @@ import datetime import io import re +from collections.abc import Sequence from os import PathLike from typing import ( TYPE_CHECKING, Any, Callable, ClassVar, - Sequence, TypeVar, Union, overload, @@ -43,7 +43,7 @@ from . import utils from .channel import PartialMessageable -from .components import _component_factory +from .components import AnyComponent, ComponentsHolder, _component_factory from .embeds import Embed from .emoji import AppEmoji, GuildEmoji from .enums import ChannelType, MessageReferenceType, MessageType, try_enum @@ -71,11 +71,10 @@ ) from .channel import TextChannel from .components import Component - from .interactions import MessageInteraction from .mentions import AllowedMentions from .role import Role from .state import ConnectionState - from .types.components import Component as ComponentPayload + from .types.component_types import Component as ComponentPayload from .types.embed import Embed as EmbedPayload from .types.member import Member as MemberPayload from .types.member import UserWithMember as UserWithMemberPayload @@ -93,7 +92,6 @@ from .types.snowflake import SnowflakeList from .types.threads import ThreadArchiveDuration from .types.user import User as UserPayload - from .ui.view import View from .user import User MR = TypeVar("MR", bound="MessageReference") @@ -681,8 +679,11 @@ class ForwardedMessage: A list of :class:`Role` that were originally mentioned. stickers: List[:class:`StickerItem`] A list of sticker items given to the original message. - components: List[:class:`Component`] + components: :class:`ComponentsHolder` A list of components in the original message. + + .. versionchanged:: 3.0 + Now is of type :class:`ComponentsHolder` instead of :class:`list`. """ def __init__( @@ -709,7 +710,9 @@ def __init__( self.attachments: list[Attachment] = [Attachment(data=a, state=state) for a in data["attachments"]] self.flags: MessageFlags = MessageFlags._from_value(data.get("flags", 0)) self.stickers: list[StickerItem] = [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", [])] + self.components: ComponentsHolder[AnyComponent] = ComponentsHolder( + *(_component_factory(d) for d in data.get("components", [])) + ) self._edited_timestamp: datetime.datetime | None = parse_time(data["edited_timestamp"]) @property @@ -909,18 +912,16 @@ class Message(Hashable): A list of sticker items given to the message. .. versionadded:: 1.6 - components: List[:class:`Component`] + components: :class:`ComponentsHolder` A list of components in the message. .. versionadded:: 2.0 - guild: Optional[:class:`Guild`] - The guild that the message belongs to, if applicable. - interaction: Optional[:class:`MessageInteraction`] - The interaction associated with the message, if applicable. - .. deprecated:: 2.6 + .. versionchanged:: 3.0 + Now is of type :class:`ComponentsHolder` instead of :class:`list`. - Use :attr:`interaction_metadata` instead. + guild: Optional[:class:`Guild`] + The guild that the message belongs to, if applicable. interaction_metadata: Optional[:class:`InteractionMetadata`] The interaction metadata associated with the message, if applicable. @@ -1018,7 +1019,9 @@ def __init__( self.content: str = data["content"] self.nonce: int | str | None = data.get("nonce") self.stickers: list[StickerItem] = [StickerItem(data=d, state=state) for d in data.get("sticker_items", [])] - self.components: list[Component] = [_component_factory(d, state=state) for d in data.get("components", [])] + self.components: ComponentsHolder[AnyComponent] = ComponentsHolder( + *(_component_factory(d, state=state) for d in data.get("components", [])) + ) try: # if the channel doesn't have a guild attribute, we handle that @@ -1062,13 +1065,8 @@ def __init__( except KeyError: self.snapshots = [] - from .interactions import InteractionMetadata, MessageInteraction # noqa: PLC0415 + from .interactions import InteractionMetadata # noqa: PLC0415 - self._interaction: MessageInteraction | None - try: - self._interaction = MessageInteraction(data=data["interaction"], state=state) - except KeyError: - self._interaction = None try: self.interaction_metadata = InteractionMetadata(data=data["interaction_metadata"], state=state) except KeyError: @@ -1270,32 +1268,12 @@ 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, state=self._state) for d in components] + self.components = ComponentsHolder(*(_component_factory(d, state=self._state) for d in components)) # pyright: ignore[reportArgumentType] def _rebind_cached_references(self, new_guild: Guild, new_channel: TextChannel | Thread) -> None: self.guild = new_guild self.channel = new_channel - @property - def interaction(self) -> MessageInteraction | None: - warn_deprecated( - "interaction", - "interaction_metadata", - "2.6", - reference="https://discord.com/developers/docs/change-log#userinstallable-apps-preview", - ) - return self._interaction - - @interaction.setter - def interaction(self, value: MessageInteraction | None) -> None: - warn_deprecated( - "interaction", - "interaction_metadata", - "2.6", - reference="https://discord.com/developers/docs/change-log#userinstallable-apps-preview", - ) - self._interaction = value - @cached_slot_property("_cs_raw_mentions") def raw_mentions(self) -> list[int]: """A property that returns an array of user IDs matched with @@ -1589,7 +1567,7 @@ async def edit( suppress: bool = ..., delete_after: float | None = ..., allowed_mentions: AllowedMentions | None = ..., - view: View | None = ..., + components: Sequence[AnyComponent] | None | utils.Undefined = MISSING, ) -> Message: ... async def edit( @@ -1603,7 +1581,7 @@ async def edit( suppress: bool | utils.Undefined = MISSING, delete_after: float | None = None, allowed_mentions: AllowedMentions | None | utils.Undefined = MISSING, - view: View | None | utils.Undefined = MISSING, + components: Sequence[AnyComponent] | None | utils.Undefined = MISSING, ) -> Message: """|coro| @@ -1652,9 +1630,8 @@ async def edit( are used instead. .. versionadded:: 1.4 - view: Optional[:class:`~discord.ui.View`] - The updated view to update this message with. If ``None`` is passed then - the view is removed. + components: + The new components to replace the originals with. If ``None`` is passed then the components are removed. Raises ------ @@ -1699,11 +1676,14 @@ async def edit( if attachments is not MISSING: payload["attachments"] = [a.to_dict() for a in attachments] - 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 components is not MISSING: + payload["components"] = [] + if components: + for c in components: + if c.any_is_v2(): + flags.is_components_v2 = True + payload["components"].append(c.to_dict()) + if file is not MISSING and files is not MISSING: raise InvalidArgument("cannot pass both file and files parameter to edit()") @@ -1738,12 +1718,6 @@ async def edit( data = await self._state.http.edit_message(self.channel.id, self.id, **payload) message = Message(state=self._state, channel=self.channel, data=data) - if view and not view.is_finished(): - view.message = message - 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) @@ -2289,11 +2263,12 @@ async def edit(self, **fields: Any) -> Message | None: to the object, otherwise it uses the attributes set in :attr:`~discord.Client.allowed_mentions`. If no object is passed at all then the defaults given by :attr:`~discord.Client.allowed_mentions` are used instead. - view: Optional[:class:`~discord.ui.View`] - The updated view to update this message with. If ``None`` is passed then - the view is removed. + components: + The new components to replace the originals with. If ``None`` is passed then the components + are removed. - .. versionadded:: 2.0 + .. versionchanged:: 3.0 + Changed from view to components. Returns ------- @@ -2346,10 +2321,14 @@ async def edit(self, **fields: Any) -> Message | None: self._state.allowed_mentions.to_dict() if self._state.allowed_mentions else None ) - view = fields.pop("view", MISSING) - if view is not MISSING: - self._state.prevent_view_updates_for(self.id) - fields["components"] = view.to_components() if view else [] + components = fields.pop("components", MISSING) + if components is not MISSING: + fields["components"] = [] + if components: + for c in components: + if c.any_is_v2(): + flags.is_components_v2 = True + fields["components"].append(c.to_dict()) if fields: data = await self._state.http.edit_message(self.channel.id, self.id, **fields) @@ -2360,11 +2339,6 @@ async def edit(self, **fields: Any) -> Message | None: if fields: # data isn't unbound msg = self._state.create_message(channel=self.channel, data=data) # type: ignore - if view and not view.is_finished(): - view.message = msg - 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/state.py b/discord/state.py index 1784a9a20e..29025f8a50 100644 --- a/discord/state.py +++ b/discord/state.py @@ -54,7 +54,7 @@ from .flags import ApplicationFlags, Intents, MemberCacheFlags from .guild import Guild from .integrations import _integration_factory -from .interactions import Interaction +from .interactions import ComponentInteraction, Interaction, ModalInteraction from .invite import Invite from .member import Member from .mentions import AllowedMentions @@ -70,8 +70,6 @@ from .stage_instance import StageInstance from .sticker import GuildSticker from .threads import Thread, ThreadMember -from .ui.modal import Modal, ModalStore -from .ui.view import View, ViewStore from .user import ClientUser, User from .utils.private import get_as_snowflake, parse_time, sane_wait_for @@ -248,7 +246,7 @@ def __init__( self.clear() - def clear(self, *, views: bool = True) -> None: + def clear(self) -> None: self.user: ClientUser | None = None # Originally, this code used WeakValueDictionary to maintain references to the # global user mapping. @@ -267,9 +265,6 @@ def clear(self, *, views: bool = True) -> None: self._stickers: dict[int, GuildSticker] = {} self._guilds: dict[int, Guild] = {} self._polls: dict[int, Poll] = {} - if views: - self._view_store: ViewStore = ViewStore(self) - self._modal_store: ModalStore = ModalStore(self) self._voice_clients: dict[int, VoiceClient] = {} self._sounds: dict[int, SoundboardSound] = {} @@ -382,19 +377,6 @@ def store_sticker(self, guild: Guild, data: GuildStickerPayload) -> GuildSticker self._stickers[sticker_id] = sticker = GuildSticker(state=self, data=data) return sticker - def store_view(self, view: View, message_id: int | None = None) -> None: - self._view_store.add_view(view, message_id) - - def store_modal(self, modal: Modal, message_id: int) -> None: - self._modal_store.add_modal(modal, message_id) - - def prevent_view_updates_for(self, message_id: int) -> View | None: - return self._view_store.remove_message_tracking(message_id) - - @property - def persistent_views(self) -> Sequence[View]: - return self._view_store.persistent_views - @property def guilds(self) -> list[Guild]: return list(self._guilds.values()) @@ -630,7 +612,7 @@ def parse_ready(self, data) -> None: self._ready_task.cancel() self._ready_state = asyncio.Queue() - self.clear(views=False) + self.clear() self.user = ClientUser(state=self, data=data["user"]) self.store_user(data["user"]) @@ -754,9 +736,6 @@ def parse_message_update(self, data) -> None: self.store_raw_poll(poll_data, raw) self.dispatch("raw_message_edit", raw) - if "components" in data and self._view_store.is_message_tracked(raw.message_id): - self._view_store.update_from_message(raw.message_id, data["components"]) - def parse_message_reaction_add(self, data) -> None: emoji = data["emoji"] emoji_id = get_as_snowflake(emoji, "id") @@ -890,17 +869,12 @@ def parse_message_poll_vote_remove(self, data) -> None: self.dispatch("poll_vote_remove", poll, user, answer) def parse_interaction_create(self, data) -> None: - interaction = Interaction(data=data, state=self) - if data["type"] == 3: # interaction component - custom_id = interaction.data["custom_id"] # type: ignore - component_type = interaction.data["component_type"] # type: ignore - self._view_store.dispatch(component_type, custom_id, interaction) - if interaction.type == InteractionType.modal_submit: - user_id, custom_id = ( - interaction.user.id, - interaction.data["custom_id"], - ) - asyncio.create_task(self._modal_store.dispatch(user_id, custom_id, interaction)) + if data["type"] == InteractionType.modal_submit.value: + interaction = ModalInteraction(data=data, state=self) + elif data["type"] == InteractionType.component.value: + interaction = ComponentInteraction(data=data, state=self) + else: + interaction = Interaction(data=data, state=self) self.dispatch("interaction", interaction) diff --git a/discord/types/component_types.py b/discord/types/component_types.py new file mode 100644 index 0000000000..f064ea273d --- /dev/null +++ b/discord/types/component_types.py @@ -0,0 +1,297 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-2021 Rapptz +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from typing import Generic, Literal, TypeAlias, TypeVar, Union + +from typing_extensions import NotRequired, TypedDict + +from .channel import ChannelType +from .emoji import PartialEmoji +from .snowflake import Snowflake + +ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 18] +ButtonStyle = Literal[1, 2, 3, 4, 5, 6] +TextInputStyle = Literal[1, 2] +SeparatorSpacingSize = Literal[1, 2] + + +class BaseComponent(TypedDict): + type: ComponentType + id: NotRequired[int] + + +class ButtonComponent(BaseComponent): + type: Literal[2] # pyright: ignore[reportIncompatibleVariableOverride] + style: ButtonStyle + label: NotRequired[str] + emoji: NotRequired[PartialEmoji] + custom_id: NotRequired[str] + url: NotRequired[str] + disabled: NotRequired[bool] + sku_id: NotRequired[Snowflake] + + +class TextInput(BaseComponent): + type: Literal[4] # pyright: ignore[reportIncompatibleVariableOverride] + min_length: NotRequired[int] + max_length: NotRequired[int] + required: NotRequired[bool] + placeholder: NotRequired[str] + value: NotRequired[str] + style: TextInputStyle + custom_id: str + label: str + + +class SelectOption(TypedDict): + description: NotRequired[str] + emoji: NotRequired[PartialEmoji] + label: str + value: str + default: bool + + +T = TypeVar("T", bound=Literal["user", "role", "channel"]) + + +class SelectDefaultValue(TypedDict, Generic[T]): + id: int + type: T + + +class StringSelect(BaseComponent): + type: Literal[3] # pyright: ignore[reportIncompatibleVariableOverride] + custom_id: str + options: list[SelectOption] + placeholder: NotRequired[str] + min_values: NotRequired[int] + max_values: NotRequired[int] + disabled: NotRequired[bool] + required: NotRequired[bool] + + +class UserSelect(BaseComponent): + type: Literal[5] # pyright: ignore[reportIncompatibleVariableOverride] + custom_id: str + placeholder: NotRequired[str] + default_values: NotRequired[list[SelectDefaultValue[Literal["user"]]]] + min_values: NotRequired[int] + max_values: NotRequired[int] + disabled: NotRequired[bool] + required: NotRequired[bool] + + +class RoleSelect(BaseComponent): + type: Literal[6] # pyright: ignore[reportIncompatibleVariableOverride] + custom_id: str + placeholder: NotRequired[str] + default_values: NotRequired[list[SelectDefaultValue[Literal["role"]]]] + min_values: NotRequired[int] + max_values: NotRequired[int] + disabled: NotRequired[bool] + required: NotRequired[bool] + + +class MentionableSelect(BaseComponent): + type: Literal[7] # pyright: ignore[reportIncompatibleVariableOverride] + custom_id: str + placeholder: NotRequired[str] + default_values: NotRequired[list[SelectDefaultValue[Literal["role", "user"]]]] + min_values: NotRequired[int] + max_values: NotRequired[int] + disabled: NotRequired[bool] + required: NotRequired[bool] + + +class ChannelSelect(BaseComponent): + type: Literal[8] # pyright: ignore[reportIncompatibleVariableOverride] + custom_id: str + channel_types: NotRequired[list[ChannelType]] + placeholder: NotRequired[str] + default_values: NotRequired[list[SelectDefaultValue[Literal["channel"]]]] + min_values: NotRequired[int] + max_values: NotRequired[int] + disabled: NotRequired[bool] + required: NotRequired[bool] + + +class SectionComponent(BaseComponent): + type: Literal[9] # pyright: ignore[reportIncompatibleVariableOverride] + components: list[TextDisplayComponent] + accessory: NotRequired[ThumbnailComponent | ButtonComponent] + + +class TextDisplayComponent(BaseComponent): + type: Literal[10] # pyright: ignore[reportIncompatibleVariableOverride] + content: str + + +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] # pyright: ignore[reportIncompatibleVariableOverride] + 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] # pyright: ignore[reportIncompatibleVariableOverride] + items: list[MediaGalleryItem] + + +class FileComponent(BaseComponent): + type: Literal[13] # pyright: ignore[reportIncompatibleVariableOverride] + file: UnfurledMediaItem + spoiler: NotRequired[bool] + name: str + size: int + + +class SeparatorComponent(BaseComponent): + type: Literal[14] # pyright: ignore[reportIncompatibleVariableOverride] + divider: NotRequired[bool] + spacing: NotRequired[SeparatorSpacingSize] + + +AllowedActionRowComponents = ( + ButtonComponent | TextInput | StringSelect | UserSelect | RoleSelect | MentionableSelect | ChannelSelect +) + + +class ActionRow(BaseComponent): + type: Literal[1] # pyright: ignore[reportIncompatibleVariableOverride] + components: list[AllowedActionRowComponents] + + +AllowedContainerComponents: TypeAlias = ( + ActionRow | TextDisplayComponent | MediaGalleryComponent | FileComponent | SeparatorComponent | SectionComponent +) + + +class ContainerComponent(BaseComponent): + type: Literal[17] # pyright: ignore[reportIncompatibleVariableOverride] + accent_color: NotRequired[int] + spoiler: NotRequired[bool] + components: list[AllowedContainerComponents] + + +class FileUpload(BaseComponent): + type: Literal[19] + custom_id: str + min_values: NotRequired[int] + max_values: NotRequired[int] + required: NotRequired[bool] + + +AllowedLabelComponents: TypeAlias = TextDisplayComponent | StringSelect | FileUpload + + +class LabelComponent(BaseComponent): + type: Literal[18] # pyright: ignore[reportIncompatibleVariableOverride] + component: AllowedLabelComponents + label: str + description: NotRequired[str] + + +Component = ( + ActionRow + | ButtonComponent + | StringSelect + | UserSelect + | RoleSelect + | MentionableSelect + | ChannelSelect + | TextInput + | TextDisplayComponent + | SectionComponent + | ThumbnailComponent + | MediaGalleryComponent + | FileComponent + | SeparatorComponent + | ContainerComponent + | LabelComponent + | FileUpload +) + +AllowedModalComponents = LabelComponent | TextDisplayComponent + + +class Modal(TypedDict): + title: str + custom_id: str + components: list[AllowedModalComponents] + + +__all__ = ( + "ComponentType", + "ButtonStyle", + "TextInputStyle", + "SeparatorSpacingSize", + "BaseComponent", + "ButtonComponent", + "TextInput", + "SelectOption", + "SelectDefaultValue", + "StringSelect", + "UserSelect", + "RoleSelect", + "MentionableSelect", + "ChannelSelect", + "SectionComponent", + "TextDisplayComponent", + "UnfurledMediaItem", + "ThumbnailComponent", + "MediaGalleryItem", + "MediaGalleryComponent", + "FileComponent", + "SeparatorComponent", + "AllowedActionRowComponents", + "ActionRow", + "AllowedContainerComponents", + "ContainerComponent", + "FileUpload", + "AllowedLabelComponents", + "LabelComponent", + "Component", + "AllowedModalComponents", + "Modal", +) diff --git a/discord/types/components.py b/discord/types/components.py deleted file mode 100644 index 88f7b9b2c1..0000000000 --- a/discord/types/components.py +++ /dev/null @@ -1,159 +0,0 @@ -""" -The MIT License (MIT) - -Copyright (c) 2015-2021 Rapptz -Copyright (c) 2021-present Pycord Development - -Permission is hereby granted, free of charge, to any person obtaining a -copy of this software and associated documentation files (the "Software"), -to deal in the Software without restriction, including without limitation -the rights to use, copy, modify, merge, publish, distribute, sublicense, -and/or sell copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. -""" - -from __future__ import annotations - -from typing import Literal - -from typing_extensions import NotRequired, TypedDict - -from .channel import ChannelType -from .emoji import PartialEmoji -from .snowflake import Snowflake - -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 BaseComponent(TypedDict): - type: ComponentType - id: NotRequired[int] - - -class ActionRow(BaseComponent): - type: Literal[1] - components: list[ButtonComponent, InputText, SelectMenu] - - -class ButtonComponent(BaseComponent): - custom_id: NotRequired[str] - url: NotRequired[str] - disabled: NotRequired[bool] - emoji: NotRequired[PartialEmoji] - label: NotRequired[str] - type: Literal[2] - style: ButtonStyle - sku_id: Snowflake - - -class InputText(BaseComponent): - min_length: NotRequired[int] - max_length: NotRequired[int] - required: NotRequired[bool] - placeholder: NotRequired[str] - value: NotRequired[str] - type: Literal[4] - style: InputTextStyle - custom_id: str - label: str - - -class SelectOption(TypedDict): - description: NotRequired[str] - emoji: NotRequired[PartialEmoji] - label: str - value: str - default: bool - - -class SelectMenu(BaseComponent): - placeholder: NotRequired[str] - min_values: NotRequired[int] - max_values: NotRequired[int] - disabled: NotRequired[bool] - channel_types: NotRequired[list[ChannelType]] - options: NotRequired[list[SelectOption]] - type: Literal[3, 5, 6, 7, 8] - 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 = ActionRow | ButtonComponent | SelectMenu | InputText - - -AllowedContainerComponents = ( - ActionRow | TextDisplayComponent | MediaGalleryComponent | FileComponent | SeparatorComponent | SectionComponent -) diff --git a/discord/types/interactions.py b/discord/types/interactions.py index 235d9722c9..e4c624d15f 100644 --- a/discord/types/interactions.py +++ b/discord/types/interactions.py @@ -25,11 +25,11 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Dict, Literal, Union +from typing import TYPE_CHECKING, Dict, Generic, Literal, TypeAlias, TypeVar, Union from ..permissions import Permissions from .channel import ChannelType -from .components import Component, ComponentType +from .component_types import Component, ComponentType from .embed import Embed from .member import Member from .message import Attachment @@ -189,11 +189,19 @@ class ComponentInteractionData(TypedDict): component_type: ComponentType -InteractionData = ApplicationCommandInteractionData | ComponentInteractionData +class ModalInteractionData(TypedDict): + custom_id: str + components: list[Component] + resolved: NotRequired[ApplicationCommandInteractionDataResolved] + +InteractionData = ApplicationCommandInteractionData | ComponentInteractionData | ModalInteractionData -class Interaction(TypedDict): - data: NotRequired[InteractionData] +D = TypeVar("D", bound=InteractionData) + + +class Interaction(TypedDict, Generic[D]): + data: NotRequired[D] guild_id: NotRequired[Snowflake] channel_id: NotRequired[Snowflake] channel: NotRequired[InteractionChannel] @@ -214,6 +222,9 @@ class Interaction(TypedDict): context: InteractionContextType +ModalInteraction: TypeAlias = Interaction[ModalInteractionData] + + class InteractionMetadata(TypedDict): id: Snowflake type: InteractionType @@ -241,13 +252,6 @@ class InteractionResponse(TypedDict): type: InteractionResponseType -class MessageInteraction(TypedDict): - id: Snowflake - type: InteractionType - name: str - user: User - - class EditApplicationCommand(TypedDict): description: NotRequired[str] options: NotRequired[list[ApplicationCommandOption] | None] diff --git a/discord/types/message.py b/discord/types/message.py index 82488745d2..707ecce113 100644 --- a/discord/types/message.py +++ b/discord/types/message.py @@ -28,7 +28,7 @@ from typing import TYPE_CHECKING, Literal from .channel import ChannelType -from .components import Component +from .component_types import Component from .embed import Embed from .emoji import PartialEmoji from .member import Member, UserWithMember @@ -39,7 +39,7 @@ from .user import User if TYPE_CHECKING: - from .interactions import InteractionMetadata, MessageInteraction + from .interactions import InteractionMetadata from typing_extensions import NotRequired, TypedDict @@ -81,6 +81,7 @@ class Attachment(TypedDict): waveform: NotRequired[str] flags: NotRequired[int] title: NotRequired[str] + ephemeral: NotRequired[bool] MessageActivityType = Literal[1, 2, 3, 5] @@ -150,7 +151,6 @@ class Message(TypedDict): flags: NotRequired[int] sticker_items: NotRequired[list[StickerItem]] referenced_message: NotRequired[Message | None] - interaction: NotRequired[MessageInteraction] interaction_metadata: NotRequired[InteractionMetadata] components: NotRequired[list[Component]] thread: NotRequired[Thread | None] diff --git a/discord/types/partial_components.py b/discord/types/partial_components.py new file mode 100644 index 0000000000..9c2d4fee8a --- /dev/null +++ b/discord/types/partial_components.py @@ -0,0 +1,115 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from typing import Literal, TypeAlias + +from typing_extensions import TypedDict + +from .snowflake import Snowflake + +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] +TextInputStyle = Literal[1, 2] +SeparatorSpacingSize = Literal[1, 2] + + +class BasePartialComponent(TypedDict): + type: ComponentType + id: int + + +class PartialButton(BasePartialComponent): + type: Literal[2] # pyright: ignore[reportIncompatibleVariableOverride] + custom_id: str | None + + +class PartialStringSelectMenu(BasePartialComponent): + type: Literal[3] # pyright: ignore[reportIncompatibleVariableOverride] + values: list[str] + custom_id: str + + +class PartialUserSelectMenu(BasePartialComponent): + type: Literal[5] # pyright: ignore[reportIncompatibleVariableOverride] + values: list[Snowflake] + custom_id: str + + +class PartialRoleSelectMenu(BasePartialComponent): + type: Literal[6] # pyright: ignore[reportIncompatibleVariableOverride] + values: list[Snowflake] + custom_id: str + + +class PartialMentionableSelectMenu(BasePartialComponent): + type: Literal[7] # pyright: ignore[reportIncompatibleVariableOverride] + values: list[Snowflake] + custom_id: str + + +class PartialChannelSelectMenu(BasePartialComponent): + type: Literal[8] # pyright: ignore[reportIncompatibleVariableOverride] + values: list[Snowflake] + custom_id: str + + +class PartialTextInput(BasePartialComponent): + type: Literal[4] # pyright: ignore[reportIncompatibleVariableOverride] + value: str + custom_id: str + + +class PartialTextDisplay(BasePartialComponent): + type: Literal[10] # pyright: ignore[reportIncompatibleVariableOverride] + value: str + + +class PartialFileUpload(BasePartialComponent): + type: Literal[19] # pyright: ignore[reportIncompatibleVariableOverride] + values: list[Snowflake] + custom_id: str + + +AllowedPartialLabelComponents: TypeAlias = "PartialStringSelectMenu | PartialTextInput | PartialFileUpload" + + +class PartialLabel(BasePartialComponent): + type: Literal[18] # pyright: ignore[reportIncompatibleVariableOverride] + component: AllowedPartialLabelComponents + + +PartialComponent: TypeAlias = ( + PartialStringSelectMenu + | PartialUserSelectMenu + | PartialButton + | PartialRoleSelectMenu + | PartialMentionableSelectMenu + | PartialChannelSelectMenu + | PartialTextInput + | PartialLabel + | PartialTextDisplay + | PartialFileUpload +) diff --git a/discord/ui/__init__.py b/discord/ui/__init__.py deleted file mode 100644 index 473ac45563..0000000000 --- a/discord/ui/__init__.py +++ /dev/null @@ -1,23 +0,0 @@ -""" -discord.ui -~~~~~~~~~~ - -UI Kit helper for the Discord API - -:copyright: (c) 2015-2021 Rapptz & (c) 2021-present Pycord Development -:license: MIT, see LICENSE for more details. -""" - -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 deleted file mode 100644 index cbdacfcd6d..0000000000 --- a/discord/ui/button.py +++ /dev/null @@ -1,344 +0,0 @@ -""" -The MIT License (MIT) - -Copyright (c) 2015-2021 Rapptz -Copyright (c) 2021-present Pycord Development - -Permission is hereby granted, free of charge, to any person obtaining a -copy of this software and associated documentation files (the "Software"), -to deal in the Software without restriction, including without limitation -the rights to use, copy, modify, merge, publish, distribute, sublicense, -and/or sell copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. -""" - -from __future__ import annotations - -import inspect -import os -from typing import TYPE_CHECKING, Callable, TypeVar - -from ..components import Button as ButtonComponent -from ..enums import ButtonStyle, ComponentType -from ..partial_emoji import PartialEmoji, _EmojiTag -from .item import Item, ItemCallbackType - -__all__ = ( - "Button", - "button", -) - -if TYPE_CHECKING: - from ..emoji import AppEmoji, GuildEmoji - from .view import View - -B = TypeVar("B", bound="Button") -V = TypeVar("V", bound="View", covariant=True) - - -class Button(Item[V]): - """Represents a UI button. - - .. versionadded:: 2.0 - - Parameters - ---------- - style: :class:`discord.ButtonStyle` - The style of the button. - custom_id: Optional[:class:`str`] - The ID of the button that gets received during an interaction. - If this button is for a URL, it does not have a custom ID. - url: Optional[:class:`str`] - The URL this button sends you to. - disabled: :class:`bool` - Whether the button is disabled or not. - label: Optional[:class:`str`] - The label of the button, if any. Maximum of 80 chars. - emoji: Optional[Union[:class:`.PartialEmoji`, :class:`GuildEmoji`, :class:`AppEmoji`, :class:`str`]] - The emoji of the button, if available. - sku_id: Optional[Union[:class:`int`]] - The ID of the SKU this button refers to. - row: Optional[:class:`int`] - The relative row this button belongs to. A Discord component can only have 5 - rows. By default, items are arranged automatically into those 5 rows. If you'd - like to control the relative positioning of the row then passing an index is advised. - For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic - ordering. The row number must be between 0 and 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, ...] = ( - "style", - "url", - "disabled", - "label", - "emoji", - "sku_id", - "row", - "custom_id", - "id", - ) - - def __init__( - self, - *, - style: ButtonStyle = ButtonStyle.secondary, - label: str | None = None, - disabled: bool = False, - custom_id: str | None = None, - url: str | None = None, - 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: - raise ValueError("label must be 80 characters or fewer") - if custom_id is not None and len(str(custom_id)) > 100: - raise ValueError("custom_id must be 100 characters or fewer") - if custom_id is not None and url is not None: - raise TypeError("cannot mix both url and custom_id with Button") - if sku_id is not None and url is not None: - raise TypeError("cannot mix both url and sku_id with Button") - if custom_id is not None and sku_id is not None: - raise TypeError("cannot mix both sku_id and custom_id with Button") - - if not isinstance(custom_id, str) and custom_id is not None: - raise TypeError(f"expected custom_id to be str, not {custom_id.__class__.__name__}") - - self._provided_custom_id = custom_id is not None - if url is None and custom_id is None and sku_id is None: - custom_id = os.urandom(16).hex() - - if url is not None: - style = ButtonStyle.link - if sku_id is not None: - style = ButtonStyle.premium - - if emoji is not None: - if isinstance(emoji, str): - emoji = PartialEmoji.from_str(emoji) - elif isinstance(emoji, _EmojiTag): - emoji = emoji._to_partial() - else: - raise TypeError( - f"expected emoji to be str, GuildEmoji, AppEmoji, or PartialEmoji not {emoji.__class__}" - ) - - self._underlying = ButtonComponent._raw_construct( - type=ComponentType.button, - custom_id=custom_id, - url=url, - disabled=disabled, - label=label, - style=style, - emoji=emoji, - sku_id=sku_id, - id=id, - ) - self.row = row - - @property - def style(self) -> ButtonStyle: - """The style of the button.""" - return self._underlying.style - - @style.setter - def style(self, value: ButtonStyle): - self._underlying.style = value - - @property - def custom_id(self) -> str | None: - """The ID of the button that gets received during an interaction. - - If this button is for a URL, it does not have a custom ID. - """ - return self._underlying.custom_id - - @custom_id.setter - def custom_id(self, value: str | None): - if value is not None and not isinstance(value, str): - raise TypeError("custom_id must be None or str") - 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: - """The URL this button sends you to.""" - return self._underlying.url - - @url.setter - def url(self, value: str | None): - if value is not None and not isinstance(value, str): - raise TypeError("url must be None or str") - self._underlying.url = value - - @property - def disabled(self) -> bool: - """Whether the button is disabled or not.""" - return self._underlying.disabled - - @disabled.setter - def disabled(self, value: bool): - self._underlying.disabled = bool(value) - - @property - def label(self) -> str | None: - """The label of the button, if available.""" - return self._underlying.label - - @label.setter - def label(self, value: str | None): - if value and len(str(value)) > 80: - raise ValueError("label must be 80 characters or fewer") - self._underlying.label = str(value) if value is not None else value - - @property - def emoji(self) -> PartialEmoji | None: - """The emoji of the button, if available.""" - return self._underlying.emoji - - @emoji.setter - def emoji(self, value: str | GuildEmoji | AppEmoji | PartialEmoji | None): # type: ignore - if value is None: - self._underlying.emoji = None - elif isinstance(value, str): - self._underlying.emoji = PartialEmoji.from_str(value) - elif isinstance(value, _EmojiTag): - self._underlying.emoji = value._to_partial() - else: - raise TypeError(f"expected str, GuildEmoji, AppEmoji, or PartialEmoji, received {value.__class__} instead") - - @property - def sku_id(self) -> int | None: - """The ID of the SKU this button refers to.""" - return self._underlying.sku_id - - @sku_id.setter - def sku_id(self, value: int | None): # type: ignore - if value is None: - self._underlying.sku_id = None - elif isinstance(value, int): - self._underlying.sku_id = value - else: - raise TypeError(f"expected int or None, received {value.__class__} instead") - - @classmethod - def from_component(cls: type[B], button: ButtonComponent) -> B: - return cls( - style=button.style, - label=button.label, - disabled=button.disabled, - custom_id=button.custom_id, - url=button.url, - emoji=button.emoji, - sku_id=button.sku_id, - row=None, - id=button.id, - ) - - @property - def type(self) -> ComponentType: - return self._underlying.type - - def to_component_dict(self): - return self._underlying.to_dict() - - 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 - return super().is_persistent() - - def refresh_component(self, button: ButtonComponent) -> None: - self._underlying = button - - -def button( - *, - label: str | None = None, - custom_id: str | None = None, - disabled: bool = False, - 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. - - The function being decorated should have three parameters, ``self`` representing - the :class:`discord.ui.View`, the :class:`discord.ui.Button` being pressed and - the :class:`discord.Interaction` you receive. - - .. note:: - - Premium and link buttons cannot be created with this decorator. Consider - creating a :class:`Button` object manually instead. These types of - buttons do not have a callback associated since Discord doesn't handle - them when clicked. - - Parameters - ---------- - label: Optional[:class:`str`] - The label of the button, if any. - custom_id: Optional[:class:`str`] - The ID of the button that gets received during an interaction. - It is recommended not to set this parameter to prevent conflicts. - style: :class:`.ButtonStyle` - The style of the button. Defaults to :attr:`.ButtonStyle.grey`. - disabled: :class:`bool` - Whether the button is disabled or not. Defaults to ``False``. - emoji: Optional[Union[:class:`str`, :class:`GuildEmoji`, :class:`AppEmoji`, :class:`.PartialEmoji`]] - The emoji of the button. This can be in string form or a :class:`.PartialEmoji` - or a full :class:`GuildEmoji` or :class:`AppEmoji`. - row: Optional[:class:`int`] - The relative row this button belongs to. A Discord component can only have 5 - rows. By default, items are arranged automatically into those 5 rows. If you'd - like to control the relative positioning of the row then passing an index is advised. - For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic - ordering. The row number must be between 0 and 4 (i.e. zero indexed). - """ - - def decorator(func: ItemCallbackType) -> ItemCallbackType: - if not inspect.iscoroutinefunction(func): - raise TypeError("button function must be a coroutine function") - - func.__discord_ui_model_type__ = Button - func.__discord_ui_model_kwargs__ = { - "style": style, - "custom_id": custom_id, - "url": None, - "disabled": disabled, - "label": label, - "emoji": emoji, - "row": row, - "id": id, - } - return func - - return decorator diff --git a/discord/ui/container.py b/discord/ui/container.py deleted file mode 100644 index d1241c54a7..0000000000 --- a/discord/ui/container.py +++ /dev/null @@ -1,415 +0,0 @@ -from __future__ import annotations - -from functools import partial -from typing import TYPE_CHECKING, ClassVar, Iterator, TypeVar - -from ..colour import Colour -from ..components import ActionRow, _component_factory -from ..components import Container as ContainerComponent -from ..enums import ComponentType, SeparatorSpacingSize -from ..utils import find -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(f"Expected discord.Colour, int, or None but received {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 - flattened = [] - for c in component.components: - if isinstance(c, ActionRow): - flattened += c.children - else: - flattened.append(c) - for i, y in enumerate(flattened): - x = self.items[i] - x.refresh_component(y) - - 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 # noqa: PLC0415 - - 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 deleted file mode 100644 index dc06b83648..0000000000 --- a/discord/ui/file.py +++ /dev/null @@ -1,112 +0,0 @@ -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__() - - self.file = UnfurledMediaItem(url) - - self._underlying = FileComponent._raw_construct( - type=ComponentType.file, - id=id, - file=self.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 refresh_component(self, component: FileComponent) -> None: - original = self._underlying.file - component.file._static_url = original._static_url - self._underlying = component - - 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 deleted file mode 100644 index d97da50d88..0000000000 --- a/discord/ui/input_text.py +++ /dev/null @@ -1,230 +0,0 @@ -from __future__ import annotations - -import os -from typing import TYPE_CHECKING - -from ..components import InputText as InputTextComponent -from ..enums import ComponentType, InputTextStyle - -__all__ = ("InputText",) - -if TYPE_CHECKING: - from ..types.components import InputText as InputTextComponentPayload - - -class InputText: - """Represents a UI text input field. - - .. versionadded:: 2.0 - - Parameters - ---------- - style: :class:`~discord.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. - label: :class:`str` - The label for the input text field. - Must be 45 characters or fewer. - placeholder: Optional[:class:`str`] - The placeholder text that is shown if nothing is selected, if any. - Must be 100 characters or fewer. - min_length: Optional[:class:`int`] - The minimum number of characters that must be entered. - Defaults to 0 and must be less than 4000. - max_length: Optional[:class:`int`] - The maximum number of characters that can be entered. - Must be between 1 and 4000. - required: Optional[:class:`bool`] - Whether the input text field is required or not. Defaults to ``True``. - value: Optional[:class:`str`] - Pre-fills the input text field with this value. - Must be 4000 characters or fewer. - row: Optional[:class:`int`] - The relative row this input text field belongs to. A modal dialog can only have 5 - rows. By default, items are arranged automatically into those 5 rows. If you'd - like to control the relative positioning of the row then passing an index is advised. - For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic - ordering. The row number must be between 0 and 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, - *, - style: InputTextStyle = InputTextStyle.short, - custom_id: str | None = None, - label: str, - placeholder: str | None = None, - min_length: int | None = None, - max_length: int | None = None, - required: bool | None = True, - value: str | None = None, - row: int | None = None, - id: int | None = None, - ): - super().__init__() - if len(str(label)) > 45: - raise ValueError("label must be 45 characters or fewer") - if min_length and (min_length < 0 or min_length > 4000): - raise ValueError("min_length must be between 0 and 4000") - if max_length and (max_length < 0 or max_length > 4000): - raise ValueError("max_length must be between 1 and 4000") - if value and len(str(value)) > 4000: - raise ValueError("value must be 4000 characters or fewer") - if placeholder and len(str(placeholder)) > 100: - raise ValueError("placeholder must be 100 characters or fewer") - if not isinstance(custom_id, str) and custom_id is not None: - raise TypeError(f"expected custom_id to be str, not {custom_id.__class__.__name__}") - custom_id = os.urandom(16).hex() if custom_id is None else custom_id - - self._underlying = InputTextComponent._raw_construct( - type=ComponentType.input_text, - style=style, - custom_id=custom_id, - label=label, - placeholder=placeholder, - min_length=min_length, - 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 - - @property - 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): - raise TypeError(f"style must be of type InputTextStyle not {value.__class__.__name__}") - self._underlying.style = value - - @property - def custom_id(self) -> str: - """The ID of the input text field that gets received during an interaction.""" - return self._underlying.custom_id - - @custom_id.setter - def custom_id(self, value: str): - if not isinstance(value, str): - raise TypeError(f"custom_id must be None or str not {value.__class__.__name__}") - self._underlying.custom_id = value - - @property - def label(self) -> str: - """The label of the input text field.""" - return self._underlying.label - - @label.setter - def label(self, value: str): - if not isinstance(value, str): - raise TypeError(f"label should be str not {value.__class__.__name__}") - if len(value) > 45: - raise ValueError("label must be 45 characters or fewer") - self._underlying.label = value - - @property - def placeholder(self) -> str | None: - """The placeholder text that is shown before anything is entered, if any.""" - return self._underlying.placeholder - - @placeholder.setter - def placeholder(self, value: str | None): - if value and not isinstance(value, str): - raise TypeError(f"placeholder must be None or str not {value.__class__.__name__}") # type: ignore - if value and len(value) > 100: - raise ValueError("placeholder must be 100 characters or fewer") - self._underlying.placeholder = value - - @property - def min_length(self) -> int | None: - """The minimum number of characters that must be entered. Defaults to 0.""" - return self._underlying.min_length - - @min_length.setter - def min_length(self, value: int | None): - if value and not isinstance(value, int): - raise TypeError(f"min_length must be None or int not {value.__class__.__name__}") # type: ignore - if value and (value < 0 or value) > 4000: - raise ValueError("min_length must be between 0 and 4000") - self._underlying.min_length = value - - @property - def max_length(self) -> int | None: - """The maximum number of characters that can be entered.""" - return self._underlying.max_length - - @max_length.setter - def max_length(self, value: int | None): - if value and not isinstance(value, int): - raise TypeError(f"min_length must be None or int not {value.__class__.__name__}") # type: ignore - if value and (value <= 0 or value > 4000): - raise ValueError("max_length must be between 1 and 4000") - self._underlying.max_length = value - - @property - def required(self) -> bool | None: - """Whether the input text field is required or not. Defaults to ``True``.""" - return self._underlying.required - - @required.setter - def required(self, value: bool | None): - if not isinstance(value, bool): - raise TypeError(f"required must be bool not {value.__class__.__name__}") # type: ignore - self._underlying.required = bool(value) - - @property - def value(self) -> str | None: - """The value entered in the text field.""" - if self._input_value is not False: - # only False on init, otherwise the value was either set or cleared - return self._input_value # type: ignore - return self._underlying.value - - @value.setter - def value(self, value: str | None): - if value and not isinstance(value, str): - raise TypeError(f"value must be None or str not {value.__class__.__name__}") # type: ignore - if value and len(str(value)) > 4000: - raise ValueError("value must be 4000 characters or fewer") - self._underlying.value = value - - @property - def width(self) -> int: - return 5 - - def to_component_dict(self) -> InputTextComponentPayload: - return self._underlying.to_dict() - - def refresh_state(self, data) -> None: - self._input_value = data["value"] diff --git a/discord/ui/item.py b/discord/ui/item.py deleted file mode 100644 index fd007e6d2d..0000000000 --- a/discord/ui/item.py +++ /dev/null @@ -1,203 +0,0 @@ -""" -The MIT License (MIT) - -Copyright (c) 2015-2021 Rapptz -Copyright (c) 2021-present Pycord Development - -Permission is hereby granted, free of charge, to any person obtaining a -copy of this software and associated documentation files (the "Software"), -to deal in the Software without restriction, including without limitation -the rights to use, copy, modify, merge, publish, distribute, sublicense, -and/or sell copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Any, Callable, Coroutine, Generic, TypeVar - -from ..interactions import Interaction - -__all__ = ("Item",) - -if TYPE_CHECKING: - from ..components import Component - from ..enums import ComponentType - from .view import View - -I = TypeVar("I", bound="Item") -V = TypeVar("V", bound="View", covariant=True) -ItemCallbackType = Callable[[Any, I, Interaction], Coroutine[Any, Any, Any]] - - -class Item(Generic[V]): - """Represents the base UI item that all UI components inherit from. - - 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",) - - 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 - # it might not be provided by the library user. However, this edge case doesn't - # 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: - self._underlying = component - - def refresh_state(self, interaction: Interaction) -> None: - return None - - @classmethod - def from_component(cls: type[I], component: Component) -> I: - return cls() - - @property - def type(self) -> ComponentType: - raise NotImplementedError - - def is_dispatchable(self) -> bool: - return False - - def is_storable(self) -> bool: - return False - - def is_persistent(self) -> bool: - return not self.is_dispatchable() or self._provided_custom_id - - def copy_text(self) -> str: - return "" - - 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 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 39 (inclusive), or ``None`` to indicate - that no specific row is set. - - Returns - ------- - Optional[:class:`int`] - The row position of the item, or ``None`` if not explicitly set. - - Raises - ------ - ValueError - If the row value is not ``None`` and is outside the range [0, 39]. - """ - return self._row - - @row.setter - def row(self, value: int | None): - if value is None: - self._row = None - elif 39 > value >= 0: - self._row = value - else: - raise ValueError("row cannot be negative or greater than or equal to 39") - - @property - def width(self) -> int: - """Gets the width of the item in the UI layout. - - The width determines how much horizontal space this item occupies within its row. - - Returns - ------- - :class:`int` - The width of the item. Defaults to 1. - """ - 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. - - The view refers to the container that holds this item. This is typically set - automatically when the item is added to a view. - - Returns - ------- - Optional[:class:`View`] - The parent view of this item, or ``None`` if the item is not attached to any view. - """ - return self._view - - async def callback(self, interaction: Interaction): - """|coro| - - The callback associated with this UI item. - - This can be overridden by subclasses. - - Parameters - ---------- - interaction: :class:`.Interaction` - The interaction that triggered this UI item. - """ diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py deleted file mode 100644 index b50daef71c..0000000000 --- a/discord/ui/media_gallery.py +++ /dev/null @@ -1,127 +0,0 @@ -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 deleted file mode 100644 index f59d7bf962..0000000000 --- a/discord/ui/modal.py +++ /dev/null @@ -1,344 +0,0 @@ -from __future__ import annotations - -import asyncio -import os -import sys -import time -from functools import partial -from itertools import groupby -from typing import TYPE_CHECKING, Any, Callable - -from .input_text import InputText - -__all__ = ( - "Modal", - "ModalStore", -) - - -if TYPE_CHECKING: - from typing_extensions import Self - - from ..interactions import Interaction - from ..state import ConnectionState - - -class Modal: - """Represents a UI Modal dialog. - - This object must be inherited to create a UI within Discord. - - .. versionadded:: 2.0 - - Parameters - ---------- - children: :class:`InputText` - The initial InputText fields that are displayed in the modal dialog. - title: :class:`str` - The title of the modal dialog. - Must be 45 characters or fewer. - custom_id: Optional[:class:`str`] - The ID of the modal dialog that gets received during an interaction. - Must be 100 characters or fewer. - timeout: Optional[:class:`float`] - Timeout in seconds from last interaction with the UI before no longer accepting input. - If ``None`` then there is no timeout. - """ - - __item_repr_attributes__: tuple[str, ...] = ( - "title", - "children", - "timeout", - ) - - def __init__( - self, - *children: InputText, - title: str, - custom_id: str | None = None, - timeout: float | None = None, - ) -> None: - self.timeout: float | None = timeout - if not isinstance(custom_id, str) and custom_id is not None: - raise TypeError(f"expected custom_id to be str, not {custom_id.__class__.__name__}") - self._custom_id: str | None = custom_id or os.urandom(16).hex() - if len(title) > 45: - raise ValueError("title must be 45 characters or fewer") - self._title = title - self._children: list[InputText] = list(children) - self._weights = _ModalWeights(self._children) - loop = asyncio.get_running_loop() - self._stopped: asyncio.Future[bool] = loop.create_future() - self.__cancel_callback: Callable[[Modal], None] | None = None - self.__timeout_expiry: float | None = None - 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: - loop = asyncio.get_running_loop() - if self.__timeout_task is not None: - self.__timeout_task.cancel() - - self.__timeout_expiry = time.monotonic() + self.timeout - self.__timeout_task = loop.create_task(self.__timeout_task_impl()) - - async def __timeout_task_impl(self) -> None: - while True: - # Guard just in case someone changes the value of the timeout at runtime - if self.timeout is None: - return - - if self.__timeout_expiry is None: - return self._dispatch_timeout() - - # Check if we've elapsed our currently set timeout - now = time.monotonic() - if now >= self.__timeout_expiry: - return self._dispatch_timeout() - - # Wait N seconds to see if timeout data has been refreshed - await asyncio.sleep(self.__timeout_expiry - now) - - @property - def _expires_at(self) -> float | None: - if self.timeout: - return time.monotonic() + self.timeout - return None - - def _dispatch_timeout(self): - if self._stopped.done(): - return - - self._stopped.set_result(True) - self.loop.create_task(self.on_timeout(), name=f"discord-ui-view-timeout-{self.custom_id}") - - @property - def title(self) -> str: - """The title of the modal dialog.""" - return self._title - - @title.setter - def title(self, value: str): - if len(value) > 45: - raise ValueError("title must be 45 characters or fewer") - if not isinstance(value, str): - raise TypeError(f"expected title to be str, not {value.__class__.__name__}") - self._title = value - - @property - def children(self) -> list[InputText]: - """The child components associated with the modal dialog.""" - return self._children - - @children.setter - def children(self, value: list[InputText]): - for item in value: - if not isinstance(item, InputText): - raise TypeError(f"all Modal children must be InputText, not {item.__class__.__name__}") - self._weights = _ModalWeights(self._children) - self._children = value - - @property - def custom_id(self) -> str: - """The ID of the modal dialog that gets received during an interaction.""" - return self._custom_id - - @custom_id.setter - def custom_id(self, value: str): - if not isinstance(value, str): - raise TypeError(f"expected custom_id to be str, not {value.__class__.__name__}") - if len(value) > 100: - raise ValueError("custom_id must be 100 characters or fewer") - self._custom_id = value - - async def callback(self, interaction: Interaction): - """|coro| - - The coroutine that is called when the modal dialog is submitted. - Should be overridden to handle the values submitted by the user. - - Parameters - ---------- - interaction: :class:`~discord.Interaction` - The interaction that submitted the modal dialog. - """ - self.stop() - - def to_components(self) -> list[dict[str, Any]]: - def key(item: InputText) -> int: - return item._rendered_row or 0 - - children = sorted(self._children, key=key) - components: list[dict[str, Any]] = [] - for _, group in groupby(children, key=key): - children = [item.to_component_dict() for item in group] - if not children: - continue - - components.append( - { - "type": 1, - "components": children, - } - ) - - return components - - def add_item(self, item: InputText) -> Self: - """Adds an InputText component to the modal dialog. - - Parameters - ---------- - item: :class:`InputText` - The item to add to the modal dialog - """ - - if len(self._children) > 5: - raise ValueError("You can only have up to 5 items in a modal dialog.") - - if not isinstance(item, InputText): - raise TypeError(f"expected InputText not {item.__class__!r}") - - self._weights.add_item(item) - self._children.append(item) - return self - - def remove_item(self, item: InputText) -> Self: - """Removes an InputText component from the modal dialog. - - Parameters - ---------- - item: :class:`InputText` - The item to remove from the modal dialog. - """ - try: - self._children.remove(item) - except ValueError: - pass - return self - - def stop(self) -> None: - """Stops listening to interaction events from the modal dialog.""" - if not self._stopped.done(): - self._stopped.set_result(True) - self.__timeout_expiry = None - if self.__timeout_task is not None: - self.__timeout_task.cancel() - self.__timeout_task = None - - async def wait(self) -> bool: - """Waits for the modal dialog to be submitted.""" - return await self._stopped - - def to_dict(self): - return { - "title": self.title, - "custom_id": self.custom_id, - "components": self.to_components(), - } - - async def on_error(self, error: Exception, interaction: Interaction) -> None: - """|coro| - - A callback that is called when the modal's callback fails with an error. - - The default implementation prints the traceback to stderr. - - Parameters - ---------- - error: :class:`Exception` - The exception that was raised. - interaction: :class:`~discord.Interaction` - The interaction that led to the failure. - """ - interaction.client.dispatch("modal_error", error, interaction) - - async def on_timeout(self) -> None: - """|coro| - - A callback that is called when a modal's timeout elapses without being explicitly stopped. - """ - - -class _ModalWeights: - __slots__ = ("weights",) - - def __init__(self, children: list[InputText]): - self.weights: list[int] = [0, 0, 0, 0, 0] - - key = lambda i: sys.maxsize if i.row is None else i.row - children = sorted(children, key=key) - for _, group in groupby(children, key=key): - for item in group: - self.add_item(item) - - def find_open_space(self, item: InputText) -> int: - for index, weight in enumerate(self.weights): - if weight + item.width <= 5: - return index - - raise ValueError("could not find open space for item") - - def add_item(self, item: InputText) -> None: - if item.row is not None: - total = self.weights[item.row] + item.width - if total > 5: - raise ValueError(f"item would not fit at row {item.row} ({total} > 5 width)") - self.weights[item.row] = total - item._rendered_row = item.row - else: - index = self.find_open_space(item) - self.weights[index] += item.width - item._rendered_row = index - - def remove_item(self, item: InputText) -> None: - if item._rendered_row is not None: - self.weights[item._rendered_row] -= item.width - item._rendered_row = None - - def clear(self) -> None: - self.weights = [0, 0, 0, 0, 0] - - -class ModalStore: - def __init__(self, state: ConnectionState) -> None: - # (user_id, custom_id) : Modal - self._modals: dict[tuple[int, str], Modal] = {} - self._state: ConnectionState = state - - def add_modal(self, modal: Modal, user_id: int): - self._modals[(user_id, modal.custom_id)] = modal - modal._start_listening_from_store(self) - - def remove_modal(self, modal: Modal, user_id): - modal.stop() - self._modals.pop((user_id, modal.custom_id)) - - async def dispatch(self, user_id: int, custom_id: str, interaction: Interaction): - key = (user_id, custom_id) - value = self._modals.get(key) - if value is None: - return - interaction.modal = value - - try: - components = [ - component - for parent_component in interaction.data["components"] - for component in parent_component["components"] - ] - for component in components: - for child in value.children: - if child.custom_id == component["custom_id"]: # type: ignore - child.refresh_state(component) - break - await value.callback(interaction) - self.remove_modal(value, user_id) - except Exception as e: - return await value.on_error(e, interaction) diff --git a/discord/ui/section.py b/discord/ui/section.py deleted file mode 100644 index b9edcae5de..0000000000 --- a/discord/ui/section.py +++ /dev/null @@ -1,322 +0,0 @@ -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 -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, strict=False): - 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 # noqa: PLC0415 - - 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 deleted file mode 100644 index 43cabd249c..0000000000 --- a/discord/ui/select.py +++ /dev/null @@ -1,678 +0,0 @@ -""" -The MIT License (MIT) - -Copyright (c) 2015-2021 Rapptz -Copyright (c) 2021-present Pycord Development - -Permission is hereby granted, free of charge, to any person obtaining a -copy of this software and associated documentation files (the "Software"), -to deal in the Software without restriction, including without limitation -the rights to use, copy, modify, merge, publish, distribute, sublicense, -and/or sell copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. -""" - -from __future__ import annotations - -import inspect -import os -from typing import TYPE_CHECKING, Callable, TypeVar - -from discord import utils - -from ..channel import _threaded_guild_channel_factory -from ..components import SelectMenu, SelectOption -from ..emoji import AppEmoji, GuildEmoji -from ..enums import ChannelType, ComponentType -from ..errors import InvalidArgument -from ..interactions import Interaction -from ..member import Member -from ..partial_emoji import PartialEmoji -from ..role import Role -from ..threads import Thread -from ..user import User -from ..utils import MISSING -from .item import Item, ItemCallbackType - -__all__ = ( - "Select", - "select", - "string_select", - "user_select", - "role_select", - "mentionable_select", - "channel_select", -) - -if TYPE_CHECKING: - from typing_extensions import Self - - from ..abc import GuildChannel - from ..types.components import SelectMenu as SelectMenuPayload - from ..types.interactions import ComponentInteractionData - from .view import View - -S = TypeVar("S", bound="Select") -V = TypeVar("V", bound="View", covariant=True) - - -class Select(Item[V]): - """Represents a UI select menu. - - This is usually represented as a drop down menu. - - In order to get the selected items that the user has chosen, use :attr:`Select.values`. - - .. versionadded:: 2.0 - - .. versionchanged:: 2.3 - - Added support for :attr:`discord.ComponentType.string_select`, :attr:`discord.ComponentType.user_select`, - :attr:`discord.ComponentType.role_select`, :attr:`discord.ComponentType.mentionable_select`, - and :attr:`discord.ComponentType.channel_select`. - - Parameters - ---------- - select_type: :class:`discord.ComponentType` - The type of select to create. Must be one of - :attr:`discord.ComponentType.string_select`, :attr:`discord.ComponentType.user_select`, - :attr:`discord.ComponentType.role_select`, :attr:`discord.ComponentType.mentionable_select`, - or :attr:`discord.ComponentType.channel_select`. - custom_id: :class:`str` - The ID of the select menu that gets received during an interaction. - If not given then one is generated for you. - placeholder: Optional[:class:`str`] - The placeholder text that is shown if nothing is selected, if any. - min_values: :class:`int` - The minimum number of items that must be chosen for this select menu. - Defaults to 1 and must be between 1 and 25. - max_values: :class:`int` - The maximum number of items that must be chosen for this select menu. - Defaults to 1 and must be between 1 and 25. - options: List[:class:`discord.SelectOption`] - A list of options that can be selected in this menu. - Only valid for selects of type :attr:`discord.ComponentType.string_select`. - channel_types: List[:class:`discord.ChannelType`] - A list of channel types that can be selected in this menu. - Only valid for selects of type :attr:`discord.ComponentType.channel_select`. - disabled: :class:`bool` - Whether the select is disabled or not. - row: Optional[:class:`int`] - The relative row this select menu belongs to. A Discord component can only have 5 - rows. By default, items are arranged automatically into those 5 rows. If you'd - like to control the relative positioning of the row then passing an index is advised. - For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic - ordering. The row number must be between 0 and 4 (i.e. zero indexed). - id: Optional[:class:`int`] - The select menu's ID. - """ - - __item_repr_attributes__: tuple[str, ...] = ( - "type", - "placeholder", - "min_values", - "max_values", - "options", - "channel_types", - "disabled", - "custom_id", - "id", - ) - - def __init__( - self, - select_type: ComponentType = ComponentType.string_select, - *, - custom_id: str | None = None, - placeholder: str | None = None, - min_values: int = 1, - max_values: int = 1, - options: list[SelectOption] | None = None, - 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") - if channel_types and select_type is not ComponentType.channel_select: - raise InvalidArgument("channel_types parameter is only valid for channel selects") - super().__init__() - self._selected_values: list[str] = [] - self._interaction: Interaction | None = None - if min_values < 0 or min_values > 25: - raise ValueError("min_values must be between 0 and 25") - if max_values < 1 or max_values > 25: - raise ValueError("max_values must be between 1 and 25") - if placeholder and len(placeholder) > 150: - raise ValueError("placeholder must be 150 characters or fewer") - if not isinstance(custom_id, str) and custom_id is not None: - raise TypeError(f"expected custom_id to be str, not {custom_id.__class__.__name__}") - - self._provided_custom_id = custom_id is not None - custom_id = os.urandom(16).hex() if custom_id is None else custom_id - self._underlying: SelectMenu = SelectMenu._raw_construct( - custom_id=custom_id, - type=select_type, - placeholder=placeholder, - min_values=min_values, - max_values=max_values, - disabled=disabled, - options=options or [], - channel_types=channel_types or [], - id=id, - ) - self.row = row - - @property - def custom_id(self) -> str: - """The ID of the select menu that gets received during an interaction.""" - return self._underlying.custom_id - - @custom_id.setter - def custom_id(self, value: str): - if not isinstance(value, str): - raise TypeError("custom_id must be None or 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: - """The placeholder text that is shown if nothing is selected, if any.""" - return self._underlying.placeholder - - @placeholder.setter - def placeholder(self, value: str | None): - if value is not None and not isinstance(value, str): - raise TypeError("placeholder must be None or str") - if value and len(value) > 150: - raise ValueError("placeholder must be 150 characters or fewer") - - self._underlying.placeholder = value - - @property - def min_values(self) -> int: - """The minimum number of items that must be chosen for this select menu.""" - return self._underlying.min_values - - @min_values.setter - def min_values(self, value: int): - if value < 0 or value > 25: - raise ValueError("min_values must be between 0 and 25") - self._underlying.min_values = int(value) - - @property - def max_values(self) -> int: - """The maximum number of items that must be chosen for this select menu.""" - return self._underlying.max_values - - @max_values.setter - def max_values(self, value: int): - if value < 1 or value > 25: - raise ValueError("max_values must be between 1 and 25") - self._underlying.max_values = int(value) - - @property - def disabled(self) -> bool: - """Whether the select is disabled or not.""" - return self._underlying.disabled - - @disabled.setter - def disabled(self, value: bool): - self._underlying.disabled = bool(value) - - @property - def channel_types(self) -> list[ChannelType]: - """A list of channel types that can be selected in this menu.""" - return self._underlying.channel_types - - @channel_types.setter - def channel_types(self, value: list[ChannelType]): - if self._underlying.type is not ComponentType.channel_select: - raise InvalidArgument("channel_types can only be set on channel selects") - self._underlying.channel_types = value - - @property - def options(self) -> list[SelectOption]: - """A list of options that can be selected in this menu.""" - return self._underlying.options - - @options.setter - def options(self, value: list[SelectOption]): - if self._underlying.type is not ComponentType.string_select: - raise InvalidArgument("options can only be set on string selects") - if not isinstance(value, list): - raise TypeError("options must be a list of SelectOption") - if not all(isinstance(obj, SelectOption) for obj in value): - raise TypeError("all list items must subclass SelectOption") - - self._underlying.options = value - - def add_option( - self, - *, - label: str, - value: str | utils.Undefined = MISSING, - 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 - :meth:`append_option` method instead. - - Parameters - ---------- - label: :class:`str` - The label of the option. This is displayed to users. - Can only be up to 100 characters. - value: :class:`str` - The value of the option. This is not displayed to users. - If not given, defaults to the label. Can only be up to 100 characters. - description: Optional[:class:`str`] - An additional description of the option, if any. - Can only be up to 100 characters. - emoji: Optional[Union[:class:`str`, :class:`GuildEmoji`, :class:`AppEmoji`, :class:`.PartialEmoji`]] - The emoji of the option, if available. This can either be a string representing - the custom or unicode emoji or an instance of :class:`.PartialEmoji`, :class:`GuildEmoji`, or :class:`AppEmoji`. - default: :class:`bool` - Whether this option is selected by default. - - Raises - ------ - ValueError - The number of options exceeds 25. - """ - if self._underlying.type is not ComponentType.string_select: - raise Exception("options can only be set on string selects") - - option = SelectOption( - label=label, - value=value, - description=description, - emoji=emoji, - default=default, - ) - - return self.append_option(option) - - def append_option(self, option: SelectOption) -> Self: - """Appends an option to the select menu. - - Parameters - ---------- - option: :class:`discord.SelectOption` - The option to append to the select menu. - - Raises - ------ - ValueError - The number of options exceeds 25. - """ - if self._underlying.type is not ComponentType.string_select: - raise Exception("options can only be set on string selects") - - if len(self._underlying.options) > 25: - raise ValueError("maximum number of options already provided") - - self._underlying.options.append(option) - return self - - @property - def values( - self, - ) -> list[str] | list[Member | User] | list[Role] | list[Member | User | Role] | list[GuildChannel | Thread]: - """List[:class:`str`] | List[:class:`discord.Member` | :class:`discord.User`]] | List[:class:`discord.Role`]] | - List[:class:`discord.Member` | :class:`discord.User` | :class:`discord.Role`]] | List[:class:`discord.abc.GuildChannel`] | None: - A list of values that have been selected by the user. This will be ``None`` if the select has not been interacted with yet. - """ - if self._interaction is None: - # The select has not been interacted with yet - return None - select_type = self._underlying.type - if select_type is ComponentType.string_select: - return self._selected_values - resolved = [] - selected_values = list(self._selected_values) - state = self._interaction._state - guild = self._interaction.guild - resolved_data = self._interaction.data.get("resolved", {}) - if select_type is ComponentType.channel_select: - for channel_id, _data in resolved_data.get("channels", {}).items(): - if channel_id not in selected_values: - continue - if int(channel_id) in guild._channels or int(channel_id) in guild._threads: - result = guild.get_channel_or_thread(int(channel_id)) - _data["_invoke_flag"] = True - (result._update(_data) if isinstance(result, Thread) else result._update(guild, _data)) - else: - # NOTE: - # This is a fallback in case the channel/thread is not found in the - # guild's channels/threads. For channels, if this fallback occurs, at the very minimum, - # permissions will be incorrect due to a lack of permission_overwrite data. - # For threads, if this fallback occurs, info like thread owner id, message count, - # flags, and more will be missing due to a lack of data sent by Discord. - obj_type = _threaded_guild_channel_factory(_data["type"])[0] - result = obj_type(state=state, data=_data, guild=guild) - resolved.append(result) - elif select_type in ( - ComponentType.user_select, - ComponentType.mentionable_select, - ): - cache_flag = state.member_cache_flags.interaction - resolved_user_data = resolved_data.get("users", {}) - resolved_member_data = resolved_data.get("members", {}) - for _id in selected_values: - if (_data := resolved_user_data.get(_id)) is not None: - if (_member_data := resolved_member_data.get(_id)) is not None: - member = dict(_member_data) - member["user"] = _data - _data = member - result = guild._get_and_update_member(_data, int(_id), cache_flag) - else: - result = User(state=state, data=_data) - resolved.append(result) - if select_type in (ComponentType.role_select, ComponentType.mentionable_select): - for role_id, _data in resolved_data.get("roles", {}).items(): - if role_id not in selected_values: - continue - resolved.append(Role(guild=guild, state=state, data=_data)) - return resolved - - @property - def width(self) -> int: - return 5 - - def to_component_dict(self) -> SelectMenuPayload: - return self._underlying.to_dict() - - def refresh_component(self, component: SelectMenu) -> None: - self._underlying = component - - def refresh_state(self, interaction: Interaction) -> None: - data: ComponentInteractionData = interaction.data # type: ignore - self._selected_values = data.get("values", []) - self._interaction = interaction - - @classmethod - def from_component(cls: type[S], component: SelectMenu) -> S: - return cls( - select_type=component.type, - custom_id=component.custom_id, - placeholder=component.placeholder, - min_values=component.min_values, - max_values=component.max_values, - options=component.options, - channel_types=component.channel_types, - disabled=component.disabled, - row=None, - id=component.id, - ) - - @property - def type(self) -> ComponentType: - return self._underlying.type - - def is_dispatchable(self) -> bool: - return True - - def is_storable(self) -> bool: - return True - - -_select_types = ( - ComponentType.string_select, - ComponentType.user_select, - ComponentType.role_select, - ComponentType.mentionable_select, - ComponentType.channel_select, -) - - -def select( - select_type: ComponentType = ComponentType.string_select, - *, - placeholder: str | None = None, - custom_id: str | None = None, - min_values: int = 1, - max_values: int = 1, - options: list[SelectOption] | utils.Undefined = MISSING, - channel_types: list[ChannelType] | utils.Undefined = 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. - - The function being decorated should have three parameters, ``self`` representing - the :class:`discord.ui.View`, the :class:`discord.ui.Select` being pressed and - the :class:`discord.Interaction` you receive. - - In order to get the selected items that the user has chosen within the callback - use :attr:`Select.values`. - - .. versionchanged:: 2.3 - - Creating select menus of different types is now supported. - - Parameters - ---------- - select_type: :class:`discord.ComponentType` - The type of select to create. Must be one of - :attr:`discord.ComponentType.string_select`, :attr:`discord.ComponentType.user_select`, - :attr:`discord.ComponentType.role_select`, :attr:`discord.ComponentType.mentionable_select`, - or :attr:`discord.ComponentType.channel_select`. - placeholder: Optional[:class:`str`] - The placeholder text that is shown if nothing is selected, if any. - custom_id: :class:`str` - The ID of the select menu that gets received during an interaction. - It is recommended not to set this parameter to prevent conflicts. - row: Optional[:class:`int`] - The relative row this select menu belongs to. A Discord component can only have 5 - rows. By default, items are arranged automatically into those 5 rows. If you'd - like to control the relative positioning of the row then passing an index is advised. - For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic - ordering. The row number must be between 0 and 4 (i.e. zero indexed). - min_values: :class:`int` - The minimum number of items that must be chosen for this select menu. - Defaults to 1 and must be between 0 and 25. - max_values: :class:`int` - The maximum number of items that must be chosen for this select menu. - Defaults to 1 and must be between 1 and 25. - options: List[:class:`discord.SelectOption`] - A list of options that can be selected in this menu. - Only valid for the :attr:`discord.ComponentType.string_select` type. - channel_types: List[:class:`discord.ChannelType`] - The channel types that should be selectable. - Only valid for the :attr:`discord.ComponentType.channel_select` type. - 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("select_type must be one of " + ", ".join([i.name for i in _select_types])) - - if options is not MISSING and select_type not in ( - ComponentType.select, - ComponentType.string_select, - ): - raise TypeError("options may only be specified for string selects") - - if channel_types is not MISSING and select_type is not ComponentType.channel_select: - raise TypeError("channel_types may only be specified for channel selects") - - def decorator(func: ItemCallbackType) -> ItemCallbackType: - if not inspect.iscoroutinefunction(func): - raise TypeError("select function must be a coroutine function") - - model_kwargs = { - "select_type": select_type, - "placeholder": placeholder, - "custom_id": custom_id, - "row": row, - "min_values": min_values, - "max_values": max_values, - "disabled": disabled, - "id": id, - } - if options: - model_kwargs["options"] = options - if channel_types: - model_kwargs["channel_types"] = channel_types - - func.__discord_ui_model_type__ = Select - func.__discord_ui_model_kwargs__ = model_kwargs - - return func - - return decorator - - -def string_select( - *, - placeholder: str | None = None, - custom_id: str | None = None, - min_values: int = 1, - max_values: int = 1, - options: list[SelectOption] | utils.Undefined = 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`. - - .. versionadded:: 2.3 - """ - return select( - ComponentType.string_select, - placeholder=placeholder, - custom_id=custom_id, - min_values=min_values, - max_values=max_values, - options=options, - disabled=disabled, - row=row, - id=id, - ) - - -def user_select( - *, - placeholder: str | None = None, - custom_id: str | None = None, - min_values: int = 1, - 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`. - - .. versionadded:: 2.3 - """ - return select( - ComponentType.user_select, - placeholder=placeholder, - custom_id=custom_id, - min_values=min_values, - max_values=max_values, - disabled=disabled, - row=row, - id=id, - ) - - -def role_select( - *, - placeholder: str | None = None, - custom_id: str | None = None, - min_values: int = 1, - 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`. - - .. versionadded:: 2.3 - """ - return select( - ComponentType.role_select, - placeholder=placeholder, - custom_id=custom_id, - min_values=min_values, - max_values=max_values, - disabled=disabled, - row=row, - id=id, - ) - - -def mentionable_select( - *, - placeholder: str | None = None, - custom_id: str | None = None, - min_values: int = 1, - 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`. - - .. versionadded:: 2.3 - """ - return select( - ComponentType.mentionable_select, - placeholder=placeholder, - custom_id=custom_id, - min_values=min_values, - max_values=max_values, - disabled=disabled, - row=row, - id=id, - ) - - -def channel_select( - *, - placeholder: str | None = None, - custom_id: str | None = None, - min_values: int = 1, - max_values: int = 1, - disabled: bool = False, - channel_types: list[ChannelType] | utils.Undefined = 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`. - - .. versionadded:: 2.3 - """ - return select( - ComponentType.channel_select, - placeholder=placeholder, - custom_id=custom_id, - min_values=min_values, - max_values=max_values, - disabled=disabled, - channel_types=channel_types, - row=row, - id=id, - ) diff --git a/discord/ui/separator.py b/discord/ui/separator.py deleted file mode 100644 index 00503eac7f..0000000000 --- a/discord/ui/separator.py +++ /dev/null @@ -1,91 +0,0 @@ -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 deleted file mode 100644 index 6624500a3f..0000000000 --- a/discord/ui/text_display.py +++ /dev/null @@ -1,80 +0,0 @@ -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 deleted file mode 100644 index f14e3022eb..0000000000 --- a/discord/ui/thumbnail.py +++ /dev/null @@ -1,113 +0,0 @@ -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 deleted file mode 100644 index 81b60d6568..0000000000 --- a/discord/ui/view.py +++ /dev/null @@ -1,765 +0,0 @@ -""" -The MIT License (MIT) - -Copyright (c) 2015-2021 Rapptz -Copyright (c) 2021-present Pycord Development - -Permission is hereby granted, free of charge, to any person obtaining a -copy of this software and associated documentation files (the "Software"), -to deal in the Software without restriction, including without limitation -the rights to use, copy, modify, merge, publish, distribute, sublicense, -and/or sell copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. -""" - -from __future__ import annotations - -import asyncio -import os -import sys -import time -from functools import partial -from itertools import groupby -from typing import TYPE_CHECKING, Any, Callable, ClassVar, Iterator, Sequence, TypeVar - -from .. import utils -from ..components import ActionRow as ActionRowComponent -from ..components import Button as ButtonComponent -from ..components import Component, FileComponent, _component_factory -from ..components import Container as ContainerComponent -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 .item import Item, ItemCallbackType - -__all__ = ("View", "_component_to_item", "_walk_all_components") - - -if TYPE_CHECKING: - from ..interactions import Interaction, InteractionMessage - from ..message import Message - from ..state import ConnectionState - from ..types.components import Component as ComponentPayload - -V = TypeVar("V", bound="View", covariant=True) - - -def _walk_all_components(components: list[Component]) -> Iterator[Component]: - for item in components: - if isinstance(item, ActionRowComponent): - yield from item.children - else: - 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 # noqa: PLC0415 - - return Button.from_component(component) - if isinstance(component, SelectComponent): - from .select import Select # noqa: PLC0415 - - return Select.from_component(component) - if isinstance(component, SectionComponent): - from .section import Section # noqa: PLC0415 - - return Section.from_component(component) - if isinstance(component, TextDisplayComponent): - from .text_display import TextDisplay # noqa: PLC0415 - - return TextDisplay.from_component(component) - if isinstance(component, ThumbnailComponent): - from .thumbnail import Thumbnail # noqa: PLC0415 - - return Thumbnail.from_component(component) - if isinstance(component, MediaGalleryComponent): - from .media_gallery import MediaGallery # noqa: PLC0415 - - return MediaGallery.from_component(component) - if isinstance(component, FileComponent): - from .file import File # noqa: PLC0415 - - return File.from_component(component) - if isinstance(component, SeparatorComponent): - from .separator import Separator # noqa: PLC0415 - - return Separator.from_component(component) - if isinstance(component, ContainerComponent): - from .container import Container # noqa: PLC0415 - - return Container.from_component(component) - if isinstance(component, ActionRowComponent): - # Handle ActionRow.children manually, or design ui.ActionRow? - - return component - return Item.from_component(component) - - -class _ViewWeights: - __slots__ = ("weights",) - - def __init__(self, children: list[Item[V]]): - self.weights: list[int] = [0, 0, 0, 0, 0] - - key = lambda i: sys.maxsize if i.row is None else i.row - children = sorted(children, key=key) - for _, group in groupby(children, key=key): - for item in group: - self.add_item(item) - - def find_open_space(self, item: Item[V]) -> int: - for index, weight in enumerate(self.weights): - # 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: - raise ValueError(f"item would not fit at row {item.row} ({total} > 5 width)") - self.weights[item.row] = total - item._rendered_row = item.row - else: - index = self.find_open_space(item) - self.weights[index] += item.width - item._rendered_row = index - - def remove_item(self, item: Item[V]) -> None: - if item._rendered_row is not None: - self.weights[item._rendered_row] -= item.width - item._rendered_row = 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. - - This object must be inherited to create a UI within Discord. - - .. versionadded:: 2.0 - - Parameters - ---------- - *items: :class:`Item` - The initial items attached to this view. - timeout: Optional[:class:`float`] - Timeout in seconds from last interaction with the UI before no longer accepting input. Defaults to 180.0. - If ``None`` then there is no timeout. - - Attributes - ---------- - timeout: Optional[:class:`float`] - Timeout from last interaction with the UI before no longer accepting input. - If ``None`` then there is no timeout. - children: List[:class:`Item`] - The list of children attached to this view. - disable_on_timeout: :class:`bool` - Whether to disable the view when the timeout is reached. Defaults to ``False``. - message: Optional[:class:`.Message`] - The message that this view is attached to. - If ``None`` then the view has not been sent with a message. - parent: Optional[:class:`.Interaction`] - The parent interaction which this view was sent from. - If ``None`` then the view was not sent using :meth:`InteractionResponse.send_message`. - """ - - __discord_ui_view__: ClassVar[bool] = True - __view_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) - - if len(children) > 40: - raise TypeError("View cannot have more than 40 children") - - cls.__view_children_items__ = children - - def __init__( - self, - *items: Item[V], - timeout: float | None = 180.0, - disable_on_timeout: bool = False, - ): - self.timeout = timeout - self.disable_on_timeout = disable_on_timeout - self.children: list[Item[V]] = [] - for func in self.__view_children_items__: - item: Item[V] = func.__discord_ui_model_type__(**func.__discord_ui_model_kwargs__) - item.callback = partial(func, self, item) - item._view = self - item.parent = self - setattr(self, func.__name__, item) - self.children.append(item) - - self.__weights = _ViewWeights(self.children) - for item in items: - self.add_item(item) - - loop = asyncio.get_running_loop() - self.id: str = os.urandom(16).hex() - self.__cancel_callback: Callable[[View], None] | None = None - self.__timeout_expiry: float | None = None - self.__timeout_task: asyncio.Task[None] | None = None - self.__stopped: asyncio.Future[bool] = loop.create_future() - self._message: Message | InteractionMessage | None = None - self.parent: Interaction | None = None - - def __repr__(self) -> str: - return f"<{self.__class__.__name__} timeout={self.timeout} children={len(self.children)}>" - - async def __timeout_task_impl(self) -> None: - while True: - # Guard just in case someone changes the value of the timeout at runtime - if self.timeout is None: - return - - if self.__timeout_expiry is None: - return self._dispatch_timeout() - - # Check if we've elapsed our currently set timeout - now = time.monotonic() - if now >= self.__timeout_expiry: - return self._dispatch_timeout() - - # Wait N seconds to see if timeout data has been refreshed - await asyncio.sleep(self.__timeout_expiry - now) - - def to_components(self) -> list[dict[str, Any]]: - def key(item: Item[V]) -> int: - return item._rendered_row or 0 - - children = sorted(self.children, key=key) - components: list[dict[str, Any]] = [] - for _, group in groupby(children, key=key): - items = list(group) - children = [item.to_component_dict() for item in items] - if not children: - continue - - if any(i._underlying.is_v2() for i in items): - components += children - else: - components.append( - { - "type": 1, - "components": children, - } - ) - - return components - - @classmethod - def from_message(cls, message: Message, /, *, timeout: float | None = 180.0) -> View: - """Converts a message's components into a :class:`View`. - - The :attr:`.Message.components` of a message are read-only - and separate types from those in the ``discord.ui`` namespace. - In order to modify and edit message components they must be - converted into a :class:`View` first. - - Parameters - ---------- - message: :class:`.Message` - The message with components to convert into a view. - timeout: Optional[:class:`float`] - The timeout of the converted view. - - Returns - ------- - :class:`View` - The converted view. This always returns a :class:`View` and not - one of its subclasses. - """ - view = View(timeout=timeout) - for component in _walk_all_components(message.components): - 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: - return time.monotonic() + self.timeout - return None - - def add_item(self, item: Item[V]) -> None: - """Adds an item to the view. - - Parameters - ---------- - item: :class:`Item` - The item to add to the view. - - Raises - ------ - TypeError - An :class:`Item` was not passed. - ValueError - 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) >= 40: - raise ValueError("maximum number of children exceeded") - - if not isinstance(item, Item): - raise TypeError(f"expected Item not {item.__class__!r}") - - 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] | 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: 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 | int) -> Item[V] | None: - """Gets an item from the view. Roughly equal to `utils.find(lambda i: i.custom_id == custom_id, self.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 - ---------- - custom_id: :class:`str` - The custom_id of the item to get - - Returns - ------- - Optional[:class:`Item`] - The item with the matching ``custom_id`` or ``id`` if it exists. - """ - if not custom_id: - return None - attr = "id" if isinstance(custom_id, int) else "custom_id" - child = utils.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| - - A callback that is called when an interaction happens within the view - that checks whether the view should process item callbacks for the interaction. - - This is useful to override if, for example, you want to ensure that the - interaction author is a given user. - - The default implementation of this returns ``True``. - - If this returns ``False``, :meth:`on_check_failure` is called. - - .. note:: - - If an exception occurs within the body then the check - is considered a failure and :meth:`on_error` is called. - - Parameters - ---------- - interaction: :class:`~discord.Interaction` - The interaction that occurred. - - Returns - ------- - :class:`bool` - Whether the view children's callbacks should be called. - """ - return True - - async def on_timeout(self) -> None: - """|coro| - - A callback that is called when a view's timeout elapses without being explicitly stopped. - """ - if self.disable_on_timeout: - self.disable_all_items() - - if not self._message or self._message.flags.ephemeral: - message = self.parent - else: - message = self.message - - if message: - m = await message.edit(view=self) - if m: - self._message = m - - async def on_check_failure(self, interaction: Interaction) -> None: - """|coro| - A callback that is called when a :meth:`View.interaction_check` returns ``False``. - This can be used to send a response when a check failure occurs. - - Parameters - ---------- - interaction: :class:`~discord.Interaction` - The interaction that occurred. - """ - - async def on_error(self, error: Exception, item: Item[V], interaction: Interaction) -> None: - """|coro| - - A callback that is called when an item's callback or :meth:`interaction_check` - fails with an error. - - The default implementation prints the traceback to stderr. - - Parameters - ---------- - error: :class:`Exception` - The exception that was raised. - item: :class:`Item` - The item that failed the dispatch. - interaction: :class:`~discord.Interaction` - The interaction that led to the failure. - """ - interaction.client.dispatch("view_error", error, item, interaction) - - async def _scheduled_task(self, item: Item[V], interaction: Interaction): - try: - if self.timeout: - self.__timeout_expiry = time.monotonic() + self.timeout - - allow = await self.interaction_check(interaction) - if not allow: - return await self.on_check_failure(interaction) - - await item.callback(interaction) - except Exception as e: - return await self.on_error(e, item, interaction) - - def _start_listening_from_store(self, store: ViewStore) -> None: - self.__cancel_callback = partial(store.remove_view) - if self.timeout: - loop = asyncio.get_running_loop() - if self.__timeout_task is not None: - self.__timeout_task.cancel() - - self.__timeout_expiry = time.monotonic() + self.timeout - self.__timeout_task = loop.create_task(self.__timeout_task_impl()) - - def _dispatch_timeout(self): - if self.__stopped.done(): - return - - self.__stopped.set_result(True) - asyncio.create_task(self.on_timeout(), name=f"discord-ui-view-timeout-{self.id}") - - def _dispatch_item(self, item: Item[V], interaction: Interaction): - if self.__stopped.done(): - return - - if interaction.message: - self.message = interaction.message - - asyncio.create_task( - self._scheduled_task(item, interaction), - name=f"discord-ui-view-dispatch-{self.id}", - ) - - def refresh(self, components: list[Component]): - # 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: - item = self.children[i] - except IndexError: - break - else: - item.refresh_component(c) - i += 1 - - def stop(self) -> None: - """Stops listening to interaction events from this view. - - This operation cannot be undone. - """ - if not self.__stopped.done(): - self.__stopped.set_result(False) - - self.__timeout_expiry = None - if self.__timeout_task is not None: - self.__timeout_task.cancel() - self.__timeout_task = None - - if self.__cancel_callback: - self.__cancel_callback(self) - self.__cancel_callback = None - - 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 - - def is_persistent(self) -> bool: - """Whether the view is set up as persistent. - - A persistent view has all their components with a set ``custom_id`` and - a :attr:`timeout` set to ``None``. - """ - return self.timeout is None and all(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. - - A view is considered finished when :meth:`stop` - is called, or it times out. - - Returns - ------- - :class:`bool` - If ``True``, then the view timed out. If ``False`` then - the view finished normally. - """ - return await self.__stopped - - def disable_all_items(self, *, exclusions: list[Item[V]] | None = None) -> None: - """ - Disables all buttons and select menus in the view. - - Parameters - ---------- - exclusions: Optional[List[:class:`Item`]] - A list of items in `self.children` to not disable from the view. - """ - for child in self.children: - 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 buttons and select menus in the view. - - Parameters - ---------- - exclusions: Optional[List[:class:`Item`]] - A list of items in `self.children` to not enable from the view. - """ - for child in self.children: - 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): - return self._message - - @message.setter - def message(self, value): - self._message = value - - -class ViewStore: - def __init__(self, state: ConnectionState): - # (component_type, message_id, custom_id): (View, Item) - self._views: dict[tuple[int, int | None, str], tuple[View, Item[V]]] = {} - # message_id: View - self._synced_message_views: dict[int, View] = {} - self._state: ConnectionState = state - - @property - def persistent_views(self) -> Sequence[View]: - views = {view.id: view for (_, (view, _)) in self._views.items() if view.is_persistent()} - return list(views.values()) - - def __verify_integrity(self): - to_remove: list[tuple[int, int | None, str]] = [] - for k, (view, _) in self._views.items(): - if view.is_finished(): - to_remove.append(k) - - for k in to_remove: - del self._views[k] - - def add_view(self, view: View, message_id: int | None = None): - self.__verify_integrity() - - view._start_listening_from_store(self) - 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.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(): - if value.id == view.id: - del self._synced_message_views[key] - break - - def dispatch(self, component_type: int, custom_id: str, interaction: Interaction): - self.__verify_integrity() - message_id: int | None = interaction.message and interaction.message.id - key = (component_type, message_id, custom_id) - # Fallback to None message_id searches in case a persistent view - # was added without an associated message_id - value = self._views.get(key) or self._views.get((component_type, None, custom_id)) - if value is None: - return - - view, item = value - interaction.view = view - item.refresh_state(interaction) - view._dispatch_item(item, interaction) - - def is_message_tracked(self, message_id: int): - return message_id in self._synced_message_views - - def remove_message_tracking(self, message_id: int) -> View | None: - return self._synced_message_views.pop(message_id, 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] - components = [_component_factory(d, state=self._state) for d in components] - view.refresh(components) diff --git a/discord/utils/__init__.py b/discord/utils/__init__.py index e136a27cdc..23540e7153 100644 --- a/discord/utils/__init__.py +++ b/discord/utils/__init__.py @@ -27,6 +27,7 @@ from .public import ( DISCORD_EPOCH, + EMOJIS_MAP, MISSING, UNICODE_EMOJIS, Undefined, @@ -63,4 +64,5 @@ "MISSING", "DISCORD_EPOCH", "UNICODE_EMOJIS", + "EMOJIS_MAP", ) diff --git a/discord/webhook/async_.py b/discord/webhook/async_.py index a11ac45913..3751bbc8e1 100644 --- a/discord/webhook/async_.py +++ b/discord/webhook/async_.py @@ -30,6 +30,7 @@ import logging import re import weakref +from collections.abc import Sequence from contextvars import ContextVar from typing import TYPE_CHECKING, Any, Literal, NamedTuple, overload from urllib.parse import quote as urlquote @@ -39,6 +40,7 @@ from .. import utils from ..asset import Asset from ..channel import ForumChannel, PartialMessageable +from ..components import AnyComponent from ..enums import WebhookType, try_enum from ..errors import ( DiscordServerError, @@ -81,7 +83,6 @@ from ..types.message import Message as MessagePayload from ..types.webhook import FollowerWebhook as FollowerWebhookPayload from ..types.webhook import Webhook as WebhookPayload - from ..ui.view import View MISSING = utils.MISSING @@ -625,7 +626,7 @@ def handle_message_parameters( attachments: list[Attachment] | utils.Undefined = MISSING, embed: Embed | None | utils.Undefined = MISSING, embeds: list[Embed] | utils.Undefined = MISSING, - view: View | None | utils.Undefined = MISSING, + components: Sequence[AnyComponent] | None | utils.Undefined = MISSING, poll: Poll | None | utils.Undefined = MISSING, applied_tags: list[Snowflake] | utils.Undefined = MISSING, allowed_mentions: AllowedMentions | None | utils.Undefined = MISSING, @@ -657,12 +658,13 @@ def handle_message_parameters( 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 components is not MISSING: + payload["components"] = [] + if components: + for c in components: + payload["components"].append(c.to_dict()) + if c.any_is_v2(): + flags.is_components_v2 = True if poll is not MISSING: payload["poll"] = poll.to_dict() payload["tts"] = tts @@ -869,7 +871,7 @@ async def edit( file: File | utils.Undefined = MISSING, files: list[File] | utils.Undefined = MISSING, attachments: list[Attachment] | utils.Undefined = MISSING, - view: View | None | utils.Undefined = MISSING, + components: Sequence[AnyComponent] | None | utils.Undefined = MISSING, allowed_mentions: AllowedMentions | None = None, suppress: bool | None | utils.Undefined = MISSING, ) -> WebhookMessage: @@ -908,11 +910,12 @@ async def edit( allowed_mentions: :class:`AllowedMentions` Controls the mentions being processed in this message. See :meth:`.abc.Messageable.send` for more information. - view: Optional[:class:`~discord.ui.View`] - The updated view to update this message with. If ``None`` is passed then - the view is removed. + components: Optional[Sequence[AnyComponent]] + A sequence of components to edit the message with. + If ``None`` is passed, then the components are cleared. + + .. versionadded:: 3.0 - .. versionadded:: 2.0 suppress: Optional[:class:`bool`] Whether to suppress embeds for the message. @@ -956,7 +959,7 @@ async def edit( file=file, files=files, attachments=attachments, - view=view, + components=components, allowed_mentions=allowed_mentions, thread=thread, suppress=suppress, @@ -1593,7 +1596,7 @@ async def send( embed: Embed | utils.Undefined = MISSING, embeds: list[Embed] | utils.Undefined = MISSING, allowed_mentions: AllowedMentions | utils.Undefined = MISSING, - view: View | utils.Undefined = MISSING, + components: Sequence[AnyComponent] | None | utils.Undefined = MISSING, poll: Poll | utils.Undefined = MISSING, thread: Snowflake | utils.Undefined = MISSING, thread_name: str | None = None, @@ -1616,7 +1619,7 @@ async def send( embed: Embed | utils.Undefined = MISSING, embeds: list[Embed] | utils.Undefined = MISSING, allowed_mentions: AllowedMentions | utils.Undefined = MISSING, - view: View | utils.Undefined = MISSING, + components: Sequence[AnyComponent] | None | utils.Undefined = MISSING, poll: Poll | utils.Undefined = MISSING, thread: Snowflake | utils.Undefined = MISSING, thread_name: str | None | utils.Undefined = None, @@ -1638,7 +1641,7 @@ async def send( embed: Embed | utils.Undefined = MISSING, embeds: list[Embed] | utils.Undefined = MISSING, allowed_mentions: AllowedMentions | utils.Undefined = MISSING, - view: View | utils.Undefined = MISSING, + components: Sequence[AnyComponent] | None | utils.Undefined = MISSING, poll: Poll | utils.Undefined = MISSING, thread: Snowflake | utils.Undefined = MISSING, thread_name: str | None = None, @@ -1680,8 +1683,6 @@ async def send( ephemeral: :class:`bool` Indicates if the message should only be visible to the user. This is only available to :attr:`WebhookType.application` webhooks. - If a view is sent with an ephemeral message, and it has no timeout set - then the timeout is set to 15 minutes. .. versionadded:: 2.0 file: :class:`File` @@ -1699,13 +1700,10 @@ async def send( Controls the mentions being processed in this message. .. versionadded:: 1.4 - view: :class:`discord.ui.View` - The view to send with the message. You can only send a view - if this webhook is not partial and has state attached. A - webhook has state attached if the webhook is managed by the - library. + components: Optional[Sequence[AnyComponent]] + A sequence of components to send with the message. - .. versionadded:: 2.0 + .. versionadded:: 3.0 thread: :class:`~discord.abc.Snowflake` The thread to send this webhook to. @@ -1746,7 +1744,7 @@ 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 dispatchable view, you specified both ``thread_name`` and ``thread``, + giving it dispatchable components, you specified both ``thread_name`` and ``thread``, or ``applied_tags`` was passed with neither ``thread_name`` nor ``thread`` specified. """ @@ -1772,11 +1770,13 @@ async def send( with_components = False - if view is not MISSING: - if isinstance(self._state, _WebhookState) and view and view.is_dispatchable(): - raise InvalidArgument("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 components is not MISSING: + if ( + isinstance(self._state, _WebhookState) + and components + and any(c.any_is_dispatchable() for c in components) + ): + raise InvalidArgument("Dispatchable Webhook components require an associated state with the webhook") if not application_webhook: with_components = True @@ -1793,7 +1793,7 @@ async def send( embed=embed, embeds=embeds, ephemeral=ephemeral, - view=view, + components=components, poll=poll, applied_tags=applied_tags, allowed_mentions=allowed_mentions, @@ -1823,14 +1823,6 @@ async def send( if wait: msg = self._create_message(data) - 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 - if msg: - view.refresh(msg.components) - if view.is_dispatchable(): - self._state.store_view(view, message_id) - if delete_after is not None: async def delete(): @@ -1901,7 +1893,7 @@ async def edit_message( file: File | utils.Undefined = MISSING, files: list[File] | utils.Undefined = MISSING, attachments: list[Attachment] | utils.Undefined = MISSING, - view: View | None | utils.Undefined = MISSING, + components: Sequence[AnyComponent] | None | utils.Undefined = MISSING, allowed_mentions: AllowedMentions | None = None, thread: Snowflake | None | utils.Undefined = MISSING, suppress: bool = False, @@ -1944,12 +1936,9 @@ async def edit_message( allowed_mentions: :class:`AllowedMentions` Controls the mentions being processed in this message. See :meth:`.abc.Messageable.send` for more information. - view: Optional[:class:`~discord.ui.View`] - The updated view to update this message with. If ``None`` is passed then - the view is removed. The webhook must have state attached, similar to - :meth:`send`. - - .. versionadded:: 2.0 + components: + The components to edit the message with. + .. versionadded:: 3.0 thread: Optional[:class:`~discord.abc.Snowflake`] The thread that contains the message. suppress: :class:`bool` @@ -1980,11 +1969,14 @@ async def edit_message( with_components = False - if view is not MISSING: - if isinstance(self._state, _WebhookState) and view and view.is_dispatchable(): - raise InvalidArgument("Dispatchable Webhook views require an associated state with the webhook") + if components is not MISSING: + if ( + isinstance(self._state, _WebhookState) + and components + and any(c.any_is_dispatchable() for c in components) + ): + raise InvalidArgument("Dispatchable Webhook components 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 @@ -1996,7 +1988,7 @@ async def edit_message( attachments=attachments, embed=embed, embeds=embeds, - view=view, + components=components, allowed_mentions=allowed_mentions, previous_allowed_mentions=previous_mentions, suppress=suppress, @@ -2022,11 +2014,6 @@ async def edit_message( ) message = self._create_message(data) - if view and not view.is_finished(): - view.message = message - view.refresh(message.components) - if view.is_dispatchable(): - self._state.store_view(view, message_id) return message async def delete_message(self, message_id: int, *, thread_id: int | None = None) -> None: diff --git a/docs/api/data_classes.rst b/docs/api/data_classes.rst index ad71d7deee..9be3a3514d 100644 --- a/docs/api/data_classes.rst +++ b/docs/api/data_classes.rst @@ -21,21 +21,6 @@ dynamic attributes in mind. .. autoclass:: Object :members: -.. attributetable:: SelectOption - -.. autoclass:: SelectOption - :members: - -.. attributetable:: MediaGalleryItem - -.. autoclass:: MediaGalleryItem - :members: - -.. attributetable:: UnfurledMediaItem - -.. autoclass:: UnfurledMediaItem - :members: - .. attributetable:: Intents .. autoclass:: Intents diff --git a/docs/api/enums.rst b/docs/api/enums.rst index 7ceb0db38a..510f3b63ba 100644 --- a/docs/api/enums.rst +++ b/docs/api/enums.rst @@ -550,7 +550,7 @@ of :class:`enum.Enum`. An alias for :attr:`link`. -.. class:: InputTextStyle +.. class:: TextInputStyle Represents the style of the input text component. diff --git a/docs/api/models.rst b/docs/api/models.rst index 5075d7084b..889a132f28 100644 --- a/docs/api/models.rst +++ b/docs/api/models.rst @@ -348,6 +348,12 @@ Interactions .. autoclass:: Interaction() :members: +.. autoclass:: ComponentInteraction() + :members: + +.. autoclass:: ModalInteraction() + :members: + .. attributetable:: InteractionResponse .. autoclass:: InteractionResponse() @@ -358,11 +364,6 @@ Interactions .. autoclass:: InteractionMessage() :members: -.. attributetable:: MessageInteraction - -.. autoclass:: MessageInteraction() - :members: - .. attributetable:: InteractionMetadata .. autoclass:: InteractionMetadata() @@ -378,73 +379,6 @@ Interactions .. autoclass:: InteractionCallback() :members: -Message Components ------------------- - -.. attributetable:: Component - -.. autoclass:: Component() - :members: - -.. attributetable:: ActionRow - -.. autoclass:: ActionRow() - :members: - -.. attributetable:: Button - -.. autoclass:: Button() - :members: - :inherited-members: - -.. attributetable:: SelectMenu - -.. autoclass:: SelectMenu() - :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 ad2769eb03..df78f2b00b 100644 --- a/docs/api/ui_kit.rst +++ b/docs/api/ui_kit.rst @@ -3,108 +3,188 @@ Bot UI Kit ========== -The library has helpers to help create component-based UIs. +The library implements a UI Kit that allows you to create interactive components for your Discord applications. +API Models +----------- -Shortcut decorators -------------------- +.. attributetable:: discord.components.ActionRow -.. autofunction:: discord.ui.button - :decorator: +.. autoclass:: discord.components.ActionRow + :members: + :inherited-members: -.. autofunction:: discord.ui.select - :decorator: +.. attributetable:: discord.components.Button -.. autofunction:: discord.ui.string_select - :decorator: +.. autoclass:: discord.components.Button + :members: + :inherited-members: -.. autofunction:: discord.ui.user_select - :decorator: +.. attributetable:: discord.components.StringSelect -.. autofunction:: discord.ui.role_select - :decorator: +.. autoclass:: discord.components.StringSelect + :members: + :inherited-members: -.. autofunction:: discord.ui.mentionable_select - :decorator: +.. attributetable:: discord.components.TextInput -.. autofunction:: discord.ui.channel_select - :decorator: +.. autoclass:: discord.components.TextInput + :members: + :inherited-members: + +.. attributetable:: discord.components.UserSelect -Objects -------- +.. autoclass:: discord.components.UserSelect + :members: + :inherited-members: -.. attributetable:: discord.ui.View +.. attributetable:: discord.components.RoleSelect -.. autoclass:: discord.ui.View +.. autoclass:: discord.components.RoleSelect :members: + :inherited-members: -.. attributetable:: discord.ui.Item +.. attributetable:: discord.components.MentionableSelect -.. autoclass:: discord.ui.Item +.. autoclass:: discord.components.MentionableSelect :members: + :inherited-members: -.. attributetable:: discord.ui.Button +.. attributetable:: discord.components.ChannelSelect -.. autoclass:: discord.ui.Button +.. autoclass:: discord.components.ChannelSelect :members: :inherited-members: -.. attributetable:: discord.ui.Select +.. attributetable:: discord.components.Section -.. autoclass:: discord.ui.Select +.. autoclass:: discord.components.Section :members: :inherited-members: -.. attributetable:: discord.ui.Section +.. attributetable:: discord.components.TextDisplay -.. autoclass:: discord.ui.Section +.. autoclass:: discord.components.TextDisplay :members: :inherited-members: -.. attributetable:: discord.ui.TextDisplay +.. attributetable:: discord.components.Thumbnail -.. autoclass:: discord.ui.TextDisplay +.. autoclass:: discord.components.Thumbnail :members: :inherited-members: -.. attributetable:: discord.ui.Thumbnail +.. attributetable:: discord.components.MediaGallery -.. autoclass:: discord.ui.Thumbnail +.. autoclass:: discord.components.MediaGallery :members: :inherited-members: -.. attributetable:: discord.ui.MediaGallery +.. attributetable:: discord.components.FileComponent -.. autoclass:: discord.ui.MediaGallery +.. autoclass:: discord.components.FileComponent :members: :inherited-members: -.. attributetable:: discord.ui.File +.. attributetable:: discord.components.Separator +.. autoclass:: discord.components.Separator + :members: + :inherited-members: -.. autoclass:: discord.ui.File +.. attributetable:: discord.components.Container +.. autoclass:: discord.components.Container :members: :inherited-members: -.. attributetable:: discord.ui.Separator +.. attributetable:: discord.components.Label +.. autoclass:: discord.components.Label + :members: + :inherited-members: -.. autoclass:: discord.ui.Separator +Interaction Components +----------- +These objects are dataclasses that represent components as they are received from Discord in interaction payloads, currently applicable only with :class:`discord.components.Interaction` of type :data:`discord.components.InteractionType.modal_submit`. + +.. attributetable:: discord.components.PartialLabel +.. autoclass:: discord.components.PartialLabel :members: :inherited-members: -.. attributetable:: discord.ui.Container +.. attributetable:: discord.components.PartialStringSelect +.. autoclass:: discord.components.PartialStringSelect + :members: + :inherited-members: -.. autoclass:: discord.ui.Container +.. attributetable:: discord.components.PartialUserSelect +.. autoclass:: discord.components.PartialUserSelect :members: :inherited-members: -.. attributetable:: discord.ui.Modal +.. attributetable:: discord.components.PartialRoleSelect +.. autoclass:: discord.components.PartialRoleSelect + :members: + :inherited-members: + +.. attributetable:: discord.components.PartialMentionableSelect +.. autoclass:: discord.components.PartialMentionableSelect + :members: + :inherited-members: -.. autoclass:: discord.ui.Modal +.. attributetable:: discord.components.PartialChannelSelect +.. autoclass:: discord.components.PartialChannelSelect :members: :inherited-members: -.. attributetable:: discord.ui.InputText +.. attributetable:: discord.components.PartialTextInput +.. autoclass:: discord.components.PartialTextInput + :members: + :inherited-members: + +.. attributetable:: discord.components.PartialTextDisplay +.. autoclass:: discord.components.PartialTextDisplay + :members: + :inherited-members: + +Additional Objects +------------------ + +.. attributetable:: discord.components.Modal +.. autoclass:: discord.components.Modal + :members: + :inherited-members: + +.. attributetable:: discord.components.UnknownComponent +.. autoclass:: discord.components.UnknownComponent + :members: + :inherited-members: + +.. attributetable:: discord.components.UnfurledMediaItem +.. autoclass:: discord.components.UnfurledMediaItem + :members: + :inherited-members: + +.. attributetable:: discord.components.MediaGalleryItem +.. autoclass:: discord.components.MediaGalleryItem + :members: + :inherited-members: + +.. attributetable:: discord.components.ComponentsHolder +.. autoclass:: discord.components.ComponentsHolder + :members: + :inherited-members: + +.. attributetable:: discord.components.DefaultSelectOption +.. autoclass:: discord.components.DefaultSelectOption + :members: + :inherited-members: + +ABCs +---- +.. attributetable:: discord.components.Component +.. autoclass:: discord.components.Component + :members: -.. autoclass:: discord.ui.InputText +.. attributetable:: discord.components.PartialComponent +.. autoclass:: discord.components.PartialComponent :members: :inherited-members: diff --git a/docs/api/utils.rst b/docs/api/utils.rst index db6930cf0d..2ee4104c27 100644 --- a/docs/api/utils.rst +++ b/docs/api/utils.rst @@ -7,9 +7,6 @@ Utility Functions .. autofunction:: discord.utils.find - -.. autofunction:: discord.utils.get_or_fetch - .. autofunction:: discord.utils.oauth_url .. autofunction:: discord.utils.remove_markdown diff --git a/docs/ext/pages/index.rst b/docs/ext/pages/index.rst deleted file mode 100644 index 22cbe06fcf..0000000000 --- a/docs/ext/pages/index.rst +++ /dev/null @@ -1,336 +0,0 @@ -.. _discord_ext_pages: - -discord.ext.pages -================= - -.. versionadded:: 2.0 - -This module provides an easy pagination system with buttons, page groups, and custom view support. - -Example usage in a cog: - -.. code-block:: python3 - - import asyncio - - import discord - from discord.commands import SlashCommandGroup - from discord.ext import commands, pages - - - class PageTest(commands.Cog): - def __init__(self, bot): - self.bot = bot - self.pages = [ - "Page 1", - [ - discord.Embed(title="Page 2, Embed 1"), - discord.Embed(title="Page 2, Embed 2"), - ], - "Page Three", - discord.Embed(title="Page Four"), - discord.Embed(title="Page Five"), - [ - discord.Embed(title="Page Six, Embed 1"), - discord.Embed(title="Page Seven, Embed 2"), - ], - ] - self.pages[3].set_image( - url="https://c.tenor.com/pPKOYQpTO8AAAAAM/monkey-developer.gif" - ) - self.pages[4].add_field( - name="Example Field", value="Example Value", inline=False - ) - self.pages[4].add_field( - name="Another Example Field", value="Another Example Value", inline=False - ) - - self.more_pages = [ - "Second Page One", - discord.Embed(title="Second Page Two"), - discord.Embed(title="Second Page Three"), - ] - - self.even_more_pages = ["11111", "22222", "33333"] - - def get_pages(self): - return self.pages - - pagetest = SlashCommandGroup("pagetest", "Commands for testing ext.pages") - - # These examples use a Slash Command Group in a cog for better organization - it's not required for using ext.pages. - @pagetest.command(name="default") - async def pagetest_default(self, ctx: discord.ApplicationContext): - """Demonstrates using the paginator with the default options.""" - paginator = pages.Paginator(pages=self.get_pages()) - await paginator.respond(ctx.interaction, ephemeral=False) - - @pagetest.command(name="hidden") - async def pagetest_hidden(self, ctx: discord.ApplicationContext): - """Demonstrates using the paginator with disabled buttons hidden.""" - paginator = pages.Paginator(pages=self.get_pages(), show_disabled=False) - await paginator.respond(ctx.interaction, ephemeral=False) - - @pagetest.command(name="loop") - async def pagetest_loop(self, ctx: discord.ApplicationContext): - """Demonstrates using the loop_pages option.""" - paginator = pages.Paginator(pages=self.get_pages(), loop_pages=True) - await paginator.respond(ctx.interaction, ephemeral=False) - - @pagetest.command(name="strings") - async def pagetest_strings(self, ctx: discord.ApplicationContext): - """Demonstrates passing a list of strings as pages.""" - paginator = pages.Paginator( - pages=["Page 1", "Page 2", "Page 3"], loop_pages=True - ) - await paginator.respond(ctx.interaction, ephemeral=False) - - @pagetest.command(name="timeout") - async def pagetest_timeout(self, ctx: discord.ApplicationContext): - """Demonstrates having the buttons be disabled when the paginator view times out.""" - paginator = pages.Paginator( - pages=self.get_pages(), disable_on_timeout=True, timeout=30 - ) - await paginator.respond(ctx.interaction, ephemeral=False) - - @pagetest.command(name="remove_buttons") - async def pagetest_remove(self, ctx: discord.ApplicationContext): - """Demonstrates using the default buttons, but removing some of them.""" - paginator = pages.Paginator(pages=self.get_pages()) - paginator.remove_button("first") - paginator.remove_button("last") - await paginator.respond(ctx.interaction, ephemeral=False) - - @pagetest.command(name="init") - async def pagetest_init(self, ctx: discord.ApplicationContext): - """Demonstrates how to pass a list of custom buttons when creating the Paginator instance.""" - pagelist = [ - pages.PaginatorButton( - "first", label="<<-", style=discord.ButtonStyle.green - ), - pages.PaginatorButton("prev", label="<-", style=discord.ButtonStyle.green), - pages.PaginatorButton( - "page_indicator", style=discord.ButtonStyle.gray, disabled=True - ), - pages.PaginatorButton("next", label="->", style=discord.ButtonStyle.green), - pages.PaginatorButton("last", label="->>", style=discord.ButtonStyle.green), - ] - paginator = pages.Paginator( - pages=self.get_pages(), - show_disabled=True, - show_indicator=True, - use_default_buttons=False, - custom_buttons=pagelist, - loop_pages=True, - ) - await paginator.respond(ctx.interaction, ephemeral=False) - - @pagetest.command(name="emoji_buttons") - async def pagetest_emoji_buttons(self, ctx: discord.ApplicationContext): - """Demonstrates using emojis for the paginator buttons instead of labels.""" - page_buttons = [ - pages.PaginatorButton( - "first", emoji="⏪", style=discord.ButtonStyle.green - ), - pages.PaginatorButton("prev", emoji="⬅", style=discord.ButtonStyle.green), - pages.PaginatorButton( - "page_indicator", style=discord.ButtonStyle.gray, disabled=True - ), - pages.PaginatorButton("next", emoji="➡", style=discord.ButtonStyle.green), - pages.PaginatorButton("last", emoji="⏩", style=discord.ButtonStyle.green), - ] - paginator = pages.Paginator( - pages=self.get_pages(), - show_disabled=True, - show_indicator=True, - use_default_buttons=False, - custom_buttons=page_buttons, - loop_pages=True, - ) - await paginator.respond(ctx.interaction, ephemeral=False) - - @pagetest.command(name="custom_buttons") - async def pagetest_custom_buttons(self, ctx: discord.ApplicationContext): - """Demonstrates adding buttons to the paginator when the default buttons are not used.""" - paginator = pages.Paginator( - pages=self.get_pages(), - use_default_buttons=False, - loop_pages=False, - show_disabled=False, - ) - paginator.add_button( - pages.PaginatorButton( - "prev", label="<", style=discord.ButtonStyle.green, loop_label="lst" - ) - ) - paginator.add_button( - pages.PaginatorButton( - "page_indicator", style=discord.ButtonStyle.gray, disabled=True - ) - ) - paginator.add_button( - pages.PaginatorButton( - "next", style=discord.ButtonStyle.green, loop_label="fst" - ) - ) - await paginator.respond(ctx.interaction, ephemeral=False) - - @pagetest.command(name="custom_view") - async def pagetest_custom_view(self, ctx: discord.ApplicationContext): - """Demonstrates passing a custom view to the paginator.""" - view = discord.ui.View() - view.add_item(discord.ui.Button(label="Test Button, Does Nothing", row=1)) - view.add_item( - discord.ui.Select( - placeholder="Test Select Menu, Does Nothing", - options=[ - discord.SelectOption( - label="Example Option", - value="Example Value", - description="This menu does nothing!", - ) - ], - ) - ) - paginator = pages.Paginator(pages=self.get_pages(), custom_view=view) - await paginator.respond(ctx.interaction, ephemeral=False) - - @pagetest.command(name="groups") - async def pagetest_groups(self, ctx: discord.ApplicationContext): - """Demonstrates using page groups to switch between different sets of pages.""" - page_buttons = [ - pages.PaginatorButton( - "first", label="<<-", style=discord.ButtonStyle.green - ), - pages.PaginatorButton("prev", label="<-", style=discord.ButtonStyle.green), - pages.PaginatorButton( - "page_indicator", style=discord.ButtonStyle.gray, disabled=True - ), - pages.PaginatorButton("next", label="->", style=discord.ButtonStyle.green), - pages.PaginatorButton("last", label="->>", style=discord.ButtonStyle.green), - ] - view = discord.ui.View() - view.add_item(discord.ui.Button(label="Test Button, Does Nothing", row=2)) - view.add_item( - discord.ui.Select( - placeholder="Test Select Menu, Does Nothing", - options=[ - discord.SelectOption( - label="Example Option", - value="Example Value", - description="This menu does nothing!", - ) - ], - ) - ) - page_groups = [ - pages.PageGroup( - pages=self.get_pages(), - label="Main Page Group", - description="Main Pages for Main Things", - ), - pages.PageGroup( - pages=[ - "Second Set of Pages, Page 1", - "Second Set of Pages, Page 2", - "Look, it's group 2, page 3!", - ], - label="Second Page Group", - description="Secondary Pages for Secondary Things", - custom_buttons=page_buttons, - use_default_buttons=False, - custom_view=view, - ), - ] - paginator = pages.Paginator(pages=page_groups, show_menu=True) - await paginator.respond(ctx.interaction, ephemeral=False) - - @pagetest.command(name="update") - async def pagetest_update(self, ctx: discord.ApplicationContext): - """Demonstrates updating an existing paginator instance with different options.""" - paginator = pages.Paginator(pages=self.get_pages(), show_disabled=False) - await paginator.respond(ctx.interaction) - await asyncio.sleep(3) - await paginator.update(show_disabled=True, show_indicator=False) - - @pagetest.command(name="target") - async def pagetest_target(self, ctx: discord.ApplicationContext): - """Demonstrates sending the paginator to a different target than where it was invoked.""" - paginator = pages.Paginator(pages=self.get_pages()) - await paginator.respond(ctx.interaction, target=ctx.interaction.user) - - @commands.command() - async def pagetest_prefix(self, ctx: commands.Context): - """Demonstrates using the paginator with a prefix-based command.""" - paginator = pages.Paginator(pages=self.get_pages(), use_default_buttons=False) - paginator.add_button( - pages.PaginatorButton("prev", label="<", style=discord.ButtonStyle.green) - ) - paginator.add_button( - pages.PaginatorButton( - "page_indicator", style=discord.ButtonStyle.gray, disabled=True - ) - ) - paginator.add_button( - pages.PaginatorButton("next", style=discord.ButtonStyle.green) - ) - await paginator.send(ctx) - - @commands.command() - async def pagetest_target(self, ctx: commands.Context): - """Demonstrates sending the paginator to a different target than where it was invoked (prefix-command version).""" - paginator = pages.Paginator(pages=self.get_pages()) - await paginator.send(ctx, target=ctx.author, target_message="Paginator sent!") - - - def setup(bot): - bot.add_cog(PageTest(bot)) - -.. _discord_ext_pages_api: - -API Reference -------------- - -Page -~~~~ - -.. attributetable:: discord.ext.pages.Page - -.. autoclass:: discord.ext.pages.Page - :members: - -Paginator -~~~~~~~~~ - -.. attributetable:: discord.ext.pages.Paginator - -.. autoclass:: discord.ext.pages.Paginator - :members: - :inherited-members: - -PaginatorButton -~~~~~~~~~~~~~~~ - -.. attributetable:: discord.ext.pages.PaginatorButton - -.. autoclass:: discord.ext.pages.PaginatorButton - :members: - :inherited-members: - -PaginatorMenu -~~~~~~~~~~~~~ - -.. attributetable:: discord.ext.pages.PaginatorMenu - -.. autoclass:: discord.ext.pages.PaginatorMenu - :members: - :inherited-members: - -PageGroup -~~~~~~~~~ - -.. attributetable:: discord.ext.pages.PageGroup - -.. autoclass:: discord.ext.pages.PageGroup - :members: - :inherited-members: diff --git a/docs/index.rst b/docs/index.rst index c4d4b9fc51..398813cb50 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -64,12 +64,10 @@ These extensions help you during development when it comes to common tasks. ext/commands/index.rst ext/tasks/index.rst - ext/pages/index.rst ext/bridge/index.rst - :doc:`ext/commands/index` - Bot commands framework - :doc:`ext/tasks/index` - asyncio.Task helpers -- :doc:`ext/pages/index` - A pagination extension module - :doc:`ext/bridge/index` - A module that bridges slash commands to prefixed commands Meta diff --git a/examples/components/simple_modal.py b/examples/components/simple_modal.py new file mode 100644 index 0000000000..5796bb921f --- /dev/null +++ b/examples/components/simple_modal.py @@ -0,0 +1,136 @@ +import os + +from dotenv import load_dotenv + +import discord +from discord import Interaction, components + +load_dotenv() + +MAZE = True # 👀 + + +bot = discord.Bot( + default_command_integration_types={discord.IntegrationType.user_install, discord.IntegrationType.guild_install}, + default_command_contexts={ + discord.InteractionContextType.guild, + discord.InteractionContextType.bot_dm, + discord.InteractionContextType.private_channel, + }, +) + + +def create_modal(user: discord.User | discord.Member) -> components.Modal: + modal = components.Modal( + components.TextDisplay( + f"""Input below your announcement's title, description, and user to mention in the announcement{", as well as attached images" if MAZE else ""}.""" + ), + components.Label( + components.TextInput( + style=discord.TextInputStyle.short, + placeholder="Launching py-cord next !", + custom_id="v1:announcement_title", + required=True, + ), + label="Announcement Title", + description="The title of your announcement", + ), + components.Label( + components.TextInput( + style=discord.TextInputStyle.paragraph, + placeholder="Today is the day we launch py-cord next !\nyada yada\n...", + custom_id="v1:announcement_content", + required=True, + ), + label="Announcement Content", + description="The content of your announcement. Supports Markdown.", + ), + components.Label( + components.MentionableSelect( + default_values=[components.DefaultSelectOption(id=user.id, type="user")], + custom_id="v1:announcement_mentions", + min_values=0, + max_values=4, + required=False, + ), + label="Mentioned Users and Roles", + description="The users and roles to mention in your announcement (if any)", + ), + title="Create an Announcement", + custom_id="v1:announcement_modal", + ) + if MAZE: + modal.components.append( + components.Label( + components.FileUpload(min_values=0, max_values=5, required=False, custom_id="v1:announcement_images"), + label="Images to attach", + description="Attach up to 5 images to your announcement. Supports PNG only.", + ) + ) + return modal + + +@bot.slash_command() +async def create_announcement(ctx: discord.ApplicationContext): + await ctx.send_modal(create_modal(ctx.author)) + + +def create_announcement( + title: str, content: str, mentions: list[discord.User | discord.Role], attachments: list[discord.Attachment] +) -> components.Container: + container = components.Container( + components.TextDisplay(f"# {title}"), + ) + if mentions: + container.components.append(components.TextDisplay(" ".join(m.mention for m in mentions))) + container.components.append(components.TextDisplay(content)) + if attachments: + container.components.append( + components.MediaGallery( + *( + components.MediaGalleryItem( + url=attachment.url, + ) + for attachment in attachments + ) + ) + ) + + return container + + +@bot.modal_listener("v1:announcement_modal") +async def announcement_modal_listener( + interaction: discord.ModalInteraction[ + components.PartialTextDisplay, + components.PartialLabel[components.PartialTextInput], + components.PartialLabel[components.PartialTextInput], + components.PartialLabel[components.PartialMentionableSelect], + components.PartialLabel[components.PartialFileUpload], + ], +): + assert interaction.channel is not None, "Channel is None" + assert isinstance(interaction.channel, discord.abc.Messageable), "Channel is not a messageable channel" + title = interaction.components[1].component.value.strip() + content = interaction.components[2].component.value.strip() + + mentions: list[discord.User | discord.Role] = [] + + for m_id in interaction.components[3].component.values: + mentions.append(interaction.roles.get(int(m_id)) or interaction.users[int(m_id)]) + + if MAZE: + attachments: list[discord.Attachment] = [ + interaction.attachments[att_id] for att_id in interaction.components[4].component.values + ] + else: + attachments = [] + + container = create_announcement(title, content, mentions, attachments) + try: + await interaction.channel.send(components=[container]) + except discord.Forbidden: + await interaction.respond(components=[container]) + + +bot.run(os.getenv("TOKEN_2")) diff --git a/examples/components/stateful_tic_tac_toe.py b/examples/components/stateful_tic_tac_toe.py new file mode 100644 index 0000000000..ebac6f09a8 --- /dev/null +++ b/examples/components/stateful_tic_tac_toe.py @@ -0,0 +1,452 @@ +import os +import random +from typing import TypedDict + +from dotenv import load_dotenv + +import discord +from discord import components + +load_dotenv() + +# ============================================================================== +# CONSTANTS +# ============================================================================== + +# Player identifiers +PLAYER_NONE = 0 # Empty cell +PLAYER_X = 1 # X player +PLAYER_O = 2 # O player + +# Display symbols for each player +X_EMOJI = "❌" +O_EMOJI = "⭕" +EMPTY_CELL = "\u200b" # Zero-width space for empty cells + +PLAYER_SYMBOLS = { + PLAYER_NONE: EMPTY_CELL, + PLAYER_X: X_EMOJI, + PLAYER_O: O_EMOJI, +} + +# Custom ID format: tic_tac_toe:{row}:{col} +# Only stores button coordinates - all game state is in GAME_STATES dict +CUSTOM_ID_PREFIX = "tic_tac_toe" + +# ============================================================================== +# TYPE DEFINITIONS +# ============================================================================== + +Board = list[list[int]] # 3x3 grid of player identifiers + + +class GameState(TypedDict): + """Represents the complete state of a Tic Tac Toe game. + + NOTE: This is stored in memory and will be lost on bot restart. + For production use, consider: + - Redis with TTL (e.g., 1 hour per game) + - Database with automatic cleanup of old games + - Any persistent storage with expiration support + """ + + board: Board # Current board state + current_turn: int # Which player's turn (1 or 2) + player_x_id: int # Discord user ID of player X + player_o_id: int # Discord user ID of player O + game_over: bool # Whether the game has ended + winner: int | None # Winner (1, 2, or None for tie) + + +# ============================================================================== +# GAME STATE STORAGE +# ============================================================================== + +# WARNING: In-memory storage - data lost on bot restart! +# For production, use Redis with TTL, a database, or another persistent store +# with automatic expiration (e.g., Redis SETEX with 3600 seconds TTL) +GAME_STATES: dict[int, GameState] = {} # Keyed by message ID + + +def create_initial_game_state(player_x_id: int, player_o_id: int) -> GameState: + """Create a new game state with empty board and random first player. + + Args: + player_x_id: Discord user ID for player X + player_o_id: Discord user ID for player O + + Returns: + A new GameState with empty board + """ + # Randomly decide who goes first + first_player = random.choice([PLAYER_X, PLAYER_O]) + + return GameState( + board=[[PLAYER_NONE for _ in range(3)] for _ in range(3)], + current_turn=first_player, + player_x_id=player_x_id, + player_o_id=player_o_id, + game_over=False, + winner=None, + ) + + +def get_player_for_user(game_state: GameState, user_id: int) -> int | None: + """Get which player (X or O) a user is controlling. + + Args: + game_state: The current game state + user_id: Discord user ID to check + + Returns: + PLAYER_X, PLAYER_O, or None if user is not in this game + """ + if user_id == game_state["player_x_id"]: + return PLAYER_X + elif user_id == game_state["player_o_id"]: + return PLAYER_O + return None + + +# ============================================================================== +# CUSTOM ID HELPERS +# ============================================================================== + + +def create_button_custom_id(row: int, col: int) -> str: + """Create a custom ID for a Tic Tac Toe button. + + Only stores coordinates - game state is looked up via message ID. + + Args: + row: Row position (0-2) + col: Column position (0-2) + """ + return f"{CUSTOM_ID_PREFIX}:{row}:{col}" + + +def parse_button_custom_id(custom_id: str) -> tuple[int, int]: + """Parse a button's custom ID to extract coordinates. + + Returns: + Tuple of (row, col) + """ + parts = custom_id.split(":") + return int(parts[1]), int(parts[2]) + + +# ============================================================================== +# BUTTON CREATION +# ============================================================================== + + +def create_cell_button(cell_value: int, row: int, col: int, disabled: bool = False) -> components.Button: + """Create a button representing a single Tic Tac Toe cell. + + Args: + cell_value: The player occupying this cell (0 = empty, 1 = X, 2 = O) + row: Row position in the grid (0-2) + col: Column position in the grid (0-2) + disabled: Whether the button should be disabled + + Returns: + A Discord Button component + """ + custom_id = create_button_custom_id(row, col) + + match cell_value: + case 0: # Empty cell - clickable + return components.Button( + style=discord.ButtonStyle.primary, label=EMPTY_CELL, custom_id=custom_id, disabled=disabled + ) + case 1 | 2: # Occupied cell - always disabled + return components.Button( + style=discord.ButtonStyle.primary, emoji=PLAYER_SYMBOLS[cell_value], custom_id=custom_id, disabled=True + ) + case _: + raise ValueError(f"Invalid cell value: {cell_value}") + + +# ============================================================================== +# BOARD STATE MANAGEMENT +# ============================================================================== + + +def check_winner(board: Board) -> int | None: + """Check if there's a winner on the board. + + Checks all rows, columns, and diagonals for three in a row. + + Args: + board: The current game board state + + Returns: + The winning player (1 or 2), or None if no winner + """ + # Check rows and columns + for i in range(3): + # Check row + if board[i][0] == board[i][1] == board[i][2] != PLAYER_NONE: + return board[i][0] + # Check column + if board[0][i] == board[1][i] == board[2][i] != PLAYER_NONE: + return board[0][i] + + # Check diagonals + if board[0][0] == board[1][1] == board[2][2] != PLAYER_NONE: + return board[0][0] + if board[0][2] == board[1][1] == board[2][0] != PLAYER_NONE: + return board[0][2] + + return None + + +def is_board_full(board: Board) -> bool: + """Check if the board is completely filled (tie game). + + Args: + board: The current game board state + + Returns: + True if no empty cells remain, False otherwise + """ + for row in board: + for cell in row: + if cell == PLAYER_NONE: + return False + return True + + +# ============================================================================== +# UI COMPONENT BUILDERS +# ============================================================================== + + +def create_game_buttons(board: Board, disable_all: bool = False) -> list[components.ActionRow]: + """Create the 3x3 grid of buttons for the Tic Tac Toe game. + + Args: + board: The current board state + disable_all: Whether to disable all buttons (game over) + + Returns: + List of ActionRow components, one per row of the game board + """ + action_rows: list[components.ActionRow] = [] + + for row_idx, row in enumerate(board): + buttons = [ + create_cell_button(cell_value=cell_value, row=row_idx, col=col_idx, disabled=disable_all) + for col_idx, cell_value in enumerate(row) + ] + action_rows.append(components.ActionRow(*buttons)) + + return action_rows + + +def create_game_container(game_buttons: list[components.ActionRow], game_state: GameState) -> components.Container: + """Create the container for an active game. + + Args: + game_buttons: The 3x3 grid of game buttons + game_state: The current game state + + Returns: + A Container with the game title, turn indicator, and buttons + """ + current_player_symbol = PLAYER_SYMBOLS[game_state["current_turn"]] + + # Mention the user whose turn it is + if game_state["current_turn"] == PLAYER_X: + current_user_id = game_state["player_x_id"] + else: + current_user_id = game_state["player_o_id"] + + return components.Container( + components.TextDisplay("## Tic Tac Toe"), + components.TextDisplay(f"It is {current_player_symbol}'s turn (<@{current_user_id}>)"), + *game_buttons, + ) + + +def create_game_over_container(game_buttons: list[components.ActionRow], game_state: GameState) -> components.Container: + """Create the container for a finished game. + + Args: + game_buttons: The final state of the game buttons + game_state: The final game state + + Returns: + A Container with the game title, result message, and final board + """ + if game_state["winner"] is None: + result_message = "It's a tie! 🤝" + else: + winner_symbol = PLAYER_SYMBOLS[game_state["winner"]] + if game_state["winner"] == PLAYER_X: + winner_id = game_state["player_x_id"] + else: + winner_id = game_state["player_o_id"] + result_message = f"Player {winner_symbol} (<@{winner_id}>) won! 🎉" + + return components.Container( + components.TextDisplay("## Tic Tac Toe"), + components.TextDisplay(result_message), + *game_buttons, + ) + + +# ============================================================================== +# BOT SETUP +# ============================================================================== + +bot = discord.Bot(intents=discord.Intents.all()) + +# ============================================================================== +# EVENT HANDLERS +# ============================================================================== + + +@bot.component_listener(lambda custom_id: custom_id.startswith(CUSTOM_ID_PREFIX)) +async def handle_tic_tac_toe_move(interaction: discord.ComponentInteraction[components.PartialButton]): + """Handle a player clicking a Tic Tac Toe cell. + + This function: + 1. Looks up the game state from the message ID + 2. Validates that it's the correct user's turn + 3. Parses which cell was clicked + 4. Updates the board with the new move + 5. Checks for a winner or tie + 6. Updates both the message and stored game state + """ + assert interaction.custom_id is not None + assert interaction.message is not None + assert interaction.user is not None + + message_id = interaction.message.id + + # Retrieve game state from storage + if message_id not in GAME_STATES: + await interaction.respond( + "❌ Game state not found! This game may have expired or the bot was restarted.", ephemeral=True + ) + return + + game_state = GAME_STATES[message_id] + + # Validate the user is in this game + user_player = get_player_for_user(game_state, interaction.user.id) + if user_player is None: + await interaction.respond("❌ You're not a player in this game!", ephemeral=True) + return + + # Validate it's this user's turn + if user_player != game_state["current_turn"]: + await interaction.respond("❌ It's not your turn!", ephemeral=True) + return + + # Parse the clicked cell coordinates + row, col = parse_button_custom_id(interaction.custom_id) + + # Apply the move to the board + game_state["board"][row][col] = game_state["current_turn"] + + # Check game end conditions + winner = check_winner(game_state["board"]) + is_tie = is_board_full(game_state["board"]) + game_over = winner is not None or is_tie + + # Update game state + if game_over: + game_state["game_over"] = True + game_state["winner"] = winner + else: + # Switch turns + game_state["current_turn"] = PLAYER_O if game_state["current_turn"] == PLAYER_X else PLAYER_X + + # Create updated button grid + updated_buttons = create_game_buttons(board=game_state["board"], disable_all=game_over) + + # Update the message with new game state + if game_over: + await interaction.edit( + components=[create_game_over_container(updated_buttons, game_state)], + ) + del GAME_STATES[message_id] # The message can't be interacted with anymore because all buttons are disabled + else: + await interaction.edit( + components=[create_game_container(updated_buttons, game_state)], + ) + + +# ============================================================================== +# SLASH COMMANDS +# ============================================================================== + + +@bot.slash_command() +async def tic_tac_toe(ctx: discord.ApplicationContext, opponent: discord.User): + """Start a new Tic Tac Toe game against another user. + + Args: + opponent: The user you want to play against + """ + # Validate opponent is not the same user + if opponent.id == ctx.user.id: + await ctx.respond("❌ You can't play against yourself!", ephemeral=True) + return + + # Validate opponent is not a bot + if opponent.bot: + await ctx.respond("❌ You can't play against a bot!", ephemeral=True) + return + + # Randomly assign X and O to the two players + players = [ctx.user.id, opponent.id] + random.shuffle(players) + player_x_id, player_o_id = players + + # Create initial game state + game_state = create_initial_game_state(player_x_id, player_o_id) + + # Create initial UI + initial_buttons = create_game_buttons(board=game_state["board"]) + + # Send the game message + message = await ctx.respond( + components=[create_game_container(initial_buttons, game_state)], + ) + + # Get the message object to store the game state + # Note: ctx.respond() returns an Interaction, we need to get the actual message + actual_message = await message.original_response() + + # Store the game state keyed by message ID + # In production: Use Redis with SETEX for automatic expiration + # Example: redis.setex(f"game:{actual_message.id}", 3600, json.dumps(game_state)) + GAME_STATES[actual_message.id] = game_state + + # Announce who goes first + first_player_symbol = PLAYER_SYMBOLS[game_state["current_turn"]] + if game_state["current_turn"] == PLAYER_X: + first_player_id = player_x_id + else: + first_player_id = player_o_id + + await ctx.send( + f"🎮 Game started! {first_player_symbol} (<@{first_player_id}>) goes first!", + ) + + +# ============================================================================== +# BOT STARTUP +# ============================================================================== + + +# Optional: Add a cleanup task for old games if not using Redis TTL +@bot.event +async def on_ready(): + print(f"Bot ready! Logged in as {bot.user}") + + +bot.run(os.getenv("TOKEN")) diff --git a/examples/components/stateless_tic_tac_toe.py b/examples/components/stateless_tic_tac_toe.py new file mode 100644 index 0000000000..13e3f1cc55 --- /dev/null +++ b/examples/components/stateless_tic_tac_toe.py @@ -0,0 +1,346 @@ +import os +from typing import Sequence + +from dotenv import load_dotenv + +import discord +from discord import components + +load_dotenv() + +# ============================================================================== +# CONSTANTS +# ============================================================================== + +# Player identifiers +PLAYER_NONE = 0 # Empty cell +PLAYER_X = 1 # X player +PLAYER_O = 2 # O player + +# Display symbols for each player +X_EMOJI = "❌" +O_EMOJI = "⭕" +EMPTY_CELL = "\u200b" # Zero-width space for empty cells + +PLAYER_SYMBOLS = { + PLAYER_NONE: EMPTY_CELL, + PLAYER_X: X_EMOJI, + PLAYER_O: O_EMOJI, +} + +# Custom ID format: tic_tac_toe:{current_player}:{row}:{col}:{next_player} +# - current_player: who occupies this cell (0 = empty, 1 = X, 2 = O) +# - row, col: grid position (0-2) +# - next_player: whose turn it is next (1 or 2) +CUSTOM_ID_PREFIX = "tic_tac_toe" + +# ============================================================================== +# TYPE DEFINITIONS +# ============================================================================== + +Board = list[list[int]] # 3x3 grid of player identifiers + +# ============================================================================== +# CUSTOM ID HELPERS +# ============================================================================== + + +def create_button_custom_id(current_player: int, row: int, col: int, next_player: int) -> str: + """Create a custom ID for a Tic Tac Toe button. + + Args: + current_player: The player occupying this cell (0 = empty) + row: Row position (0-2) + col: Column position (0-2) + next_player: The player whose turn is next (1 or 2) + """ + return f"{CUSTOM_ID_PREFIX}:{current_player}:{row}:{col}:{next_player}" + + +def parse_button_custom_id(custom_id: str) -> tuple[int, int, int, int]: + """Parse a button's custom ID to extract game state. + + Returns: + Tuple of (current_player, row, col, next_player) + """ + parts = custom_id.split(":") + return ( + int(parts[1]), # current_player + int(parts[2]), # row + int(parts[3]), # col + int(parts[4]), # next_player + ) + + +# ============================================================================== +# BUTTON CREATION +# ============================================================================== + + +def create_cell_button( + current_player: int, row: int, col: int, next_player: int, disabled: bool = False +) -> components.Button: + """Create a button representing a single Tic Tac Toe cell. + + Args: + current_player: The player occupying this cell (0 = empty) + row: Row position in the grid (0-2) + col: Column position in the grid (0-2) + next_player: The player whose turn is next + disabled: Whether the button should be disabled + + Returns: + A Discord Button component + """ + custom_id = create_button_custom_id(current_player, row, col, next_player) + + match current_player: + case 0: # Empty cell - clickable + return components.Button( + style=discord.ButtonStyle.primary, label=EMPTY_CELL, custom_id=custom_id, disabled=disabled + ) + case 1 | 2: # Occupied cell - always disabled + return components.Button( + style=discord.ButtonStyle.secondary, + emoji=PLAYER_SYMBOLS[current_player], + custom_id=custom_id, + disabled=True, + ) + case _: + raise ValueError(f"Invalid player identifier: {current_player}") + + +# ============================================================================== +# BOARD STATE MANAGEMENT +# ============================================================================== + + +def extract_board_from_components(action_rows: Sequence[components.ActionRow]) -> Board: + """Extract the current board state from Discord action rows. + + The board state is encoded in the custom_id of each button. This function + reconstructs the 3x3 game board from the button components. + + Args: + action_rows: The ActionRow components containing the game buttons + + Returns: + A 3x3 board represented as a list of lists + """ + board: list[list[int]] = [] + + for action_row in action_rows: + row: list[int] = [] + for button in action_row.components: + # Extract the current player value from the button's custom_id + current_player, _, _, _ = parse_button_custom_id(button.custom_id) # pyright: ignore [reportOptionalMemberAccess] + row.append(current_player) + board.append(row) + + return board + + +def check_winner(board: Board) -> int | None: + """Check if there's a winner on the board. + + Checks all rows, columns, and diagonals for three in a row. + + Args: + board: The current game board state + + Returns: + The winning player (1 or 2), or None if no winner + """ + # Check rows and columns + for i in range(3): + # Check row + if board[i][0] == board[i][1] == board[i][2] != PLAYER_NONE: + return board[i][0] + # Check column + if board[0][i] == board[1][i] == board[2][i] != PLAYER_NONE: + return board[0][i] + + # Check diagonals + if board[0][0] == board[1][1] == board[2][2] != PLAYER_NONE: + return board[0][0] + if board[0][2] == board[1][1] == board[2][0] != PLAYER_NONE: + return board[0][2] + + return None + + +def is_board_full(board: Board) -> bool: + """Check if the board is completely filled (tie game). + + Args: + board: The current game board state + + Returns: + True if no empty cells remain, False otherwise + """ + for row in board: + for cell in row: + if cell == PLAYER_NONE: + return False + return True + + +# ============================================================================== +# UI COMPONENT BUILDERS +# ============================================================================== + + +def create_game_buttons( + board: Board | None = None, next_player: int = PLAYER_X, disable_all: bool = False +) -> list[components.ActionRow]: + """Create the 3x3 grid of buttons for the Tic Tac Toe game. + + Args: + board: The current board state (None for a new game) + next_player: The player whose turn is next + disable_all: Whether to disable all buttons (game over) + + Returns: + List of ActionRow components, one per row of the game board + """ + if board is None: + # Initialize empty 3x3 board + board = [[PLAYER_NONE for _ in range(3)] for _ in range(3)] + + action_rows: list[components.ActionRow] = [] + + for row_idx, row in enumerate(board): + buttons = [ + create_cell_button( + current_player=cell_value, row=row_idx, col=col_idx, next_player=next_player, disabled=disable_all + ) + for col_idx, cell_value in enumerate(row) + ] + action_rows.append(components.ActionRow(*buttons)) + + return action_rows + + +def create_game_container(game_buttons: list[components.ActionRow], next_player: int) -> components.Container: + """Create the container for an active game. + + Args: + game_buttons: The 3x3 grid of game buttons + next_player: The player whose turn it is + + Returns: + A Container with the game title, turn indicator, and buttons + """ + return components.Container( + components.TextDisplay("## Tic Tac Toe"), + components.TextDisplay(f"**It is {PLAYER_SYMBOLS[next_player]}'s turn**"), + *game_buttons, + ) + + +def create_game_over_container(game_buttons: list[components.ActionRow], winner: int) -> components.Container: + """Create the container for a finished game. + + Args: + game_buttons: The final state of the game buttons + winner: The winning player (0 for tie, 1 or 2 for winners) + + Returns: + A Container with the game title, result message, and final board + """ + if winner == PLAYER_NONE: + result_message = "**It's a tie!**" + else: + result_message = f"**Player {PLAYER_SYMBOLS[winner]} won!**" + + return components.Container( + components.TextDisplay("## Tic Tac Toe"), + components.TextDisplay(result_message), + *game_buttons, + ) + + +# ============================================================================== +# BOT SETUP +# ============================================================================== + +bot = discord.Bot(intents=discord.Intents.all()) + +# ============================================================================== +# EVENT HANDLERS +# ============================================================================== + + +@bot.component_listener(lambda custom_id: custom_id.startswith(CUSTOM_ID_PREFIX)) +async def handle_tic_tac_toe_move(interaction: discord.ComponentInteraction[components.PartialButton]): + """Handle a player clicking a Tic Tac Toe cell. + + This function: + 1. Extracts the current board state from the message components + 2. Parses which cell was clicked and which player clicked it + 3. Updates the board with the new move + 4. Checks for a winner or tie + 5. Updates the message with the new game state + """ + assert interaction.custom_id is not None + assert interaction.message is not None + + # Extract board state from the existing message + # Components structure: [Container -> TextDisplay, TextDisplay, ActionRow, ActionRow, ActionRow] + game_action_rows = interaction.message.components[0].components[2:5] + board = extract_board_from_components(game_action_rows) + + # Parse the clicked button's custom_id to get move details + _, row, col, current_player = parse_button_custom_id(interaction.custom_id) + + # Determine next player (alternate between X and O) + next_player = PLAYER_O if current_player == PLAYER_X else PLAYER_X + + # Apply the move to the board + board[row][col] = current_player + + # Check game end conditions + winner = check_winner(board) + is_tie = is_board_full(board) + game_over = winner is not None or is_tie + + # Create updated button grid + updated_buttons = create_game_buttons(board=board, next_player=next_player, disable_all=game_over) + + # Update the message with new game state + if game_over: + final_winner = winner if winner is not None else PLAYER_NONE + await interaction.edit( + components=[create_game_over_container(updated_buttons, final_winner)], + ) + else: + await interaction.edit( + components=[create_game_container(updated_buttons, next_player)], + ) + + +# ============================================================================== +# SLASH COMMANDS +# ============================================================================== + + +@bot.slash_command() +async def tic_tac_toe(ctx: discord.ApplicationContext): + """Start a new Tic Tac Toe game.""" + initial_buttons = create_game_buttons(next_player=PLAYER_X) + await ctx.respond( + components=[create_game_container(initial_buttons, next_player=PLAYER_X)], + ) + + +# ============================================================================== +# BOT STARTUP +# ============================================================================== + + +@bot.event +async def on_ready(): + print(f"Bot ready! Logged in as {bot.user}") + + +bot.run(os.getenv("TOKEN")) diff --git a/examples/modal_dialogs.py b/examples/modal_dialogs.py deleted file mode 100644 index 885a6f42ac..0000000000 --- a/examples/modal_dialogs.py +++ /dev/null @@ -1,91 +0,0 @@ -# This example requires the `message_content` privileged intent for prefixed commands. - -import discord -from discord.ext import commands - -intents = discord.Intents.default() -intents.message_content = True - -bot = commands.Bot(command_prefix=commands.when_mentioned_or("!"), debug_guilds=[...], intents=intents) - - -class MyModal(discord.ui.Modal): - def __init__(self, *args, **kwargs) -> None: - super().__init__( - discord.ui.InputText( - label="Short Input", - placeholder="Placeholder Test", - ), - discord.ui.InputText( - label="Longer Input", - value="Longer Value\nSuper Long Value", - style=discord.InputTextStyle.long, - ), - *args, - **kwargs, - ) - - async def callback(self, interaction: discord.Interaction): - embed = discord.Embed( - title="Your Modal Results", - fields=[ - discord.EmbedField(name="First Input", value=self.children[0].value, inline=False), - discord.EmbedField(name="Second Input", value=self.children[1].value, inline=False), - ], - color=discord.Color.random(), - ) - await interaction.response.send_message(embeds=[embed]) - - -@bot.slash_command(name="modaltest") -async def modal_slash(ctx: discord.ApplicationContext): - """Shows an example of a modal dialog being invoked from a slash command.""" - modal = MyModal(title="Slash Command Modal") - await ctx.send_modal(modal) - - -@bot.message_command(name="messagemodal") -async def modal_message(ctx: discord.ApplicationContext, message: discord.Message): - """Shows an example of a modal dialog being invoked from a message command.""" - modal = MyModal(title="Message Command Modal") - modal.title = f"Modal for Message ID: {message.id}" - await ctx.send_modal(modal) - - -@bot.user_command(name="usermodal") -async def modal_user(ctx: discord.ApplicationContext, member: discord.Member): - """Shows an example of a modal dialog being invoked from a user command.""" - modal = MyModal(title="User Command Modal") - modal.title = f"Modal for User: {member.display_name}" - await ctx.send_modal(modal) - - -@bot.command() -async def modaltest(ctx: commands.Context): - """Shows an example of modals being invoked from an interaction component (e.g. a button or select menu)""" - - class MyView(discord.ui.View): - @discord.ui.button(label="Modal Test", style=discord.ButtonStyle.primary) - async def button_callback(self, button: discord.ui.Button, interaction: discord.Interaction): - modal = MyModal(title="Modal Triggered from Button") - await interaction.response.send_modal(modal) - - @discord.ui.select( - placeholder="Pick Your Modal", - min_values=1, - max_values=1, - options=[ - discord.SelectOption(label="First Modal", description="Shows the first modal"), - discord.SelectOption(label="Second Modal", description="Shows the second modal"), - ], - ) - async def select_callback(self, select: discord.ui.Select, interaction: discord.Interaction): - modal = MyModal(title="Temporary Title") - modal.title = select.values[0] - await interaction.response.send_modal(modal) - - view = MyView() - await ctx.send("Click Button, Receive Modal", view=view) - - -bot.run("TOKEN") diff --git a/examples/views/button_roles.py b/examples/views/button_roles.py deleted file mode 100644 index 12284e65bc..0000000000 --- a/examples/views/button_roles.py +++ /dev/null @@ -1,113 +0,0 @@ -import discord -from discord.ext import commands - -""" -Let users assign themselves roles by clicking on Buttons. -The view made is persistent, so it will work even when the bot restarts. - -See this example for more information about persistent views: -https://github.com/Pycord-Development/pycord/blob/master/examples/views/persistent.py -Make sure to load this cog when your bot starts! -""" - -# This is the list of role IDs that will be added as buttons. -role_ids = [...] - - -class RoleButton(discord.ui.Button): - def __init__(self, role: discord.Role): - """A button for one role. `custom_id` is needed for persistent views.""" - super().__init__( - label=role.name, - style=discord.ButtonStyle.primary, - custom_id=str(role.id), - ) - - async def callback(self, interaction: discord.Interaction): - """ - This function will be called any time a user clicks on this button. - - Parameters - ---------- - interaction: :class:`discord.Interaction` - The interaction object that was created when a user clicks on a button. - """ - # Get the user who clicked the button. - user = interaction.user - # Get the role this button is for (stored in the custom ID). - role = interaction.guild.get_role(int(self.custom_id)) - - if role is None: - # If the specified role does not exist, return nothing. - # Error handling could be done here. - return - - # Add the role and send a response to the user ephemerally (hidden to other users). - if role not in user.roles: - # Give the user the role if they don't already have it. - await user.add_roles(role) - await interaction.response.send_message( - f"🎉 You have been given the role {role.mention}!", - ephemeral=True, - ) - else: - # Otherwise, take the role away from the user. - await user.remove_roles(role) - await interaction.response.send_message( - f"❌ The {role.mention} role has been taken from you!", - ephemeral=True, - ) - - -class ButtonRoleCog(commands.Cog): - """ - A cog with a slash command for posting the message with buttons - and to initialize the view again when the bot is restarted. - """ - - def __init__(self, bot): - self.bot = bot - - # Pass a list of guild IDs to restrict usage to the supplied guild IDs. - @commands.slash_command(guild_ids=[...], description="Post the button role message") - async def post(self, ctx: discord.ApplicationContext): - """Slash command to post a new view with a button for each role.""" - - # timeout is None because we want this view to be persistent. - view = discord.ui.View(timeout=None) - - # Loop through the list of roles and add a new button to the view for each role. - for role_id in role_ids: - # Get the role from the guild by ID. - role = ctx.guild.get_role(role_id) - view.add_item(RoleButton(role)) - - await ctx.respond("Click a button to assign yourself a role", view=view) - - @commands.Cog.listener() - async def on_ready(self): - """ - This method is called every time the bot restarts. - If a view was already created before (with the same custom IDs for buttons), - it will be loaded and the bot will start watching for button clicks again. - """ - # We recreate the view as we did in the /post command. - view = discord.ui.View(timeout=None) - # Make sure to set the guild ID here to whatever server you want the buttons in! - guild = self.bot.get_guild(...) - for role_id in role_ids: - role = guild.get_role(role_id) - view.add_item(RoleButton(role)) - - # Add the view to the bot so that it will watch for button interactions. - self.bot.add_view(view) - - -def setup(bot): - bot.add_cog(ButtonRoleCog(bot)) - - -# The basic bot instance in a separate file should look something like this: -# bot = commands.Bot(command_prefix=commands.when_mentioned_or("!")) -# bot.load_extension("button_roles") -# bot.run("TOKEN") diff --git a/examples/views/channel_select.py b/examples/views/channel_select.py deleted file mode 100644 index 69a96e1eb0..0000000000 --- a/examples/views/channel_select.py +++ /dev/null @@ -1,39 +0,0 @@ -import discord - -# Channel selects (dropdowns) are a new type of select menu/dropdown Discord has added so users can select channels from a dropdown. - - -# Defines a simple View that allows the user to use the Select menu. -# In this view, we define the channel_select with `discord.ui.channel_select` -# Using the decorator automatically sets `select_type` to `discord.ComponentType.channel_select`. -class DropdownView(discord.ui.View): - @discord.ui.channel_select( - placeholder="Select channels...", min_values=1, max_values=3 - ) # Users can select a maximum of 3 channels in the dropdown - async def channel_select_dropdown(self, select: discord.ui.Select, interaction: discord.Interaction) -> None: - await interaction.response.send_message( - f"You selected the following channels:" + f", ".join(f"{channel.mention}" for channel in select.values) - ) - - -bot: discord.Bot = discord.Bot(debug_guilds=[...]) - - -@bot.slash_command() -async def channel_select(ctx: discord.ApplicationContext) -> None: - """Sends a message with our dropdown that contains a channel select.""" - - # Create the view containing our dropdown - view = DropdownView() - - # Sending a message containing our View - await ctx.respond("Select channels:", view=view) - - -@bot.event -async def on_ready() -> None: - print(f"Logged in as {bot.user} (ID: {bot.user.id})") - print("------") - - -bot.run("TOKEN") diff --git a/examples/views/confirm.py b/examples/views/confirm.py deleted file mode 100644 index cbf9cf1b8b..0000000000 --- a/examples/views/confirm.py +++ /dev/null @@ -1,60 +0,0 @@ -# This example requires the `message_content` privileged intent for prefixed commands. - -import discord -from discord.ext import commands - - -class Bot(commands.Bot): - def __init__(self): - intents = discord.Intents.default() - intents.message_content = True - super().__init__(command_prefix=commands.when_mentioned_or("!"), intents=intents) - - async def on_ready(self): - print(f"Logged in as {self.user} (ID: {self.user.id})") - print("------") - - -# Define a simple View that gives us a confirmation menu. -class Confirm(discord.ui.View): - def __init__(self): - super().__init__() - self.value = None - - # When the confirm button is pressed, set the inner value - # to `True` and stop the View from listening to more input. - # We also send the user an ephemeral message that we're confirming their choice. - @discord.ui.button(label="Confirm", style=discord.ButtonStyle.green) - async def confirm_callback(self, button: discord.ui.Button, interaction: discord.Interaction): - await interaction.response.send_message("Confirming", ephemeral=True) - self.value = True - self.stop() - - # This one is similar to the confirmation button except sets the inner value to `False`. - @discord.ui.button(label="Cancel", style=discord.ButtonStyle.grey) - async def cancel_callback(self, button: discord.ui.Button, interaction: discord.Interaction): - await interaction.response.send_message("Cancelling", ephemeral=True) - self.value = False - self.stop() - - -bot = Bot() - - -@bot.command() -async def ask(ctx: commands.Context): - """Asks the user a question to confirm something.""" - # We create the View and assign it to a variable so that we can wait for it later. - view = Confirm() - await ctx.send("Do you want to continue?", view=view) - # Wait for the View to stop listening for input... - await view.wait() - if view.value is None: - print("Timed out...") - elif view.value: - print("Confirmed...") - else: - print("Cancelled...") - - -bot.run("TOKEN") diff --git a/examples/views/counter.py b/examples/views/counter.py deleted file mode 100644 index d1ad096495..0000000000 --- a/examples/views/counter.py +++ /dev/null @@ -1,44 +0,0 @@ -# This example requires the `message_content` privileged intent for prefixed commands. - -import discord -from discord.ext import commands - - -class CounterBot(commands.Bot): - def __init__(self): - intents = discord.Intents.default() - intents.message_content = True - super().__init__(command_prefix=commands.when_mentioned_or("!"), intents=intents) - - async def on_ready(self): - print(f"Logged in as {self.user} (ID: {self.user.id})") - print("------") - - -# Define a simple View that gives us a counter button. -class Counter(discord.ui.View): - # When pressed, this increments the number displayed until it hits 5. - # When it hits 5, the counter button is disabled, and it turns green. - # NOTE: The name of the function does not matter to the library. - @discord.ui.button(label="0", style=discord.ButtonStyle.red) - async def count(self, button: discord.ui.Button, interaction: discord.Interaction): - number = int(button.label) if button.label else 0 - if number >= 4: - button.style = discord.ButtonStyle.green - button.disabled = True - button.label = str(number + 1) - - # Make sure to update the message with our updated selves - await interaction.response.edit_message(view=self) - - -bot = CounterBot() - - -@bot.command() -async def counter(ctx: commands.Context): - """Starts a counter for pressing.""" - await ctx.send("Press!", view=Counter()) - - -bot.run("TOKEN") diff --git a/examples/views/dropdown.py b/examples/views/dropdown.py deleted file mode 100644 index 0929763069..0000000000 --- a/examples/views/dropdown.py +++ /dev/null @@ -1,70 +0,0 @@ -import discord - - -# Defines a custom Select containing colour options -# that the user can choose. The callback function -# of this class is called when the user changes their choice. -class Dropdown(discord.ui.Select): - def __init__(self, bot_: discord.Bot): - # For example, you can use self.bot to retrieve a user or perform other functions in the callback. - # Alternatively you can use Interaction.client, so you don't need to pass the bot instance. - self.bot = bot_ - # Set the options that will be presented inside the dropdown: - options = [ - discord.SelectOption(label="Red", description="Your favourite colour is red", emoji="🟥"), - discord.SelectOption(label="Green", description="Your favourite colour is green", emoji="🟩"), - discord.SelectOption(label="Blue", description="Your favourite colour is blue", emoji="🟦"), - ] - - # The placeholder is what will be shown when no option is selected. - # The min and max values indicate we can only pick one of the three options. - # The options parameter, contents shown above, define the dropdown options. - super().__init__( - placeholder="Choose your favourite colour...", - min_values=1, - max_values=1, - options=options, - ) - - async def callback(self, interaction: discord.Interaction): - # Use the interaction object to send a response message containing - # the user's favourite colour or choice. The self object refers to the - # Select object, and the values attribute gets a list of the user's - # selected options. We only want the first one. - await interaction.response.send_message(f"Your favourite colour is {self.values[0]}") - - -# Defines a simple View that allows the user to use the Select menu. -class DropdownView(discord.ui.View): - def __init__(self, bot_: discord.Bot): - self.bot = bot_ - super().__init__() - - # Adds the dropdown to our View object - self.add_item(Dropdown(self.bot)) - - # Initializing the view and adding the dropdown can actually be done in a one-liner if preferred: - # super().__init__(Dropdown(self.bot)) - - -bot = discord.Bot(debug_guilds=[...]) - - -@bot.slash_command() -async def colour(ctx: discord.ApplicationContext): - """Sends a message with our dropdown that contains colour options.""" - - # Create the view containing our dropdown - view = DropdownView(bot) - - # Sending a message containing our View - await ctx.respond("Pick your favourite colour:", view=view) - - -@bot.event -async def on_ready(): - print(f"Logged in as {bot.user} (ID: {bot.user.id})") - print("------") - - -bot.run("TOKEN") diff --git a/examples/views/ephemeral.py b/examples/views/ephemeral.py deleted file mode 100644 index 03f11cde99..0000000000 --- a/examples/views/ephemeral.py +++ /dev/null @@ -1,46 +0,0 @@ -import discord - - -# Define a simple View that gives us a counter button. -class Counter(discord.ui.View): - # When pressed, this increments the number displayed until it hits 5. - # When it hits 5, the counter button is disabled, and it turns green. - # NOTE: The name of the function does not matter to the library. - @discord.ui.button(label="0", style=discord.ButtonStyle.red) - async def count(self, button: discord.ui.Button, interaction: discord.Interaction): - number = int(button.label) if button.label else 0 - if number >= 4: - button.style = discord.ButtonStyle.green - button.disabled = True - button.label = str(number + 1) - - # Make sure to update the message with our updated selves - await interaction.response.edit_message(view=self) - - -# Define a View that will give us our own personal counter button. -class EphemeralCounter(discord.ui.View): - # When this button is pressed, it will respond with a Counter View that will - # give the button presser their own personal button they can press 5 times. - @discord.ui.button(label="Click", style=discord.ButtonStyle.blurple) - async def receive(self, button: discord.ui.Button, interaction: discord.Interaction): - # ephemeral=True makes the message hidden from everyone except the button presser. - await interaction.response.send_message("Enjoy!", view=Counter(), ephemeral=True) - - -bot = discord.Bot(debug_guilds=[...]) - - -@bot.slash_command() -async def counter(ctx: discord.ApplicationContext): - """Starts a counter for pressing.""" - await ctx.respond("Press!", view=EphemeralCounter()) - - -@bot.event -async def on_ready(): - print(f"Logged in as {bot.user} (ID: {bot.user.id})") - print("------") - - -bot.run("TOKEN") diff --git a/examples/views/link.py b/examples/views/link.py deleted file mode 100644 index c7c463c0ae..0000000000 --- a/examples/views/link.py +++ /dev/null @@ -1,39 +0,0 @@ -from urllib.parse import quote_plus - -import discord - - -# Define a simple View that gives us a Google link button. -# We take in `query` as the query that the command author requests for. -class Google(discord.ui.View): - def __init__(self, query: str): - super().__init__() - # We need to quote the query string to make a valid url. Discord will raise an error if it isn't valid. - query = quote_plus(query) - url = f"https://www.google.com/search?q={query}" - - # Link buttons cannot be made with the - # decorator, so we have to manually create one. - # We add the quoted url to the button, and add the button to the view. - self.add_item(discord.ui.Button(label="Click Here", url=url)) - - # Initializing the view and adding the button can actually be done in a one-liner at the start if preferred: - # super().__init__(discord.ui.Button(label="Click Here", url=url)) - - -bot = discord.Bot(debug_guilds=[...]) - - -@bot.slash_command() -async def google(ctx: discord.ApplicationContext, query: str): - """Returns a google link for a query.""" - await ctx.respond(f"Google Result for: `{query}`", view=Google(query)) - - -@bot.event -async def on_ready(): - print(f"Logged in as {bot.user} (ID: {bot.user.id})") - print("------") - - -bot.run("TOKEN") diff --git a/examples/views/new_components.py b/examples/views/new_components.py deleted file mode 100644 index 1fb433e9a2..0000000000 --- a/examples/views/new_components.py +++ /dev/null @@ -1,77 +0,0 @@ -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.parent.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") diff --git a/examples/views/paginator.py b/examples/views/paginator.py deleted file mode 100644 index d40254f895..0000000000 --- a/examples/views/paginator.py +++ /dev/null @@ -1,301 +0,0 @@ -# Docs: https://docs.pycord.dev/en/master/ext/pages/index.html - -# This example demonstrates a standalone cog file with the bot instance in a separate file. - -# Note that the below examples use a Slash Command Group in a cog for -# better organization and doing so is not required for using ext.pages. - -import asyncio - -import discord -from discord.commands import SlashCommandGroup -from discord.ext import commands, pages - - -class PageTest(commands.Cog): - def __init__(self, bot): - self.bot = bot - self.pages = [ - "Page 1", - [ - discord.Embed(title="Page 2, Embed 1"), - discord.Embed(title="Page 2, Embed 2"), - ], - "Page Three", - discord.Embed(title="Page Four"), - discord.Embed( - title="Page Five", - fields=[ - discord.EmbedField(name="Example Field", value="Example Value", inline=False), - ], - ), - [ - discord.Embed(title="Page Six, Embed 1"), - discord.Embed(title="Page Seven, Embed 2"), - ], - ] - self.pages[3].set_image(url="https://c.tenor.com/pPKOYQpTO8AAAAAM/monkey-developer.gif") - self.pages[4].add_field(name="Another Example Field", value="Another Example Value", inline=False) - - self.more_pages = [ - "Second Page One", - discord.Embed(title="Second Page Two"), - discord.Embed(title="Second Page Three"), - ] - - self.even_more_pages = ["11111", "22222", "33333"] - - self.new_pages = [ - pages.Page( - content="Page 1 Title!", - embeds=[ - discord.Embed(title="New Page 1 Embed Title 1!"), - discord.Embed(title="New Page 1 Embed Title 2!"), - ], - ), - pages.Page( - content="Page 2 Title!", - embeds=[ - discord.Embed(title="New Page 2 Embed Title 1!"), - discord.Embed(title="New Page 2 Embed Title 2!"), - ], - ), - ] - - def get_pages(self): - return self.pages - - pagetest = SlashCommandGroup("pagetest", "Commands for testing ext.pages.") - - @pagetest.command(name="default") - async def pagetest_default(self, ctx: discord.ApplicationContext): - """Demonstrates using the paginator with the default options.""" - paginator = pages.Paginator(pages=self.get_pages()) - await paginator.respond(ctx.interaction, ephemeral=False) - - @pagetest.command(name="new") - async def pagetest_new(self, ctx: discord.ApplicationContext): - """Demonstrates using the paginator with the Page class.""" - paginator = pages.Paginator(pages=self.new_pages) - await paginator.respond(ctx.interaction, ephemeral=False) - - @pagetest.command(name="hidden") - async def pagetest_hidden(self, ctx: discord.ApplicationContext): - """Demonstrates using the paginator with disabled buttons hidden.""" - paginator = pages.Paginator(pages=self.get_pages(), show_disabled=False) - await paginator.respond(ctx.interaction, ephemeral=False) - - @pagetest.command(name="loop") - async def pagetest_loop(self, ctx: discord.ApplicationContext): - """Demonstrates using the loop_pages option.""" - paginator = pages.Paginator(pages=self.get_pages(), loop_pages=True) - await paginator.respond(ctx.interaction, ephemeral=False) - - @pagetest.command(name="strings") - async def pagetest_strings(self, ctx: discord.ApplicationContext): - """Demonstrates passing a list of strings as pages.""" - paginator = pages.Paginator(pages=["Page 1", "Page 2", "Page 3"], loop_pages=True) - await paginator.respond(ctx.interaction, ephemeral=False) - - @pagetest.command(name="timeout") - async def pagetest_timeout(self, ctx: discord.ApplicationContext): - """Demonstrates having the buttons be disabled when the paginator view times out.""" - paginator = pages.Paginator(pages=self.get_pages(), disable_on_timeout=True, timeout=30) - await paginator.respond(ctx.interaction, ephemeral=False) - - @pagetest.command(name="remove_buttons") - async def pagetest_remove(self, ctx: discord.ApplicationContext): - """Demonstrates using the default buttons, but removing some of them.""" - paginator = pages.Paginator(pages=self.get_pages()) - paginator.remove_button("first") - paginator.remove_button("last") - await paginator.respond(ctx.interaction, ephemeral=False) - - @pagetest.command(name="init") - async def pagetest_init(self, ctx: discord.ApplicationContext): - """Demonstrates how to pass a list of custom buttons when creating the Paginator instance.""" - page_buttons = [ - pages.PaginatorButton("first", label="<<-", style=discord.ButtonStyle.green), - pages.PaginatorButton("prev", label="<-", style=discord.ButtonStyle.green), - pages.PaginatorButton("page_indicator", style=discord.ButtonStyle.gray, disabled=True), - pages.PaginatorButton("next", label="->", style=discord.ButtonStyle.green), - pages.PaginatorButton("last", label="->>", style=discord.ButtonStyle.green), - ] - paginator = pages.Paginator( - pages=self.get_pages(), - show_disabled=True, - show_indicator=True, - use_default_buttons=False, - custom_buttons=page_buttons, - loop_pages=True, - ) - await paginator.respond(ctx.interaction, ephemeral=False) - - @pagetest.command(name="custom_buttons") - async def pagetest_custom_buttons(self, ctx: discord.ApplicationContext): - """Demonstrates adding buttons to the paginator when the default buttons are not used.""" - paginator = pages.Paginator( - pages=self.get_pages(), - use_default_buttons=False, - loop_pages=False, - show_disabled=False, - ) - paginator.add_button( - pages.PaginatorButton("prev", label="<", style=discord.ButtonStyle.green, loop_label="lst") - ) - paginator.add_button(pages.PaginatorButton("page_indicator", style=discord.ButtonStyle.gray, disabled=True)) - paginator.add_button(pages.PaginatorButton("next", style=discord.ButtonStyle.green, loop_label="fst")) - await paginator.respond(ctx.interaction, ephemeral=False) - - @pagetest.command(name="emoji_buttons") - async def pagetest_emoji_buttons(self, ctx: discord.ApplicationContext): - """Demonstrates using emojis for the paginator buttons instead of labels.""" - page_buttons = [ - pages.PaginatorButton("first", emoji="⏪", style=discord.ButtonStyle.green), - pages.PaginatorButton("prev", emoji="⬅", style=discord.ButtonStyle.green), - pages.PaginatorButton("page_indicator", style=discord.ButtonStyle.gray, disabled=True), - pages.PaginatorButton("next", emoji="➡", style=discord.ButtonStyle.green), - pages.PaginatorButton("last", emoji="⏩", style=discord.ButtonStyle.green), - ] - paginator = pages.Paginator( - pages=self.get_pages(), - show_disabled=True, - show_indicator=True, - use_default_buttons=False, - custom_buttons=page_buttons, - loop_pages=True, - ) - await paginator.respond(ctx.interaction, ephemeral=False) - - @pagetest.command(name="custom_view") - async def pagetest_custom_view(self, ctx: discord.ApplicationContext): - """Demonstrates passing a custom view to the paginator.""" - view = discord.ui.View( - discord.ui.Button(label="Test Button, Does Nothing", row=1), - ) - view.add_item( - discord.ui.Select( - placeholder="Test Select Menu, Does Nothing", - options=[ - discord.SelectOption( - label="Example Option", - value="Example Value", - description="This menu does nothing!", - ) - ], - ) - ) - paginator = pages.Paginator(pages=self.get_pages(), custom_view=view) - await paginator.respond(ctx.interaction, ephemeral=False) - - @pagetest.command(name="disable") - async def pagetest_disable(self, ctx: discord.ApplicationContext): - """Demonstrates disabling the paginator buttons and showing a custom page when disabled.""" - paginator = pages.Paginator(pages=self.get_pages()) - await paginator.respond(ctx.interaction, ephemeral=False) - await ctx.respond("Disabling paginator in 5 seconds...") - await asyncio.sleep(5) - disable_page = discord.Embed( - title="Paginator Disabled!", - description="This page is only shown when the paginator is disabled.", - ) - await paginator.disable(page=disable_page) - - @pagetest.command(name="cancel") - async def pagetest_cancel(self, ctx: discord.ApplicationContext): - """Demonstrates cancelling (stopping) the paginator and showing a custom page when cancelled.""" - paginator = pages.Paginator(pages=self.get_pages()) - await paginator.respond(ctx.interaction, ephemeral=False) - await ctx.respond("Cancelling paginator in 5 seconds...") - await asyncio.sleep(5) - cancel_page = discord.Embed( - title="Paginator Cancelled!", - description="This page is only shown when the paginator is cancelled.", - ) - await paginator.cancel(page=cancel_page) - - @pagetest.command(name="groups") - async def pagetest_groups(self, ctx: discord.ApplicationContext): - """Demonstrates using page groups to switch between different sets of pages.""" - page_buttons = [ - pages.PaginatorButton("first", label="<<-", style=discord.ButtonStyle.green), - pages.PaginatorButton("prev", label="<-", style=discord.ButtonStyle.green), - pages.PaginatorButton("page_indicator", style=discord.ButtonStyle.gray, disabled=True), - pages.PaginatorButton("next", label="->", style=discord.ButtonStyle.green), - pages.PaginatorButton("last", label="->>", style=discord.ButtonStyle.green), - ] - view = discord.ui.View(discord.ui.Button(label="Test Button, Does Nothing", row=2)) - view.add_item( - discord.ui.Select( - placeholder="Test Select Menu, Does Nothing", - options=[ - discord.SelectOption( - label="Example Option", - value="Example Value", - description="This menu does nothing!", - ) - ], - ) - ) - page_groups = [ - pages.PageGroup( - pages=self.get_pages(), - label="Main Page Group", - description="Main Pages for Main Things", - ), - pages.PageGroup( - pages=[ - "Second Set of Pages, Page 1", - "Second Set of Pages, Page 2", - "Look, it's group 2, page 3!", - ], - label="Second Page Group", - description="Secondary Pages for Secondary Things", - custom_buttons=page_buttons, - use_default_buttons=False, - custom_view=view, - ), - ] - paginator = pages.Paginator(pages=page_groups, show_menu=True) - await paginator.respond(ctx.interaction, ephemeral=False) - - @pagetest.command(name="update") - async def pagetest_update(self, ctx: discord.ApplicationContext): - """Demonstrates updating an existing paginator instance with different options.""" - paginator = pages.Paginator(pages=self.get_pages(), show_disabled=False) - await paginator.respond(ctx.interaction) - await asyncio.sleep(3) - await paginator.update(show_disabled=True, show_indicator=False) - - @pagetest.command(name="target") - async def pagetest_target(self, ctx: discord.ApplicationContext): - """Demonstrates sending the paginator to a different target than where it was invoked.""" - paginator = pages.Paginator(pages=self.get_pages()) - await paginator.respond(ctx.interaction, target=ctx.interaction.user) - - @commands.command() - async def pagetest_prefix(self, ctx: commands.Context): - """Demonstrates using the paginator with a prefix-based command.""" - paginator = pages.Paginator(pages=self.get_pages(), use_default_buttons=False) - paginator.add_button(pages.PaginatorButton("prev", label="<", style=discord.ButtonStyle.green)) - paginator.add_button(pages.PaginatorButton("page_indicator", style=discord.ButtonStyle.gray, disabled=True)) - paginator.add_button(pages.PaginatorButton("next", style=discord.ButtonStyle.green)) - await paginator.send(ctx) - - @commands.command() - async def pagetest_target(self, ctx: commands.Context): - """Demonstrates sending the paginator to a different target than where it was invoked (prefix version).""" - paginator = pages.Paginator(pages=self.get_pages()) - await paginator.send(ctx, target=ctx.author, target_message="Paginator sent!") - - -def setup(bot): - bot.add_cog(PageTest(bot)) - - -# The basic bot instance in a separate file should look something like this: -# intents = discord.Intents.default() -# intents.message_content = True # required for prefixed commands -# bot = commands.Bot(command_prefix=commands.when_mentioned_or("!"), intents=intents) -# bot.load_extension("paginator") -# bot.run("TOKEN") diff --git a/examples/views/persistent.py b/examples/views/persistent.py deleted file mode 100644 index db3ce2ebe3..0000000000 --- a/examples/views/persistent.py +++ /dev/null @@ -1,71 +0,0 @@ -# This example requires the `message_content` privileged intent for prefixed commands. - -import discord -from discord.ext import commands - - -# Define a simple View that persists between bot restarts. -# In order for a View to persist between restarts it needs to meet the following conditions: -# 1) The timeout of the View has to be set to None -# 2) Every item in the View has to have a custom_id set -# It is recommended that the custom_id be sufficiently unique to -# prevent conflicts with other buttons the bot sends. -# For this example the custom_id is prefixed with the name of the bot. -# Note that custom_ids can only be up to 100 characters long. -class PersistentView(discord.ui.View): - def __init__(self): - super().__init__(timeout=None) - - @discord.ui.button( - label="Green", - style=discord.ButtonStyle.green, - custom_id="persistent_view:green", - ) - async def green(self, button: discord.ui.Button, interaction: discord.Interaction): - await interaction.response.send_message("This is green.", ephemeral=True) - - @discord.ui.button(label="Red", style=discord.ButtonStyle.red, custom_id="persistent_view:red") - async def red(self, button: discord.ui.Button, interaction: discord.Interaction): - await interaction.response.send_message("This is red.", ephemeral=True) - - @discord.ui.button(label="Grey", style=discord.ButtonStyle.grey, custom_id="persistent_view:grey") - async def grey(self, button: discord.ui.Button, interaction: discord.Interaction): - await interaction.response.send_message("This is grey.", ephemeral=True) - - -class PersistentViewBot(commands.Bot): - def __init__(self): - intents = discord.Intents.default() - intents.message_content = True - super().__init__(command_prefix=commands.when_mentioned_or("!"), intents=intents) - self.persistent_views_added = False - - async def on_ready(self): - if not self.persistent_views_added: - # Register the persistent view for listening here. - # Note that this does not send the view to any message. - # In order to do this you need to first send a message with the View, which is shown below. - # If you have the message_id you can also pass it as a keyword argument, - # but for this example we don't have one. - self.add_view(PersistentView()) - self.persistent_views_added = True - - print(f"Logged in as {self.user} (ID: {self.user.id})") - print("------") - - -bot = PersistentViewBot() - - -@bot.command() -@commands.is_owner() -async def prepare(ctx: commands.Context): - """Starts a persistent view.""" - # In order for a persistent view to be listened to, it needs to be sent to an actual message. - # Call this method once just to store it somewhere. - # In a more complicated program you might fetch the message_id from a database for use later. - # However, this is outside the scope of this simple example. - await ctx.send("What's your favourite colour?", view=PersistentView()) - - -bot.run("TOKEN") diff --git a/examples/views/role_select.py b/examples/views/role_select.py deleted file mode 100644 index 68e73e2548..0000000000 --- a/examples/views/role_select.py +++ /dev/null @@ -1,39 +0,0 @@ -import discord - -# Role selects (dropdowns) are a new type of select menu/dropdown Discord has added so people can select server roles from a dropdown. - - -# Defines a simple View that allows the user to use the Select menu. -# In this view, we define the role_select with `discord.ui.role_select` -# Using the decorator automatically sets `select_type` to `discord.ComponentType.role_select`. -class DropdownView(discord.ui.View): - @discord.ui.role_select( - placeholder="Select roles...", min_values=1, max_values=3 - ) # Users can select a maximum of 3 roles in the dropdown - async def role_select_dropdown(self, select: discord.ui.Select, interaction: discord.Interaction) -> None: - await interaction.response.send_message( - f"You selected the following roles:" + f", ".join(f"{role.mention}" for role in select.values) - ) - - -bot: discord.Bot = discord.Bot(debug_guilds=[...]) - - -@bot.slash_command() -async def role_select(ctx: discord.ApplicationContext) -> None: - """Sends a message with our dropdown that contains a role select.""" - - # Create the view containing our dropdown - view = DropdownView() - - # Sending a message containing our View - await ctx.respond("Select roles:", view=view) - - -@bot.event -async def on_ready() -> None: - print(f"Logged in as {bot.user} (ID: {bot.user.id})") - print("------") - - -bot.run("TOKEN") diff --git a/examples/views/tic_tac_toe.py b/examples/views/tic_tac_toe.py deleted file mode 100644 index 1d240c2356..0000000000 --- a/examples/views/tic_tac_toe.py +++ /dev/null @@ -1,144 +0,0 @@ -# This example requires the 'message_content' privileged intent for prefixed commands. - -from typing import List - -import discord -from discord.ext import commands - - -# Defines a custom button that contains the logic of the game. -# The ['TicTacToe'] bit is for type hinting purposes to tell your IDE or linter -# what the type of `self.view` is. It is not required. -class TicTacToeButton(discord.ui.Button["TicTacToe"]): - def __init__(self, x: int, y: int): - # A label is required, but we don't need one so a zero-width space is used. - # The row parameter tells the View which row to place the button under. - # A View can only contain up to 5 rows -- each row can only have 5 buttons. - # Since a Tic Tac Toe grid is 3x3 that means we have 3 rows and 3 columns. - super().__init__(style=discord.ButtonStyle.secondary, label="\u200b", row=y) - self.x = x - self.y = y - - # This function is called whenever this particular button is pressed. - # This is part of the "meat" of the game logic. - async def callback(self, interaction: discord.Interaction): - assert self.view is not None - view: TicTacToe = self.view - state = view.board[self.y][self.x] - if state in (view.X, view.O): - return - - if view.current_player == view.X: - self.style = discord.ButtonStyle.danger - self.label = "X" - view.board[self.y][self.x] = view.X - view.current_player = view.O - content = "It is now O's turn" - else: - self.style = discord.ButtonStyle.success - self.label = "O" - view.board[self.y][self.x] = view.O - view.current_player = view.X - content = "It is now X's turn" - - self.disabled = True - winner = view.check_board_winner() - if winner is not None: - if winner == view.X: - content = "X won!" - elif winner == view.O: - content = "O won!" - else: - content = "It's a tie!" - - for child in view.children: - child.disabled = True - - view.stop() - - await interaction.response.edit_message(content=content, view=view) - - -# This is our actual board View. -class TicTacToe(discord.ui.View): - # This tells the IDE or linter that all our children will be TicTacToeButtons. - # This is not required. - children: List[TicTacToeButton] - X = -1 - O = 1 - Tie = 2 - - def __init__(self): - super().__init__() - self.current_player = self.X - self.board = [ - [0, 0, 0], - [0, 0, 0], - [0, 0, 0], - ] - - # Our board is made up of 3 by 3 TicTacToeButtons. - # The TicTacToeButton maintains the callbacks and helps steer - # the actual game. - for x in range(3): - for y in range(3): - self.add_item(TicTacToeButton(x, y)) - - # This method checks for the board winner and is used by the TicTacToeButton. - def check_board_winner(self): - # Check horizontal - for across in self.board: - value = sum(across) - if value == 3: - return self.O - elif value == -3: - return self.X - - # Check vertical - for line in range(3): - value = self.board[0][line] + self.board[1][line] + self.board[2][line] - if value == 3: - return self.O - elif value == -3: - return self.X - - # Check diagonals - diag = self.board[0][2] + self.board[1][1] + self.board[2][0] - if diag == 3: - return self.O - elif diag == -3: - return self.X - - diag = self.board[0][0] + self.board[1][1] + self.board[2][2] - if diag == -3: - return self.X - elif diag == 3: - return self.O - - # If we're here, we need to check if a tie has been reached. - if all(i != 0 for row in self.board for i in row): - return self.Tie - - return None - - -intents = discord.Intents.default() -intents.message_content = True - -bot = commands.Bot(command_prefix=commands.when_mentioned_or("!"), intents=intents) - - -@bot.command() -async def tic(ctx: commands.Context): - """Starts a tic-tac-toe game with yourself.""" - # Setting the reference message to ctx.message makes the bot reply to the member's message. - await ctx.send("Tic Tac Toe: X goes first", view=TicTacToe(), reference=ctx.message) - - -@bot.event -async def on_ready(): - print(f"Logged in as {bot.user} (ID: {bot.user.id})") - print("------") - - -bot.run("TOKEN") diff --git a/pyproject.toml b/pyproject.toml index 5ea5753ecb..02fd242b0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ dynamic = ["version"] dependencies = [ "aiohttp>=3.6.0,<4.0", "colorlog~=6.9.0", - "typing-extensions>=4,<5", + "typing-extensions>=4.12.0,<5", ] [project.urls] diff --git a/tests/test_components.py b/tests/test_components.py new file mode 100644 index 0000000000..d605e573b8 --- /dev/null +++ b/tests/test_components.py @@ -0,0 +1,211 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +import random +import string +from typing import Any + +import pytest + +import discord +from discord import components + +random.seed(42) + + +def random_string( + min_len, max_len, spaces: bool = True, punctuation: bool = True, separators: tuple[str, ...] = ("-", "_") +): + chars = string.ascii_letters + string.digits + if spaces: + chars += " " + if punctuation: + chars += string.punctuation + if separators: + chars += "".join(separators) + return "".join(random.choices(chars, k=random.randint(min_len, max_len))) + + +def generate_test_user_select_modal( + *, + modal_title: str, + modal_custom_id: str, + label_title: str, + label_description: str, + select_default_user_id: int, + select_custom_id: str, +): + MODAL: components.Modal = components.Modal( + components.Label( + components.UserSelect( + default_values=[components.DefaultSelectOption(id=select_default_user_id, type="user")], + custom_id=select_custom_id, + ), + label=label_title, + description=label_description, + ), + title=modal_title, + custom_id=modal_custom_id, + ) + EXPECTED_PAYLOAD = { + "title": modal_title, # 1-45 characters + "custom_id": modal_custom_id, # 1-100 characters + "components": [ + { + "type": 18, + "label": label_title, # 1-45 characters + "description": label_description, # 1-100 characters + "id": None, + "component": { + "type": 5, + "custom_id": select_custom_id, # int64 + "default_values": [ + { + "id": select_default_user_id, # int64 + "type": "user", + } + ], + "id": None, + "max_values": 1, + "min_values": 1, + }, + } + ], + } + + return MODAL, EXPECTED_PAYLOAD + + +USER_SELECT_MODAL_CASES = [ + generate_test_user_select_modal( + modal_title=random_string(1, 45), + modal_custom_id=random_string(1, 100), + label_title=random_string(1, 45), + label_description=random_string(1, 100), + select_default_user_id=random.randint(100000000000000000, 999999999999999999), + select_custom_id=random_string(1, 100), + ) + for _ in range(10) +] + + +@pytest.mark.parametrize( + ("modal", "payload"), + USER_SELECT_MODAL_CASES, +) +def test_user_select_modal_to_dict( + modal: components.Modal, + payload: dict[Any, Any], +): + # Test that the modal generates the expected payload + assert modal.to_dict() == payload + + +def generate_test_text_input_modal( + *, + modal_title: str, + modal_custom_id: str, + label_title: str, + label_description: str, + text_input_custom_id: str, + text_input_value: str, + text_input_placeholder: str, + text_input_min_length: int, + text_input_max_length: int, + text_input_required: bool, + text_input_multiline: bool, +): + MODAL: components.Modal = components.Modal( + components.Label( + components.TextInput( + custom_id=text_input_custom_id, + value=text_input_value, + placeholder=text_input_placeholder, + min_length=text_input_min_length, + max_length=text_input_max_length, + required=text_input_required, + style=discord.TextInputStyle.paragraph if text_input_multiline else discord.TextInputStyle.short, + ), + label=label_title, + description=label_description, + ), + title=modal_title, + custom_id=modal_custom_id, + ) + EXPECTED_PAYLOAD = { + "title": modal_title, # 1-45 characters + "custom_id": modal_custom_id, # 1-100 characters + "components": [ + { + "type": 18, + "label": label_title, # 1-45 characters + "description": label_description, # 1-100 characters + "id": None, + "component": { + "type": 4, + "custom_id": text_input_custom_id, # 1-100 characters + "value": text_input_value, # 0-4000 characters + "placeholder": text_input_placeholder, # 0-100 characters + "min_length": text_input_min_length, # 0-4000 + "max_length": text_input_max_length, # 1-4000 + "style": 2 if text_input_multiline else 1, + "id": None, + }, + } + ], + } + if not text_input_required: + EXPECTED_PAYLOAD["components"][0]["component"]["required"] = False # pyright: ignore[reportArgumentType] + + return MODAL, EXPECTED_PAYLOAD + + +TEXT_INPUT_MODAL_CASES = [ + generate_test_text_input_modal( + modal_title=random_string(1, 45), + modal_custom_id=random_string(1, 100), + label_title=random_string(1, 45), + label_description=random_string(1, 100), + text_input_custom_id=random_string(1, 100), + text_input_value=random_string(1, 4000), + text_input_placeholder=random_string(1, 100), + text_input_min_length=random.randint(0, 4000), + text_input_max_length=random.randint(1, 4000), + text_input_required=random.choice([True, False]), + text_input_multiline=random.choice([True, False]), + ) + for _ in range(10) +] + + +@pytest.mark.parametrize( + ("modal", "payload"), + TEXT_INPUT_MODAL_CASES, +) +def test_text_input_modal_to_dict( + modal: components.Modal, + payload: dict[Any, Any], +): + # Test that the modal generates the expected payload + assert modal.to_dict() == payload diff --git a/uv.lock b/uv.lock index f3a3804885..fb1732afc9 100644 --- a/uv.lock +++ b/uv.lock @@ -1493,7 +1493,7 @@ requires-dist = [ { name = "colorlog", specifier = "~=6.9.0" }, { name = "msgspec", marker = "extra == 'speed'", specifier = "~=0.19.0" }, { name = "pynacl", marker = "extra == 'voice'", specifier = ">=1.3.0,<1.6" }, - { name = "typing-extensions", specifier = ">=4,<5" }, + { name = "typing-extensions", specifier = ">=4.12.0,<5" }, ] provides-extras = ["speed", "voice"]