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 3b2440411b..e19bf101b4 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 * @@ -39,7 +39,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 0dbccca5e6..9369fea99e 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -27,6 +27,7 @@ import asyncio import time +from collections.abc import Sequence from typing import ( TYPE_CHECKING, Callable, @@ -77,12 +78,12 @@ ) from .channel.thread import Thread from .client import Client + from .components import AnyComponent from .embeds import Embed from .message import Message, MessageReference, PartialMessage from .poll import Poll 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 @@ -319,7 +320,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 = ..., @@ -340,7 +341,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 = ..., @@ -361,7 +362,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 = ..., @@ -382,7 +383,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 = ..., @@ -404,7 +405,7 @@ async def send( allowed_mentions=None, reference=None, mention_author=None, - view=None, + components=None, poll=None, suppress=None, silent=None, @@ -473,8 +474,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. @@ -565,17 +568,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() @@ -608,7 +609,7 @@ async def send( allowed_mentions=allowed_mentions, message_reference=_reference, stickers=stickers, - components=components, + components=components_p, flags=flags.value, poll=poll, ) @@ -627,17 +628,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(): - await 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/app/cache.py b/discord/app/cache.py index 7f97b5f837..d8f53c929a 100644 --- a/discord/app/cache.py +++ b/discord/app/cache.py @@ -40,8 +40,6 @@ from ..types.message import Message as MessagePayload from ..types.sticker import GuildSticker as GuildStickerPayload from ..types.user import User as UserPayload -from ..ui.modal import Modal -from ..ui.view import View from ..user import User if TYPE_CHECKING: @@ -85,20 +83,6 @@ async def store_sticker(self, guild: Guild, data: GuildStickerPayload) -> GuildS async def delete_sticker(self, sticker_id: int) -> None: ... - # interactions - - async def store_view(self, view: View, message_id: int | None) -> None: ... - - async def delete_view_on(self, message_id: int) -> None: ... - - async def get_all_views(self) -> list[View]: ... - - async def store_modal(self, modal: Modal, user_id: int) -> None: ... - - async def delete_modal(self, custom_id: str) -> None: ... - - async def get_all_modals(self) -> list[Modal]: ... - # guilds async def get_all_guilds(self) -> list[Guild]: ... @@ -186,8 +170,6 @@ def __init__(self, max_messages: int | None = None) -> None: self._guilds: dict[int, Guild] = {} self._polls: dict[int, Poll] = {} self._stickers: dict[int, list[GuildSticker]] = {} - self._views: dict[str, View] = {} - self._modals: dict[str, Modal] = {} self._sounds: dict[int, SoundboardSound] = {} self._messages: Deque[Message] = deque(maxlen=self.max_messages) @@ -206,9 +188,6 @@ async def clear(self, views: bool = True) -> None: self._guilds: dict[int, Guild] = {} self._polls: dict[int, Poll] = {} self._stickers: dict[int, list[GuildSticker]] = {} - if views: - self._views: dict[str, View] = {} - self._modals: dict[str, Modal] = {} self._messages: Deque[Message] = deque(maxlen=self.max_messages) self._emojis: dict[int, list[GuildEmoji | AppEmoji]] = {} @@ -261,25 +240,6 @@ async def store_sticker(self, guild: Guild, data: GuildStickerPayload) -> GuildS async def delete_sticker(self, sticker_id: int) -> None: self._stickers.pop(sticker_id, None) - # interactions - - async def delete_view_on(self, message_id: int) -> View | None: - for view in await self.get_all_views(): - if view.message and view.message.id == message_id: - return view - - async def store_view(self, view: View, message_id: int) -> None: - self._views[str(message_id or view.id)] = view - - async def get_all_views(self) -> list[View]: - return list(self._views.values()) - - async def store_modal(self, modal: Modal) -> None: - self._modals[modal.custom_id] = modal - - async def get_all_modals(self) -> list[Modal]: - return list(self._modals.values()) - # guilds async def get_all_guilds(self) -> list[Guild]: diff --git a/discord/app/event_emitter.py b/discord/app/event_emitter.py index 5af1b6a34a..db93139b66 100644 --- a/discord/app/event_emitter.py +++ b/discord/app/event_emitter.py @@ -39,6 +39,13 @@ class Event(ABC): __event_name__: str + @classmethod + def event_type(cls) -> type[Self]: + # this is used for cases such as the ChannelCreate event, where the actual event class will be a dinamically created + # subclass of Event and the relevant channel class, where this method will be overriden to return ChannelCreate + # instead of (RelevantChannel, Event) + return cls + @classmethod @abstractmethod async def __load__(cls, data: Any, state: "ConnectionState") -> Self | None: ... diff --git a/discord/app/state.py b/discord/app/state.py index d62828e729..e2345e7c27 100644 --- a/discord/app/state.py +++ b/discord/app/state.py @@ -56,7 +56,7 @@ from ..enums import ChannelType, InteractionType, Status, try_enum from ..flags import ApplicationFlags, Intents, MemberCacheFlags from ..guild import Guild -from ..interactions import Interaction +from ..interactions import BaseInteraction from ..invite import Invite from ..member import Member from ..mentions import AllowedMentions @@ -68,8 +68,6 @@ from ..raw_models import * from ..role import Role from ..sticker import GuildSticker -from ..ui.modal import Modal -from ..ui.view import View from ..user import ClientUser, User from ..utils.private import get_as_snowflake, parse_time, sane_wait_for from .cache import Cache @@ -344,20 +342,6 @@ async def maybe_store_app_emoji(self, application_id: int, data: EmojiPayload) - async def store_sticker(self, guild: Guild, data: GuildStickerPayload) -> GuildSticker: return await self.cache.store_sticker(guild, data) - async def store_view(self, view: View, message_id: int | None = None) -> None: - await self.cache.store_view(view, message_id) - - async def store_modal(self, modal: Modal, user_id: int) -> None: - await self.cache.store_modal(modal, user_id) - - async def prevent_view_updates_for(self, message_id: int) -> View | None: - return await self.cache.delete_view_on(message_id) - - async def get_persistent_views(self) -> Sequence[View]: - views = await self.cache.get_all_views() - persistent_views = {view.id: view for view in views if view.is_persistent()} - return list(persistent_views.values()) - async def get_guilds(self) -> list[Guild]: return await self.cache.get_all_guilds() diff --git a/discord/bot.py b/discord/bot.py index bc9c683a43..e0e4850285 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 .commands import ( ApplicationCommand, @@ -59,14 +63,17 @@ from .enums import IntegrationType, InteractionContextType, InteractionType from .errors import CheckFailure, DiscordException from .events import InteractionCreate -from .interactions import Interaction +from .interactions import BaseInteraction from .shard import AutoShardedClient 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]] @@ -756,7 +763,7 @@ async def on_connect(): cmd.id = i["id"] self._application_commands[cmd.id] = cmd - async def process_application_commands(self, interaction: Interaction, auto_sync: bool | None = None) -> None: + async def process_application_commands(self, interaction: BaseInteraction, auto_sync: bool | None = None) -> None: """|coro| This function processes the commands that have been registered @@ -775,7 +782,7 @@ async def process_application_commands(self, interaction: Interaction, auto_sync Parameters ---------- - interaction: :class:`discord.Interaction` + interaction: :class:`discord.BaseInteraction` The interaction to process auto_sync: Optional[:class:`bool`] Whether to automatically sync and unregister the command if it is not found in the internal cache. This will @@ -813,7 +820,7 @@ async def process_application_commands(self, interaction: Interaction, auto_sync await self.sync_commands() else: await self.sync_commands(check_guilds=[guild_id]) - return self._bot.dispatch("unknown_application_command", interaction) + # return self._bot.dispatch("unknown_application_command", interaction) if interaction.type is InteractionType.auto_complete: return self._bot.dispatch("application_command_auto_complete", interaction, command) @@ -823,7 +830,9 @@ async def process_application_commands(self, interaction: Interaction, auto_sync interaction.command = command await self.invoke_application_command(ctx) - async def on_application_command_auto_complete(self, interaction: Interaction, command: ApplicationCommand) -> None: + async def on_application_command_auto_complete( + self, interaction: BaseInteraction, command: ApplicationCommand + ) -> None: async def callback() -> None: ctx = await self.get_autocomplete_context(interaction) interaction.command = command @@ -1016,7 +1025,7 @@ def walk_application_commands(self) -> Generator[ApplicationCommand]: yield command async def get_application_context( - self, interaction: Interaction, cls: Any = ApplicationContext + self, interaction: BaseInteraction, cls: Any = ApplicationContext ) -> ApplicationContext: r"""|coro| @@ -1027,7 +1036,7 @@ async def get_application_context( Parameters ----------- - interaction: :class:`discord.Interaction` + interaction: :class:`discord.BaseInteraction` The interaction to get the invocation context from. cls The factory class that will be used to create the context. @@ -1044,7 +1053,7 @@ class be provided, it must be similar enough to return cls(self, interaction) async def get_autocomplete_context( - self, interaction: Interaction, cls: Any = AutocompleteContext + self, interaction: BaseInteraction, cls: Any = AutocompleteContext ) -> AutocompleteContext: r"""|coro| @@ -1055,7 +1064,7 @@ async def get_autocomplete_context( Parameters ----------- - interaction: :class:`discord.Interaction` + interaction: :class:`discord.BaseInteraction` The interaction to get the invocation context from. cls The factory class that will be used to create the context. @@ -1153,7 +1162,7 @@ def __init__(self, description=None, *args, **options): self._before_invoke = None self._after_invoke = None - self._bot.add_listener(self.on_interaction, event=InteractionCreate) + # self._bot.add_listener(self.on_interaction, event=InteractionCreate) async def on_connect(self): if self.auto_sync_commands: diff --git a/discord/client.py b/discord/client.py index 27667955e6..9d7aa0b974 100644 --- a/discord/client.py +++ b/discord/client.py @@ -65,7 +65,6 @@ from .stage_instance import StageInstance from .sticker import GuildSticker, StandardSticker, StickerPack, _sticker_factory from .template import Template -from .ui.view import View from .user import ClientUser, User from .utils import MISSING from .utils.private import ( @@ -82,7 +81,7 @@ if TYPE_CHECKING: from .abc import PrivateChannel, Snowflake, SnowflakeTime from .channel import DMChannel, GuildChannel - from .interactions import Interaction + from .interactions import BaseInteraction from .member import Member from .message import Message from .poll import Poll @@ -133,7 +132,7 @@ def _cleanup_loop(loop: asyncio.AbstractEventLoop) -> None: loop.close() -class Client: +class Client(Gear): r"""Represents a client connection that connects to Discord. This class is used to interact with the Discord WebSocket and API. @@ -239,8 +238,11 @@ def __init__( self, *, loop: asyncio.AbstractEventLoop | None = None, + discord_api_url: str = "https://discord.com/api/v10", **options: Any, ): + super().__init__() + self._flavor = options.get("flavor", logging.INFO) self._debug = options.get("debug", False) self._banner_module = options.get("banner_module") @@ -260,6 +262,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} @@ -282,9 +285,7 @@ def __init__( self._connection._get_client = lambda: self self._event_handlers: dict[str, list[Coro]] = {} - self._main_gear: Gear = Gear() - - self._connection.emitter.add_receiver(self._handle_event) + self._connection.emitter.add_receiver(self._gather_events) if VoiceClient.warn_nacl: VoiceClient.warn_nacl = False @@ -293,8 +294,8 @@ def __init__( # Used to hard-reference tasks so they don't get garbage collected (discarded with done_callbacks) self._tasks = set() - async def _handle_event(self, event: Event) -> None: - await asyncio.gather(*self._main_gear._handle_event(event)) + async def _gather_events(self, event: Event) -> None: + await asyncio.gather(*self._handle_event(event)) async def __aenter__(self) -> Client: loop = asyncio.get_running_loop() @@ -315,42 +316,6 @@ async def __aexit__( if not self.is_closed(): await self.close() - # Gear methods - - @copy_doc(Gear.attach_gear) - def attach_gear(self, gear: Gear) -> None: - return self._main_gear.attach_gear(gear) - - @copy_doc(Gear.detach_gear) - def detach_gear(self, gear: Gear) -> None: - return self._main_gear.detach_gear(gear) - - @copy_doc(Gear.add_listener) - def add_listener( - self, - callback: Callable[[Event], Awaitable[None]], - *, - event: type[Event] | Undefined = MISSING, - is_instance_function: bool = False, - once: bool = False, - ) -> None: - return self._main_gear.add_listener(callback, event=event, is_instance_function=is_instance_function, once=once) - - @copy_doc(Gear.remove_listener) - def remove_listener( - self, - callback: Callable[[Event], Awaitable[None]], - event: type[Event] | Undefined = MISSING, - is_instance_function: bool = False, - ) -> None: - return self._main_gear.remove_listener(callback, event=event, is_instance_function=is_instance_function) - - @copy_doc(Gear.listen) - def listen( - self, event: type[Event] | Undefined = MISSING, once: bool = False - ) -> Callable[[Callable[[Event], Awaitable[None]]], Callable[[Event], Awaitable[None]]]: - return self._main_gear.listen(event=event, once=once) - # internals def _get_websocket(self, guild_id: int | None = None, *, shard_id: int | None = None) -> DiscordWebSocket: @@ -519,7 +484,7 @@ async def on_error(self, event_method: str, *args: Any, **kwargs: Any) -> None: print(f"Ignoring exception in {event_method}", file=sys.stderr) traceback.print_exc() - async def on_view_error(self, error: Exception, item: Item, interaction: Interaction) -> None: + async def on_view_error(self, error: Exception, item: Item, interaction: BaseInteraction) -> None: """|coro| The default view error handler provided by the client. @@ -532,7 +497,7 @@ async def on_view_error(self, error: Exception, item: Item, interaction: Interac The exception that was raised. item: :class:`Item` The item that the user interacted with. - interaction: :class:`Interaction` + interaction: :class:`BaseInteraction` The interaction that was received. """ @@ -542,7 +507,7 @@ async def on_view_error(self, error: Exception, item: Item, interaction: Interac ) traceback.print_exception(error.__class__, error, error.__traceback__, file=sys.stderr) - async def on_modal_error(self, error: Exception, interaction: Interaction) -> None: + async def on_modal_error(self, error: Exception, interaction: BaseInteraction) -> None: """|coro| The default modal error handler provided by the client. @@ -554,7 +519,7 @@ async def on_modal_error(self, error: Exception, interaction: Interaction) -> No ---------- error: :class:`Exception` The exception that was raised. - interaction: :class:`Interaction` + interaction: :class:`BaseInteraction` The interaction that was received. """ @@ -683,7 +648,7 @@ async def connect(self, *, reconnect: bool = True) -> None: aiohttp.ClientError, asyncio.TimeoutError, ) as exc: - self.dispatch("disconnect") + # self.dispatch("disconnect") # TODO: dispatch event if not reconnect: await self.close() if isinstance(exc, ConnectionClosed) and exc.code == 1000: @@ -1710,47 +1675,6 @@ async def create_dm(self, user: Snowflake) -> DMChannel: data = await state.http.start_private_message(user.id) return await state.add_dm_channel(data) - async 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") - - await self._connection.store_view(view, message_id) - - async def get_persistent_views(self) -> Sequence[View]: - """A sequence of persistent views added to the client. - - .. versionadded:: 2.0 - """ - return await self._connection.get_persistent_views() - async def fetch_role_connection_metadata_records( self, ) -> list[ApplicationRoleConnectionMetadata]: diff --git a/discord/commands/context.py b/discord/commands/context.py deleted file mode 100644 index 7d5810e2bd..0000000000 --- a/discord/commands/context.py +++ /dev/null @@ -1,434 +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, TypeVar - -import discord.abc -from discord.interactions import Interaction, InteractionMessage, InteractionResponse -from discord.webhook.async_ import Webhook - -if TYPE_CHECKING: - from typing import Awaitable, Callable - - from typing_extensions import ParamSpec - - import discord - - from .. import Bot - from ..app.state import ConnectionState - from ..client import ClientUser - from ..cog import Cog - from ..guild import Guild - from ..interactions import InteractionChannel - from ..member import Member - from ..message import Message - from ..permissions import Permissions - from ..user import User - from ..voice_client import VoiceClient - from ..webhook import WebhookMessage - from .core import ApplicationCommand, Option - -from ..utils.private import copy_doc - -T = TypeVar("T") -CogT = TypeVar("CogT", bound="Cog") - -if TYPE_CHECKING: - P = ParamSpec("P") -else: - P = TypeVar("P") - -__all__ = ("ApplicationContext", "AutocompleteContext") - - -class ApplicationContext(discord.abc.Messageable): - """Represents a Discord application command interaction context. - - This class is not created manually and is instead passed to application - commands as the first parameter. - - .. versionadded:: 2.0 - - Attributes - ---------- - bot: :class:`.Bot` - The bot that the command belongs to. - interaction: :class:`.Interaction` - The interaction object that invoked the command. - """ - - def __init__(self, bot: Bot, interaction: Interaction): - self.bot = bot - self.interaction = interaction - - # below attributes will be set after initialization - self.focused: Option = None # type: ignore - self.value: str = None # type: ignore - self.options: dict = None # type: ignore - - self._state: ConnectionState = self.interaction._state - - async def _get_channel(self) -> InteractionChannel | None: - return self.interaction.channel - - async def invoke( - self, - command: ApplicationCommand[CogT, P, T], - /, - *args: P.args, - **kwargs: P.kwargs, - ) -> T: - r"""|coro| - - Calls a command with the arguments given. - This is useful if you want to just call the callback that a - :class:`.ApplicationCommand` holds internally. - - .. note:: - - This does not handle converters, checks, cooldowns, pre-invoke, - or after-invoke hooks in any matter. It calls the internal callback - directly as-if it was a regular function. - You must take care in passing the proper arguments when - using this function. - - Parameters - ----------- - command: :class:`.ApplicationCommand` - The command that is going to be called. - \*args - The arguments to use. - \*\*kwargs - The keyword arguments to use. - - Raises - ------- - TypeError - The command argument to invoke is missing. - """ - return await command(self, *args, **kwargs) - - @property - def command(self) -> ApplicationCommand | None: - """The command that this context belongs to.""" - return self.interaction.command - - @command.setter - def command(self, value: ApplicationCommand | None) -> None: - self.interaction.command = value - - @property - def channel(self) -> InteractionChannel | None: - """Union[:class:`abc.GuildChannel`, :class:`PartialMessageable`, :class:`Thread`]: - Returns the channel associated with this context's command. Shorthand for :attr:`.Interaction.channel`. - """ - return self.interaction.channel - - @property - def channel_id(self) -> int | None: - """Returns the ID of the channel associated with this context's command. - Shorthand for :attr:`.Interaction.channel_id`. - """ - return self.interaction.channel_id - - @property - def guild(self) -> Guild | None: - """Returns the guild associated with this context's command. - Shorthand for :attr:`.Interaction.guild`. - """ - return self.interaction.guild - - @property - def guild_id(self) -> int | None: - """Returns the ID of the guild associated with this context's command. - Shorthand for :attr:`.Interaction.guild_id`. - """ - return self.interaction.guild_id - - @property - def locale(self) -> str | None: - """Returns the locale of the guild associated with this context's command. - Shorthand for :attr:`.Interaction.locale`. - """ - return self.interaction.locale - - @property - def guild_locale(self) -> str | None: - """Returns the locale of the guild associated with this context's command. - Shorthand for :attr:`.Interaction.guild_locale`. - """ - return self.interaction.guild_locale - - @property - def app_permissions(self) -> Permissions: - return self.interaction.app_permissions - - @property - def me(self) -> Member | ClientUser | None: - """Union[:class:`.Member`, :class:`.ClientUser`]: - Similar to :attr:`.Guild.me` except it may return the :class:`.ClientUser` in private message - message contexts, or when :meth:`Intents.guilds` is absent. - """ - return self.interaction.guild.me if self.interaction.guild is not None else self.bot.user - - @property - def message(self) -> Message | None: - """Returns the message sent with this context's command. - Shorthand for :attr:`.Interaction.message`, if applicable. - """ - return self.interaction.message - - @property - def user(self) -> Member | User: - """Returns the user that sent this context's command. - Shorthand for :attr:`.Interaction.user`. - """ - return self.interaction.user # type: ignore # command user will never be None - - author: Member | User = user - - @property - def voice_client(self) -> VoiceClient | None: - """Returns the voice client associated with this context's command. - Shorthand for :attr:`Interaction.guild.voice_client<~discord.Guild.voice_client>`, if applicable. - """ - if self.interaction.guild is None: - return None - - return self.interaction.guild.voice_client - - @property - def response(self) -> InteractionResponse: - """Returns the response object associated with this context's command. - Shorthand for :attr:`.Interaction.response`. - """ - return self.interaction.response - - @property - def selected_options(self) -> list[dict[str, Any]] | None: - """The options and values that were selected by the user when sending the command. - - Returns - ------- - Optional[List[Dict[:class:`str`, Any]]] - A dictionary containing the options and values that were selected by the user when the command - was processed, if applicable. Returns ``None`` if the command has not yet been invoked, - or if there are no options defined for that command. - """ - return self.interaction.data.get("options", None) - - @property - def unselected_options(self) -> list[Option] | None: - """The options that were not provided by the user when sending the command. - - Returns - ------- - Optional[List[:class:`.Option`]] - A list of Option objects (if any) that were not selected by the user when the command was processed. - Returns ``None`` if there are no options defined for that command. - """ - if self.command.options is not None: # type: ignore - if self.selected_options: - return [ - option - for option in self.command.options # type: ignore - if option.to_dict()["name"] not in [opt["name"] for opt in self.selected_options] - ] - else: - return self.command.options # type: ignore - return None - - @property - def attachment_size_limit(self) -> int: - """Returns the attachment size limit associated with this context's interaction. - Shorthand for :attr:`.Interaction.attachment_size_limit`. - """ - return self.interaction.attachment_size_limit - - @property - @copy_doc(InteractionResponse.send_modal) - def send_modal(self) -> Callable[..., Awaitable[Interaction]]: - return self.interaction.response.send_modal - - @property - @copy_doc(Interaction.respond) - def respond(self, *args, **kwargs) -> Callable[..., Awaitable[Interaction | WebhookMessage]]: - return self.interaction.respond - - @property - @copy_doc(InteractionResponse.send_message) - def send_response(self) -> Callable[..., Awaitable[Interaction]]: - if not self.interaction.response.is_done(): - return self.interaction.response.send_message - else: - raise RuntimeError( - f"Interaction was already issued a response. Try using {type(self).__name__}.send_followup() instead." - ) - - @property - @copy_doc(Webhook.send) - def send_followup(self) -> Callable[..., Awaitable[WebhookMessage]]: - if self.interaction.response.is_done(): - return self.followup.send - else: - raise RuntimeError( - f"Interaction was not yet issued a response. Try using {type(self).__name__}.respond() first." - ) - - @property - @copy_doc(InteractionResponse.defer) - def defer(self) -> Callable[..., Awaitable[None]]: - return self.interaction.response.defer - - @property - def followup(self) -> Webhook: - """Returns the followup webhook for followup interactions.""" - return self.interaction.followup - - async def delete(self, *, delay: float | None = None) -> None: - """|coro| - - Deletes the original interaction response message. - - This is a higher level interface to :meth:`Interaction.delete_original_response`. - - Parameters - ---------- - delay: Optional[:class:`float`] - If provided, the number of seconds to wait before deleting the message. - - Raises - ------ - HTTPException - Deleting the message failed. - Forbidden - You do not have proper permissions to delete the message. - """ - if not self.interaction.response.is_done(): - await self.defer() - - return await self.interaction.delete_original_response(delay=delay) - - @property - @copy_doc(Interaction.edit_original_response) - def edit(self) -> Callable[..., Awaitable[InteractionMessage]]: - return self.interaction.edit_original_response - - @property - def cog(self) -> Cog | None: - """Returns the cog associated with this context's command. - ``None`` if it does not exist. - """ - if self.command is None: - return None - - return self.command.cog - - def is_guild_authorised(self) -> bool: - """:class:`bool`: Checks if the invoked command is guild-installed. - This is a shortcut for :meth:`Interaction.is_guild_authorised`. - - There is an alias for this called :meth:`.is_guild_authorized`. - - .. versionadded:: 2.7 - """ - return self.interaction.is_guild_authorised() - - def is_user_authorised(self) -> bool: - """:class:`bool`: Checks if the invoked command is user-installed. - This is a shortcut for :meth:`Interaction.is_user_authorised`. - - There is an alias for this called :meth:`.is_user_authorized`. - - .. versionadded:: 2.7 - """ - return self.interaction.is_user_authorised() - - def is_guild_authorized(self) -> bool: - """:class:`bool`: An alias for :meth:`.is_guild_authorised`. - - .. versionadded:: 2.7 - """ - return self.is_guild_authorised() - - def is_user_authorized(self) -> bool: - """:class:`bool`: An alias for :meth:`.is_user_authorised`. - - .. versionadded:: 2.7 - """ - return self.is_user_authorised() - - -class AutocompleteContext: - """Represents context for a slash command's option autocomplete. - - This class is not created manually and is instead passed to an :class:`.Option`'s autocomplete callback. - - .. versionadded:: 2.0 - - Attributes - ---------- - bot: :class:`.Bot` - The bot that the command belongs to. - interaction: :class:`.Interaction` - The interaction object that invoked the autocomplete. - focused: :class:`.Option` - The option the user is currently typing. - value: :class:`.str` - The content of the focused option. - options: Dict[:class:`str`, Any] - A name to value mapping of the options that the user has selected before this option. - """ - - __slots__ = ("bot", "interaction", "focused", "value", "options") - - def __init__(self, bot: Bot, interaction: Interaction): - self.bot = bot - self.interaction = interaction - - self.focused: Option = None # type: ignore - self.value: str = None # type: ignore - self.options: dict = None # type: ignore - - @property - def cog(self) -> Cog | None: - """Returns the cog associated with this context's command. - ``None`` if it does not exist. - """ - if self.command is None: - return None - - return self.command.cog - - @property - def command(self) -> ApplicationCommand | None: - """The command that this context belongs to.""" - return self.interaction.command - - @command.setter - def command(self, value: ApplicationCommand | None) -> None: - self.interaction.command = value diff --git a/discord/commands/core.py b/discord/commands/core.py index acbc2a757f..48b784997b 100644 --- a/discord/commands/core.py +++ b/discord/commands/core.py @@ -45,7 +45,7 @@ Union, ) -from discord.interactions import AutocompleteInteraction, Interaction +from discord.interactions import AutocompleteInteraction, BaseInteraction from ..channel import PartialMessageable, _threaded_guild_channel_factory from ..channel.thread import Thread diff --git a/discord/commands/options.py b/discord/commands/options.py index 0bb5a30f95..79621dfbd2 100644 --- a/discord/commands/options.py +++ b/discord/commands/options.py @@ -45,7 +45,7 @@ from typing_extensions import TypeAlias, TypeVar, override -from discord.interactions import AutocompleteInteraction, Interaction +from discord.interactions import AutocompleteInteraction, BaseInteraction from ..utils.private import maybe_awaitable 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..3dfb7db79a --- /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 # 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..d28ba473f5 --- /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 # 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..113b7da9bd --- /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 # 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..867aa7e630 --- /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 # 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 4e3f678b5a..1a0c4effa7 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -66,7 +66,7 @@ "ScheduledEventStatus", "ScheduledEventPrivacyLevel", "ScheduledEventLocationType", - "InputTextStyle", + "TextInputStyle", "SlashCommandOptionType", "AutoModTriggerType", "AutoModEventType", @@ -645,8 +645,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 @@ -659,6 +658,8 @@ class ComponentType(Enum): separator = 14 content_inventory_entry = 16 container = 17 + label = 18 + file_upload = 19 def __int__(self): return self.value @@ -682,18 +683,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): @@ -989,6 +987,12 @@ class ApplicationCommandPermissionType(Enum): channel = 3 +class ApplicationCommandType(IntEnum): + CHAT_INPUT = 1 + USER = 2 + MESSAGE = 3 + + def try_enum(cls: type[E], val: Any) -> E: """A function that tries to turn the value into enum ``cls``. diff --git a/discord/errors.py b/discord/errors.py index 6be0198281..af735549f8 100644 --- a/discord/errors.py +++ b/discord/errors.py @@ -37,7 +37,7 @@ except ModuleNotFoundError: _ResponseType = ClientResponse - from .interactions import Interaction + from .interactions import BaseInteraction __all__ = ( "AnnotationMismatch", @@ -279,12 +279,12 @@ class InteractionResponded(ClientException): Attributes ---------- - interaction: :class:`Interaction` + interaction: :class:`BaseInteraction` The interaction that's already been responded to. """ - def __init__(self, interaction: Interaction): - self.interaction: Interaction = interaction + def __init__(self, interaction: BaseInteraction): + self.interaction: BaseInteraction = interaction super().__init__("This interaction has already been responded to before") diff --git a/discord/events/channel.py b/discord/events/channel.py index 5ae6255826..b497d1b163 100644 --- a/discord/events/channel.py +++ b/discord/events/channel.py @@ -61,9 +61,18 @@ def _create_event_channel_class(event_cls: type[Event], channel_cls: type[GuildC A new class that inherits from both the event and channel """ - class EventChannel(event_cls, channel_cls): # type: ignore + class EventChannel(channel_cls, event_cls): # type: ignore __slots__ = () + @override + def __init__(self) -> None: + pass + + @override + @classmethod + def event_type(self) -> type[Event]: + return event_cls + EventChannel.__name__ = f"{event_cls.__name__}_{channel_cls.__name__}" EventChannel.__qualname__ = f"{event_cls.__qualname__}_{channel_cls.__name__}" diff --git a/discord/events/gateway.py b/discord/events/gateway.py index f3b9b75a15..82eb893804 100644 --- a/discord/events/gateway.py +++ b/discord/events/gateway.py @@ -54,7 +54,7 @@ class Resumed(Event): __event_name__: str = "RESUMED" @classmethod - async def __load__(cls, _data: Any, _state: ConnectionState) -> Self | None: + async def __load__(cls, data: Any, state: ConnectionState) -> Self | None: return cls() diff --git a/discord/events/interaction.py b/discord/events/interaction.py index ed1a3a8f2f..7d9af05885 100644 --- a/discord/events/interaction.py +++ b/discord/events/interaction.py @@ -32,36 +32,55 @@ from ..app.event_emitter import Event from ..app.state import ConnectionState -from ..interactions import ApplicationCommandInteraction, AutocompleteInteraction, Interaction +from ..interactions import ( + ApplicationCommandInteraction, + AutocompleteInteraction, + BaseInteraction, + ComponentInteraction, + ModalInteraction, +) -def _interaction_factory(payload: InteractionPayload) -> type[Interaction]: +def _interaction_factory(payload: InteractionPayload) -> type[BaseInteraction]: type: int = payload["type"] - if type == InteractionType.application_command: + if type == InteractionType.application_command.value: return ApplicationCommandInteraction - if type == InteractionType.auto_complete: + if type == InteractionType.auto_complete.value: return AutocompleteInteraction - return Interaction + if type == InteractionType.component.value: + return ComponentInteraction + if type == InteractionType.modal_submit.value: + return ModalInteraction + return BaseInteraction @lru_cache(maxsize=128) -def _create_event_interaction_class(event_cls: type[Event], interaction_cls: type[Interaction]) -> type[Interaction]: - class EventInteraction(event_cls, interaction_cls): # type: ignore +def _create_event_interaction_class(interaction_cls: type[BaseInteraction]) -> type[BaseInteraction]: + class EventInteraction(interaction_cls, Event): # type: ignore __slots__ = () + @override + def __init__(self) -> None: + pass + + @override + @classmethod + def event_type(self) -> type[Event]: + return InteractionCreate + + @classmethod + @override + async def __load__(cls, data: InteractionPayload, state: ConnectionState) -> None: + return None + return EventInteraction # type: ignore -class InteractionCreate(Event, Interaction): +class InteractionCreate(Event, BaseInteraction): """Called when an interaction is created. This currently happens due to application command invocations or components being used. - .. warning:: - This is a low level event that is not generally meant to be used. - If you are working with components, consider using the callbacks associated - with the :class:`~discord.ui.View` instead as it provides a nicer user experience. - This event inherits from :class:`Interaction`. """ @@ -75,7 +94,7 @@ def __init__(self) -> None: async def __load__(cls, data: Any, state: ConnectionState) -> Self | None: factory = _interaction_factory(data) interaction = await factory._from_data(payload=data, state=state) - interaction_event_cls = _create_event_interaction_class(cls, factory) + interaction_event_cls = _create_event_interaction_class(factory) self = interaction_event_cls() self._populate_from_slots(interaction) return self diff --git a/discord/ext/bridge/bot.py b/discord/ext/bridge/bot.py index b54ef805cb..a3379701b5 100644 --- a/discord/ext/bridge/bot.py +++ b/discord/ext/bridge/bot.py @@ -30,7 +30,7 @@ from discord.commands import ApplicationContext from discord.errors import CheckFailure, DiscordException -from discord.interactions import Interaction +from discord.interactions import BaseInteraction from discord.message import Message from ..commands import AutoShardedBot as ExtAutoShardedBot @@ -77,7 +77,7 @@ def walk_bridge_commands( if isinstance(cmd, BridgeCommandGroup): yield from cmd.walk_commands() - async def get_application_context(self, interaction: Interaction, cls=None) -> BridgeApplicationContext: + async def get_application_context(self, interaction: BaseInteraction, cls=None) -> BridgeApplicationContext: cls = cls if cls is not None else BridgeApplicationContext # Ignore the type hinting error here. BridgeApplicationContext is a subclass of ApplicationContext, and since # we gave it cls, it will be used instead. diff --git a/discord/ext/bridge/context.py b/discord/ext/bridge/context.py index 331ff2a900..a1afcbcc7d 100644 --- a/discord/ext/bridge/context.py +++ b/discord/ext/bridge/context.py @@ -29,7 +29,7 @@ from typing import TYPE_CHECKING, Any, Union, overload from discord.commands import ApplicationContext -from discord.interactions import Interaction, InteractionMessage +from discord.interactions import BaseInteraction, InteractionMessage from discord.message import Message from discord.webhook import WebhookMessage @@ -67,7 +67,7 @@ async def example(ctx: BridgeContext): """ @abstractmethod - async def _respond(self, *args, **kwargs) -> Interaction | WebhookMessage | Message: ... + async def _respond(self, *args, **kwargs) -> BaseInteraction | WebhookMessage | Message: ... @abstractmethod async def _defer(self, *args, **kwargs) -> None: ... @@ -78,7 +78,7 @@ async def _edit(self, *args, **kwargs) -> InteractionMessage | Message: ... @overload async def invoke(self, command: BridgeSlashCommand | BridgeExtCommand, *args, **kwargs) -> None: ... - async def respond(self, *args, **kwargs) -> Interaction | WebhookMessage | Message: + async def respond(self, *args, **kwargs) -> BaseInteraction | WebhookMessage | Message: """|coro| Responds to the command with the respective response type to the current context. In :class:`BridgeExtContext`, @@ -87,7 +87,7 @@ async def respond(self, *args, **kwargs) -> Interaction | WebhookMessage | Messa """ return await self._respond(*args, **kwargs) - async def reply(self, *args, **kwargs) -> Interaction | WebhookMessage | Message: + async def reply(self, *args, **kwargs) -> BaseInteraction | WebhookMessage | Message: """|coro| Alias for :meth:`~.BridgeContext.respond`. @@ -137,7 +137,7 @@ def __init__(self, *args, **kwargs): # This is needed in order to represent the correct class init signature on the docs super().__init__(*args, **kwargs) - async def _respond(self, *args, **kwargs) -> Interaction | WebhookMessage: + async def _respond(self, *args, **kwargs) -> BaseInteraction | WebhookMessage: return await self._get_super("respond")(*args, **kwargs) async def _defer(self, *args, **kwargs) -> None: diff --git a/discord/ext/bridge/core.py b/discord/ext/bridge/core.py index d2f2549f93..89fb000765 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/gears/application_commands.py b/discord/gears/application_commands.py new file mode 100644 index 0000000000..f5234da3a2 --- /dev/null +++ b/discord/gears/application_commands.py @@ -0,0 +1,76 @@ +""" +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 abc import ABC +from functools import wraps +from typing import TYPE_CHECKING, Any, Awaitable, Callable, Coroutine, Generic, ParamSpec, Protocol, TypeAlias, TypeVar + +from typing_extensions import Unpack + +from ..enums import ApplicationCommandType +from ..events import InteractionCreate +from ..interactions import ApplicationCommandInteraction +from ..utils import MISSING, Undefined +from ..utils.private import hybridmethod, maybe_awaitable +from .base import GearBase + +if TYPE_CHECKING: + from ..commands import ApplicationCommand +P = ParamSpec("P") +R = TypeVar("R") + + +class CommandListener(Protocol, Generic[P, R]): + __command__: "ApplicationCommand" + + async def __call__(self, interaction: ApplicationCommandInteraction, *args: P.args, **kwargs: P.kwargs) -> R: ... + + +def _listener_factory(listener: CommandListener, command_name: str) -> Callable[..., ...]: + @wraps(listener) + async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R | None: + # Assume last positional arg is the interaction + if args: + interaction: Any = args[-1] + if isinstance(interaction, ApplicationCommandInteraction) and interaction.command_name == command_name: + interaction.command = listener.__command__ + if interaction.command_type == ApplicationCommandType.CHAT_INPUT: + ... + elif interaction.command_type == ApplicationCommandType.USER: + ... + + return await listener(*args, **kwargs) + return None + + return wrapper + + +ACG_t = TypeVar("ACG_t", bound="ApplicationCommandsGearMixin") + + +class ApplicationCommandsGearMixin(GearBase, ABC): + """A mixin that provides application commands handling for a :class:`discord.Gear`. + + This mixin is used to handle application commands interactions. + """ diff --git a/discord/gears/base.py b/discord/gears/base.py new file mode 100644 index 0000000000..e7a5c5998a --- /dev/null +++ b/discord/gears/base.py @@ -0,0 +1,65 @@ +""" +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 abc import ABC, abstractmethod +from collections.abc import Awaitable, Callable +from typing import ( + TYPE_CHECKING, + Any, + TypeAlias, + TypeVar, +) + +from ..app.event_emitter import Event +from ..utils import MISSING, Undefined + +if TYPE_CHECKING: + from .gear import EventListener + +E = TypeVar("E", bound="Event", covariant=True) + +EventCallback: TypeAlias = Callable[[E], Awaitable[None]] + + +class GearBase(ABC): + @abstractmethod + def add_listener( + self, + callback: Callable[[E], Awaitable[None]], + *, + event: type[E] | Undefined = MISSING, + is_instance_function: bool = False, + once: bool = False, + ) -> "EventListener[E]": ... + + @abstractmethod + def remove_listener( + self, + listener: "EventListener[E]", + event: type[E] | Undefined = MISSING, + is_instance_function: bool = False, + ) -> None: ... + + @abstractmethod + def listen(self, *args: Any, **kwargs: Any) -> Any: ... diff --git a/discord/gears/components.py b/discord/gears/components.py new file mode 100644 index 0000000000..2460b86900 --- /dev/null +++ b/discord/gears/components.py @@ -0,0 +1,407 @@ +""" +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 asyncio +from abc import ABC +from collections.abc import Awaitable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Callable, Generic, TypeAlias, TypeVar, cast + +from typing_extensions import Unpack + +from ..events import InteractionCreate +from ..interactions import ComponentInteraction, ModalInteraction +from ..utils.private import hybridmethod, maybe_awaitable +from .base import GearBase + +ComponentListenerCallback: TypeAlias = ( + Callable[[ComponentInteraction[Any]], Awaitable[Any]] | Callable[[Any, ComponentInteraction[Any]], Awaitable[Any]] +) + +ModalListenerCallback: TypeAlias = ( + Callable[[ModalInteraction[Unpack[tuple[Any, ...]]]], Awaitable[Any]] + | Callable[[Any, ModalInteraction[Unpack[tuple[Any, ...]]]], Awaitable[Any]] +) + +T = TypeVar("T", bound="ComponentListenerCallback | ModalListenerCallback") + + +def _unwrap_predicate( + maybe_predicate: Callable[[str], bool | Awaitable[bool]] | str, +) -> Callable[[str], bool | Awaitable[bool]]: + return (lambda x: x == maybe_predicate) if isinstance(maybe_predicate, str) else maybe_predicate + + +UiPredicate: TypeAlias = Callable[[str], bool | Awaitable[bool]] + + +@dataclass(frozen=True) +class UiListener(ABC, Generic[T]): + callback: T + predicate: UiPredicate + _pass_self: bool = False + once: bool = False + + +@dataclass(frozen=True) +class ComponentListener(UiListener[ComponentListenerCallback]): + """A registered component interaction listener. + + This class represents a listener that has been registered to handle + component interactions based on a predicate. + """ + + +CG_t = TypeVar("CG_t", bound="ComponentGearMixin") + + +class ComponentGearMixin(GearBase, ABC): + """A mixin that provides component handling for a :class:`discord.Gear`. + + This mixin is used to handle components such as buttons, select menus, and other interactive elements. + """ + + def __init__(self) -> None: + super().__init__() + self._component_listeners: set[ComponentListener] = set() + for name in dir(type(self)): + attr = getattr(type(self), name, None) + if isinstance(attr, ComponentListener): + self._component_listeners.add(attr) + + self.add_listener(self._handle_component_interaction, event=InteractionCreate) + + async def _handle_component_interaction(self, event: InteractionCreate) -> None: + if not isinstance(event, ComponentInteraction): + return + + listeners_to_remove: list[ComponentListener] = [] + tasks: list[Awaitable[None]] = [] + for listener in self._component_listeners: + if not await maybe_awaitable(listener.predicate, event.custom_id): + continue + + if listener.once: + listeners_to_remove.append(listener) + + if listener._pass_self: + callback = cast(Callable[[Any, ComponentInteraction[Any]], Awaitable[Any]], listener.callback) + tasks.append(callback(self, event)) + else: + callback = cast(Callable[[ComponentInteraction[Any]], Awaitable[Any]], listener.callback) + tasks.append(callback(event)) + + for listener in listeners_to_remove: + self._component_listeners.remove(listener) + + await asyncio.gather(*tasks) + + def add_component_listener( + self, + predicate: Callable[[str], bool | Awaitable[bool]] | str, + listener: ComponentListenerCallback, + once: bool = False, + ) -> ComponentListener: + """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. + + 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. + + once: + Whether to unregister the listener after it has been called once. + + Returns + ------- + ComponentListener + The registered listener. Use this to unregister the listener. + """ + actual_predicate: Callable[[str], bool | Awaitable[bool]] = _unwrap_predicate(predicate) + component_listener = ComponentListener(callback=listener, predicate=actual_predicate, once=once) + self._component_listeners.add(component_listener) + return component_listener + + def remove_component_listener(self, listener: ComponentListener) -> None: + """Unregisters a component interaction listener. + + This method can be used to unregister a previously registered + component interaction listener. + + Parameters + ---------- + listener: + The listener to unregister. + + Raises + ------ + KeyError + If the listener is not registered. + """ + self._component_listeners.remove(listener) + + if TYPE_CHECKING: + + @classmethod + def listen_component( + cls: type[CG_t], + predicate: Callable[[str], bool | Awaitable[bool]] | str, # pyright: ignore[reportUnusedParameter] + once: bool = False, # pyright: ignore[reportUnusedParameter] + ) -> Callable[ + [ComponentListenerCallback], + ComponentListener, + ]: + """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. + + 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. + once: + Whether to unregister the listener after it has been called once. + """ + ... + else: + # Instance function listeners (but not bound to an instance) + @hybridmethod + def listen_component( + cls: type[CG_t], # noqa: N805 + predicate: Callable[[str], bool | Awaitable[bool]] | str, + once: bool = False, + ) -> Callable[ + [Callable[[Any, ComponentInteraction[Any]], Awaitable[None]]], + ComponentListener, + ]: + def decorator( + func: Callable[[Any, ComponentInteraction[Any]], Awaitable[None]], + ) -> ComponentListener: + actual_predicate: Callable[[str], bool | Awaitable[bool]] = _unwrap_predicate(predicate) + + component_listener = ComponentListener( + callback=func, + predicate=actual_predicate, + _pass_self=True, + once=once, + ) + return component_listener + + return decorator + + # Bare listeners (everything else) + @listen_component.instancemethod + def listen_component( + self, + predicate: Callable[[str], bool | Awaitable[bool]] | str, + once: bool = False, + ) -> Callable[[ComponentListenerCallback], ComponentListener]: + def decorator( + func: ComponentListenerCallback, + ) -> ComponentListener: + return self.add_component_listener(predicate=predicate, listener=func, once=once) + + return decorator + + +@dataclass(frozen=True) +class ModalListener(UiListener[ModalListenerCallback]): + """A registered modal interaction listener. + + This class represents a listener that has been registered to handle + modal interactions based on a predicate. + """ + + +MG_t = TypeVar("MG_t", bound="ModalGearMixin") + + +class ModalGearMixin(GearBase, ABC): + """A mixin that provides modal handling for a :class:`discord.Gear`. + + This mixin is used to handle modals interactions. + """ + + def __init__(self) -> None: + super().__init__() + self._modal_listeners: set[ModalListener] = set() + for name in dir(type(self)): + attr = getattr(type(self), name, None) + if isinstance(attr, ModalListener): + self._modal_listeners.add(attr) + + self.add_listener(self._handle_modal_interaction, event=InteractionCreate) + + async def _handle_modal_interaction(self, event: InteractionCreate) -> None: + if not isinstance(event, ModalInteraction): + return + + listeners_to_remove: list[ModalListener] = [] + tasks: list[Awaitable[None]] = [] + for listener in self._modal_listeners: + if not await maybe_awaitable(listener.predicate, event.custom_id): + continue + + if listener.once: + listeners_to_remove.append(listener) + + if listener._pass_self: + callback = cast( + Callable[[Any, ModalInteraction[Unpack[tuple[Any, ...]]]], Awaitable[Any]], listener.callback + ) + tasks.append(callback(self, event)) + else: + callback = cast( + Callable[[ModalInteraction[Unpack[tuple[Any, ...]]]], Awaitable[Any]], listener.callback + ) + tasks.append(callback(event)) + + for listener in listeners_to_remove: + self._modal_listeners.remove(listener) + + await asyncio.gather(*tasks) + + def add_modal_listener( + self, + predicate: Callable[[str], bool | Awaitable[bool]] | str, + listener: ModalListenerCallback, + once: bool = False, + ) -> ModalListener: + """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. + + 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. + once: + Whether to unregister the listener after it has been called once. + + Returns + ------- + ModalListener + The registered listener. Use this to unregister the listener. + """ + actual_predicate: Callable[[str], bool | Awaitable[bool]] = _unwrap_predicate(predicate) + modal_listener = ModalListener(callback=listener, predicate=actual_predicate, once=once) + self._modal_listeners.add(modal_listener) + return modal_listener + + def remove_modal_listener(self, listener: ModalListener) -> None: + """Unregisters a modal interaction listener. + + This method can be used to unregister a previously registered + modal interaction listener. + + Parameters + ---------- + listener: + The listener to unregister. + + Raises + ------ + KeyError + If the listener is not registered. + """ + self._modal_listeners.remove(listener) + + if TYPE_CHECKING: + + @classmethod + def listen_modal( + cls: type[MG_t], + predicate: Callable[[str], bool | Awaitable[bool]] | str, # pyright: ignore[reportUnusedParameter] + once: bool = False, # pyright: ignore[reportUnusedParameter] + ) -> Callable[ + [ModalListenerCallback], + ModalListener, + ]: + """A shortcut decorator that registers a modal interaction listener. + + This decorator can be used to register a function that will be called + when a modal interaction occurs that matches the provided predicate. + + 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. + """ + ... + else: + # Instance function listeners (but not bound to an instance) + @hybridmethod + def listen_modal( + cls: type[MG_t], # noqa: N805 + predicate: Callable[[str], bool | Awaitable[bool]] | str, + ) -> Callable[ + [Callable[[Any, ModalInteraction[Unpack[tuple[Any, ...]]]], Awaitable[None]]], + ModalListener, + ]: + def decorator( + func: Callable[[Any, ModalInteraction[Unpack[tuple[Any, ...]]]], Awaitable[None]], + ) -> ModalListener: + actual_predicate: Callable[[str], bool | Awaitable[bool]] = _unwrap_predicate(predicate) + + modal_listener = ModalListener( + callback=func, + predicate=actual_predicate, + _pass_self=True, + ) + return modal_listener + + return decorator + + # Bare listeners (everything else) + @listen_modal.instancemethod + def listen_modal( + self, + predicate: Callable[[str], bool | Awaitable[bool]] | str, + once: bool = False, + ) -> Callable[[ModalListenerCallback], ModalListener]: + def decorator( + func: ModalListenerCallback, + ) -> ModalListener: + return self.add_modal_listener(predicate=predicate, listener=func, once=once) + + return decorator diff --git a/discord/gears/gear.py b/discord/gears/gear.py index 9aa8d6cc00..7cebdc7e47 100644 --- a/discord/gears/gear.py +++ b/discord/gears/gear.py @@ -23,43 +23,51 @@ """ from collections import defaultdict -from collections.abc import Awaitable, Callable, Collection, Sequence -from functools import partial +from collections.abc import Awaitable, Callable, Collection +from dataclasses import dataclass from typing import ( TYPE_CHECKING, Any, - Protocol, + Generic, TypeAlias, TypeVar, cast, - runtime_checkable, ) +from typing_extensions import override + from ..app.event_emitter import Event from ..utils import MISSING, Undefined from ..utils.annotations import get_annotations from ..utils.private import hybridmethod +from .base import GearBase +from .components import ComponentGearMixin, ModalGearMixin _T = TypeVar("_T", bound="Gear") E = TypeVar("E", bound="Event", covariant=True) -E_contra = TypeVar("E_contra", bound="Event", contravariant=True) +EventCallback: TypeAlias = Callable[[E], Awaitable[None]] | Callable[[Any, E], Awaitable[None]] -@runtime_checkable -class AttributedEventCallback(Protocol): - __event__: type[Event] - __once__: bool +@dataclass +class EventListener(Generic[E]): + """A registered event listener. -@runtime_checkable -class StaticAttributedEventCallback(AttributedEventCallback, Protocol): - __staticmethod__: bool + This class represents a listener that has been registered to handle + events of a specific type. + """ + callback: Callable[..., Awaitable[None]] + event: type[E] + once: bool = False + _pass_self: bool = False -EventCallback: TypeAlias = Callable[[E], Awaitable[None]] + @override + def __hash__(self) -> int: + return hash((self.callback, self.event)) -class Gear: +class Gear(ModalGearMixin, ComponentGearMixin, GearBase): # pyright: ignore[reportUnsafeMultipleInheritance] """A gear is a modular component that can listen to and handle events. You can subclass this class to create your own gears and attach them to your bot or other gears. @@ -85,36 +93,33 @@ async def on_event(event: Ready) -> None: """ def __init__(self) -> None: - self._listeners: dict[type[Event], set[EventCallback[Event]]] = defaultdict(set) - self._once_listeners: set[EventCallback[Event]] = set() + self._listeners: dict[type[Event], set[EventListener[Event]]] = defaultdict(set) self._init_called: bool = True self._gears: set[Gear] = set() for name in dir(type(self)): attr = getattr(type(self), name, None) - if not callable(attr): - continue - if isinstance(attr, StaticAttributedEventCallback): - callback = attr - event = attr.__event__ - once = attr.__once__ - elif isinstance(attr, AttributedEventCallback): - callback = partial(attr, self) - event = attr.__event__ - once = attr.__once__ - else: - continue - self.add_listener(cast("EventCallback[Event]", callback), event=event, once=once) - setattr(self, name, callback) + if isinstance(attr, EventListener): + self._listeners[attr.event.event_type()].add(attr) + + super().__init__() def _handle_event(self, event: Event) -> Collection[Awaitable[Any]]: tasks: list[Awaitable[None]] = [] - for listener in self._listeners[type(event)]: - if listener in self._once_listeners: - self._once_listeners.remove(listener) - tasks.append(listener(event)) + listeners_to_remove: list[EventListener[Event]] = [] + for listener in self._listeners[event.event_type()]: + if listener.once: + listeners_to_remove.append(listener) + + if listener._pass_self: + tasks.append(listener.callback(self, event)) + else: + tasks.append(listener.callback(event)) + + for listener in listeners_to_remove: + self._listeners[event.event_type()].remove(listener) for gear in self._gears: tasks.extend(gear._handle_event(event)) @@ -167,6 +172,7 @@ def _parse_listener_signature( event = next(iter(params.values())) return cast(type[E], event) + @override def add_listener( self, callback: Callable[[E], Awaitable[None]], @@ -174,7 +180,7 @@ def add_listener( event: type[E] | Undefined = MISSING, is_instance_function: bool = False, once: bool = False, - ) -> None: + ) -> EventListener[E]: """ Adds an event listener to the gear. @@ -189,6 +195,11 @@ def add_listener( is_instance_function: Whether the callback is an instance method (i.e., it takes the gear instance as the first argument). + Returns + ------- + EventListener + The registered listener. Use this to unregister the listener. + Raises ------ TypeError @@ -196,22 +207,28 @@ def add_listener( """ if event is MISSING: event = self._parse_listener_signature(callback, is_instance_function) - if once: - self._once_listeners.add(cast("EventCallback[Event]", callback)) - self._listeners[event].add(cast("EventCallback[Event]", callback)) + listener = EventListener(callback=callback, event=event, once=once) + self._listeners[event.event_type()].add(cast(EventListener[Event], listener)) + return listener + + @override def remove_listener( - self, callback: EventCallback[E], event: type[E] | Undefined = MISSING, is_instance_function: bool = False + self, + listener: EventListener[E], + event: type[E] | Undefined = MISSING, + is_instance_function: bool = False, ) -> None: """ Removes an event listener from the gear. Parameters ---------- - callback: - The callback function to be removed. + listener: + The EventListener instance to be removed. event: The type of event the listener was registered for. If not provided, it will be inferred from the callback signature. + Only required if passing a callback instead of an EventListener. is_instance_function: Whether the callback is an instance method (i.e., it takes the gear instance as the first argument). @@ -222,20 +239,20 @@ def remove_listener( KeyError If the listener is not found. """ - if event is MISSING: - event = self._parse_listener_signature(callback) - self._listeners[event].remove(cast("EventCallback[Event]", callback)) + if isinstance(listener, EventListener): + self._listeners[listener.event.event_type()].remove(cast(EventListener[Event], listener)) if TYPE_CHECKING: @classmethod + @override def listen( cls: type[_T], - event: type[E] | Undefined = MISSING, # pyright: ignore[reportUnusedParameter] + event: type[E] | Undefined = MISSING, once: bool = False, ) -> Callable[ [Callable[[E], Awaitable[None]] | Callable[[Any, E], Awaitable[None]]], - EventCallback[E], + EventListener[E], ]: """ A decorator that registers an event listener. @@ -262,18 +279,24 @@ def listen( @hybridmethod def listen( cls: type[_T], # noqa: N805 # Ruff complains of our shenanigans here - event: type[E] | Undefined = MISSING, + event: type[E] | None = None, once: bool = False, - ) -> Callable[[Callable[[Any, E], Awaitable[None]]], Callable[[Any, E], Awaitable[None]]]: - def decorator(func: Callable[[Any, E], Awaitable[None]]) -> Callable[[Any, E], Awaitable[None]]: - if isinstance(func, staticmethod): - func.__func__.__event__ = event - func.__func__.__once__ = once - func.__func__.__staticmethod__ = True + ) -> Callable[[Callable[[Any, E], Awaitable[None]]], EventListener[E]]: + def decorator(func: Callable[[Any, E], Awaitable[None]]) -> EventListener[E]: + is_static = isinstance(func, staticmethod) + + if event is None: + inferred_event: type[E] = cls._parse_listener_signature(func, is_instance_function=not is_static) else: - func.__event__ = event - func.__once__ = once - return func + inferred_event = event + + event_listener = EventListener( + callback=func, + event=inferred_event, + once=once, + _pass_self=not is_static, + ) + return event_listener return decorator @@ -281,9 +304,9 @@ def decorator(func: Callable[[Any, E], Awaitable[None]]) -> Callable[[Any, E], A @listen.instancemethod def listen( self, event: type[E] | Undefined = MISSING, once: bool = False - ) -> Callable[[Callable[[E], Awaitable[None]]], EventCallback[E]]: - def decorator(func: Callable[[E], Awaitable[None]]) -> EventCallback[E]: - self.add_listener(func, event=event, is_instance_function=False, once=once) - return cast(EventCallback[E], func) + ) -> Callable[[Callable[[E], Awaitable[None]]], EventListener[E]]: + def decorator(func: Callable[[E], Awaitable[None]]) -> EventListener[E]: + listener = self.add_listener(func, event=event, is_instance_function=False, once=once) + return listener return decorator diff --git a/discord/guild.py b/discord/guild.py index a5a298c282..d2322b4e72 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -327,12 +327,12 @@ async def _add_member(self, member: Member, /) -> None: await cast("ConnectionState", self._state).cache.store_member(member) async def _get_and_update_member(self, payload: MemberPayload, user_id: int, cache_flag: bool, /) -> Member: - members = await cast(ConnectionState, self._state).cache.get_guild_members(self.id) + members = await cast("ConnectionState", self._state).cache.get_guild_members(self.id) # we always get the member, and we only update if the cache_flag (this cache # flag should always be MemberCacheFlag.interaction) is set to True if user_id in members: member = cast(Member, await self.get_member(user_id)) - await member._update(payload) if cache_flag else None + await member._update(payload) if cache_flag else None # TODO: This is being cached incorrectly @VincentRPS else: # NOTE: # This is a fallback in case the member is not found in the guild's members. @@ -340,7 +340,7 @@ async def _get_and_update_member(self, payload: MemberPayload, user_id: int, cac # class will be incorrect such as status and activities. member = await Member._from_data(guild=self, state=self._state, data=payload) # type: ignore if cache_flag: - await cast(ConnectionState, self._state).cache.store_member(member) + await cast("ConnectionState", self._state).cache.store_member(member) return member def _store_thread(self, payload: ThreadPayload, /) -> Thread: @@ -387,7 +387,6 @@ def __repr__(self) -> str: ("id", self.id), ("name", self.name), ("shard_id", self.shard_id), - ("chunked", self.chunked), ("member_count", self._member_count), ) inner = " ".join("%s=%r" % t for t in attrs) @@ -446,7 +445,7 @@ def _remove_role(self, role_id: int, /) -> Role: return role @classmethod - async def _from_data(cls, guild: GuildPayload, state: ConnectionState) -> Self: + async def _from_data(cls, data: GuildPayload, state: ConnectionState) -> Self: self = cls() # NOTE: # Adding an attribute here and getting an AttributeError saying @@ -459,89 +458,89 @@ async def _from_data(cls, guild: GuildPayload, state: ConnectionState) -> Self: self._threads: dict[int, Thread] = {} self._sounds: dict[int, SoundboardSound] = {} self._state = state - member_count = guild.get("member_count") + member_count = data.get("member_count") # Either the payload includes member_count, or it hasn't been set yet. # Prevents valid _member_count from suddenly changing to None if member_count is not None or not hasattr(self, "_member_count"): self._member_count: int | None = member_count - self.name: str = guild.get("name") - self.verification_level: VerificationLevel = try_enum(VerificationLevel, guild.get("verification_level")) + self.name: str = data.get("name") + self.verification_level: VerificationLevel = try_enum(VerificationLevel, data.get("verification_level")) self.default_notifications: NotificationLevel = try_enum( - NotificationLevel, guild.get("default_message_notifications") + NotificationLevel, data.get("default_message_notifications") ) - self.explicit_content_filter: ContentFilter = try_enum(ContentFilter, guild.get("explicit_content_filter", 0)) - self.afk_timeout: int = guild.get("afk_timeout") - self._icon: str | None = guild.get("icon") - self._banner: str | None = guild.get("banner") - self.unavailable: bool = guild.get("unavailable", False) - self.id: int = int(guild["id"]) + self.explicit_content_filter: ContentFilter = try_enum(ContentFilter, data.get("explicit_content_filter", 0)) + self.afk_timeout: int = data.get("afk_timeout") + self._icon: str | None = data.get("icon") + self._banner: str | None = data.get("banner") + self.unavailable: bool = data.get("unavailable", False) + self.id: int = int(data["id"]) self._roles: dict[int, Role] = {} state = self._state # speed up attribute access - for r in guild.get("roles", []): + for r in data.get("roles", []): role = Role(guild=self, data=r, state=state) self._roles[role.id] = role - self.mfa_level: MFALevel = guild.get("mfa_level") + self.mfa_level: MFALevel = data.get("mfa_level") emojis = [] - for emoji in guild.get("emojis", []): + for emoji in data.get("emojis", []): emojis.append(await state.store_emoji(self, emoji)) self.emojis: tuple[GuildEmoji, ...] = tuple(emojis) stickers = [] - for sticker in guild.get("stickers", []): + for sticker in data.get("stickers", []): stickers.append(await state.store_sticker(self, sticker)) self.stickers: tuple[GuildSticker, ...] = tuple(stickers) - self.features: list[GuildFeature] = guild.get("features", []) - self._splash: str | None = guild.get("splash") - self._system_channel_id: int | None = get_as_snowflake(guild, "system_channel_id") - self.description: str | None = guild.get("description") - self.max_presences: int | None = guild.get("max_presences") - self.max_members: int | None = guild.get("max_members") - self.max_video_channel_users: int | None = guild.get("max_video_channel_users") - self.premium_tier: int = guild.get("premium_tier", 0) - self.premium_subscription_count: int = guild.get("premium_subscription_count") or 0 - self.premium_progress_bar_enabled: bool = guild.get("premium_progress_bar_enabled") or False - self._system_channel_flags: int = guild.get("system_channel_flags", 0) - self.preferred_locale: str | None = guild.get("preferred_locale") - self._discovery_splash: str | None = guild.get("discovery_splash") - self._rules_channel_id: int | None = get_as_snowflake(guild, "rules_channel_id") - self._public_updates_channel_id: int | None = get_as_snowflake(guild, "public_updates_channel_id") - self.nsfw_level: NSFWLevel = try_enum(NSFWLevel, guild.get("nsfw_level", 0)) - self.approximate_presence_count = guild.get("approximate_presence_count") - self.approximate_member_count = guild.get("approximate_member_count") + self.features: list[GuildFeature] = data.get("features", []) + self._splash: str | None = data.get("splash") + self._system_channel_id: int | None = get_as_snowflake(data, "system_channel_id") + self.description: str | None = data.get("description") + self.max_presences: int | None = data.get("max_presences") + self.max_members: int | None = data.get("max_members") + self.max_video_channel_users: int | None = data.get("max_video_channel_users") + self.premium_tier: int = data.get("premium_tier", 0) + self.premium_subscription_count: int = data.get("premium_subscription_count") or 0 + self.premium_progress_bar_enabled: bool = data.get("premium_progress_bar_enabled") or False + self._system_channel_flags: int = data.get("system_channel_flags", 0) + self.preferred_locale: str | None = data.get("preferred_locale") + self._discovery_splash: str | None = data.get("discovery_splash") + self._rules_channel_id: int | None = get_as_snowflake(data, "rules_channel_id") + self._public_updates_channel_id: int | None = get_as_snowflake(data, "public_updates_channel_id") + self.nsfw_level: NSFWLevel = try_enum(NSFWLevel, data.get("nsfw_level", 0)) + self.approximate_presence_count = data.get("approximate_presence_count") + self.approximate_member_count = data.get("approximate_member_count") self._stage_instances: dict[int, StageInstance] = {} - for s in guild.get("stage_instances", []): + for s in data.get("stage_instances", []): stage_instance = StageInstance(guild=self, data=s, state=state) self._stage_instances[stage_instance.id] = stage_instance cache_joined = self._state.member_cache_flags.joined self_id = self._state.self_id - for mdata in guild.get("members", []): + for mdata in data.get("members", []): member = await Member._from_data(data=mdata, guild=self, state=state) if cache_joined or member.id == self_id: await self._add_member(member) events = [] - for event in guild.get("guild_scheduled_events", []): + for event in data.get("guild_scheduled_events", []): creator = None if not event.get("creator", None) else await self.get_member(event.get("creator_id")) events.append(ScheduledEvent(state=self._state, guild=self, creator=creator, data=event)) self._scheduled_events_from_list(events) - await self._sync(guild) + await self._sync(data) self._large: bool | None = None if self._member_count is None else self._member_count >= 250 - self.owner_id: int | None = get_as_snowflake(guild, "owner_id") - self.afk_channel: VoiceChannel | None = self.get_channel(get_as_snowflake(guild, "afk_channel_id")) # type: ignore + self.owner_id: int | None = get_as_snowflake(data, "owner_id") + self.afk_channel: VoiceChannel | None = self.get_channel(get_as_snowflake(data, "afk_channel_id")) # type: ignore - for obj in guild.get("voice_states", []): + for obj in data.get("voice_states", []): await self._update_voice_state(obj, int(obj["channel_id"])) - for sound in guild.get("soundboard_sounds", []): + for sound in data.get("soundboard_sounds", []): sound = SoundboardSound(state=state, http=state.http, data=sound) await self._add_sound(sound) - incidents_payload = guild.get("incidents_data") + incidents_payload = data.get("incidents_data") self.incidents_data: IncidentsData | None = ( IncidentsData(data=incidents_payload) if incidents_payload is not None else None ) diff --git a/discord/http.py b/discord/http.py index 39b3432df1..f0c0e3ca21 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 26c369d4f2..8cfbc24325 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -27,13 +27,17 @@ import asyncio import datetime -from typing import TYPE_CHECKING, Any, Coroutine, Generic, Union +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any, Coroutine, Generic, Protocol, cast -from typing_extensions import Self, TypeVar, override, reveal_type +from typing_extensions import Self, TypeVar, TypeVarTuple, Unpack, override, reveal_type from . import utils -from .channel import ChannelType, PartialMessageable, _threaded_channel_factory +from .channel import PartialMessageable, _threaded_channel_factory +from .components import ComponentsHolder, _partial_component_factory from .enums import ( + ApplicationCommandType, + ChannelType, InteractionContextType, InteractionResponseType, InteractionType, @@ -48,10 +52,14 @@ from .monetization import Entitlement from .object import Object from .permissions import Permissions +from .role import Role from .types.interactions import ( ApplicationCommandAutocompleteInteraction as ApplicationCommandAutocompleteInteractionPayload, ) from .types.interactions import ApplicationCommandInteraction as ApplicationCommandInteractionPayload +from .types.interactions import ( + ApplicationCommandInteractionDataOption, +) from .types.interactions import Interaction as InteractionPayload from .user import User from .utils import find @@ -64,10 +72,12 @@ ) __all__ = ( - "Interaction", + "BaseInteraction", + "ModalInteraction", + "ComponentInteraction", + "ApplicationCommandInteraction", "InteractionMessage", "InteractionResponse", - "MessageInteraction", "InteractionMetadata", "AuthorizingIntegrationOwners", "InteractionCallback", @@ -89,16 +99,23 @@ from .channel.thread import Thread 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 + from .types.interactions import ComponentInteraction as ComponentInteractionPayload from .types.interactions import InteractionCallback as InteractionCallbackPayload from .types.interactions import InteractionCallbackResponse, InteractionData from .types.interactions import InteractionData as InteractionDataPayload 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 @@ -117,7 +134,7 @@ T = TypeVar("T", bound="InteractionPayload") -class Interaction(Generic[T]): +class BaseInteraction(Generic[T]): """Represents a Discord interaction. An interaction happens when a user does an action that needs to @@ -174,14 +191,6 @@ class Interaction(Generic[T]): 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. @@ -209,9 +218,6 @@ class Interaction(Generic[T]): "authorizing_integration_owners", "callback", "command", - "view", - "modal", - "_payload", "attachment_size_limit", "_channel_data", "_message_data", @@ -226,6 +232,7 @@ class Interaction(Generic[T]): "_cs_response", "_cs_followup", "_cs_channel", + "_payload", ) def __init__(self, *, payload: InteractionPayload, state: ConnectionState): @@ -235,6 +242,7 @@ def __init__(self, *, payload: InteractionPayload, state: ConnectionState): self._original_response: InteractionMessage | None = None self.data = payload.get("data") self.callback: InteractionCallback | None = None + super().__init__() @classmethod async def _from_data(cls, payload: InteractionPayload, state: ConnectionState) -> Self: @@ -252,7 +260,6 @@ async def _from_data(cls, payload: InteractionPayload, state: ConnectionState) - self.application_id: int = int(self._payload["application_id"]) self.locale: str | None = self._payload.get("locale") self.guild_locale: str | None = self._payload.get("guild_locale") - self.custom_id: str | None = self.data.get("custom_id") if self.data is not None else None self._app_permissions: int = int(self._payload.get("app_permissions", 0)) self.entitlements: list[Entitlement] = [ Entitlement(data=e, state=self._state) for e in self._payload.get("entitlements", []) @@ -266,9 +273,6 @@ async def _from_data(cls, payload: InteractionPayload, state: ConnectionState) - try_enum(InteractionContextType, self._payload["context"]) if "context" in self._payload else None ) - self.command: ApplicationCommand | None = None - self.view: View | None = None - self.modal: Modal | None = None self.attachment_size_limit: int = self._payload.get("attachment_size_limit") self.message: Message | None = None @@ -310,8 +314,8 @@ async def _from_data(cls, payload: InteractionPayload, state: ConnectionState) - if ch_type in (ChannelType.group, ChannelType.private): self.channel = await factory._from_data(data=channel, state=self._state) - if self.channel is None and self.guild: - self.channel = self.guild._resolve_channel(self.channel_id) + if self.channel is None and self._guild: + self.channel = self._guild._resolve_channel(self.channel_id) if self.channel is None and self.channel_id is not None: ch_type = ChannelType.text if self.guild_id is not None else ChannelType.private self.channel = PartialMessageable(state=self._state, id=self.channel_id, type=ch_type) @@ -323,6 +327,8 @@ async def _from_data(cls, payload: InteractionPayload, state: ConnectionState) - self._message_data = message_data + return self + @property def client(self) -> Client: """Returns the client that sent the interaction.""" @@ -347,23 +353,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. @@ -486,24 +475,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, *, @@ -513,7 +484,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, @@ -548,9 +519,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, @@ -583,7 +556,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, @@ -604,39 +577,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(): - await 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| @@ -673,20 +619,7 @@ 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: + async def respond(self, *args, **kwargs) -> BaseInteraction | WebhookMessage: """|coro| Sends either a response or a message using the followup webhook determined by whether the interaction @@ -771,23 +704,118 @@ def to_dict(self) -> dict[str, Any]: U = TypeVar("U", bound="ApplicationCommandInteractionPayload | ApplicationCommandAutocompleteInteractionPayload") -class _CommandBoundInteraction(Interaction[U], Generic[U]): - def __init__(self, *, payload: U, state: ConnectionState): - super().__init__(payload=payload, state=state) +class _CommandBoundInteractionMixin: + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) self._command: ApplicationCommand | None = None @property def command(self) -> ApplicationCommand: """The command that this interaction belongs to.""" if self._command is None: + return None # TODO: this is incorrect @Paillat-dev raise RuntimeError("This interaction has no command associated with it.") return self._command -class ApplicationCommandInteraction(_CommandBoundInteraction[ApplicationCommandInteractionPayload]): ... +class _ResolvedDataInteraction(BaseInteraction[T], Generic[T]): + """A mixin that loads and parses the resolved data from an interaction payload.""" + + __slots__: tuple[str, ...] = ( + "users", + "members", + "roles", + "channels", + "messages", + "attachments", + ) + + @override + @classmethod + async def _from_data(cls, payload: InteractionPayload, state: ConnectionState) -> Self: + self = await super()._from_data(payload=payload, state=state) + resolved = self._payload.get("data", {}).get("resolved", {}) + if users := resolved.get("users"): + self.users: dict[int, User] = { + int(user_id): User(state=state, data=user_data) for user_id, user_data in users.items() + } + if (members := resolved.get("members")) and (guild := await self.get_guild()): + self.members: dict[int, Member] = {} + for member_id, member_data in members.items(): + member_data["id"] = int(member_id) + member_data["user"] = resolved["users"][member_id] + self.members[member_data["id"]] = await guild._get_and_update_member( + member_data, member_data["id"], self._state.member_cache_flags.interaction + ) + if roles := resolved.get("roles"): + self.roles: dict[int, Role] = { + int(role_id): Role(state=state, data=role_data, guild=self.guild) + for role_id, role_data in roles.items() + } + if channels := resolved.get("channels"): # noqa: F841 see below + # TODO: Partial channels @Paillat-dev + self.channels: dict[int, InteractionChannel] = {} + if messages := resolved.get("messages"): + self.messages: dict[int, Message] = {} + for message_id, message_data in messages.items(): + channel = self.channel + if channel.id != int(message_data["channel_id"]): + # we got weird stuff going on, make up a channel + channel = PartialMessageable(state=self._state, id=int(message_data["channel_id"])) + + self.messages[int(message_id)] = await Message._from_data( + state=self._state, channel=channel, data=message_data + ) + if attachments := resolved.get("attachments"): + self.attachments: dict[int, Attachment] = { + int(att_id): Attachment(state=state, data=att_data) for att_id, att_data in attachments.items() + } + return self + + +class ApplicationCommandInteraction( + _ResolvedDataInteraction[ApplicationCommandInteractionPayload], _CommandBoundInteractionMixin +): + __slots__: tuple[str, ...] = ("_command",) + def __init__(self, *, payload: ApplicationCommandInteractionPayload, state: ConnectionState): + super().__init__(payload=payload, state=state) + if self.data is None: # TODO: make it so that this can never be None @Paillat-dev + raise RuntimeError("This interaction has no data associated with it.") + self.command_name = self._parse_command_name(self.data["name"], self.data.get("options", [])) + self.command_type: ApplicationCommandType = self.data["type"] + self.guild_id: int | None = self.data.get("guild_id") + # self.options: list[ApplicationCommandInteractionDataOption] = self.data.get("options", []) + self.target: User | Member | Message | None = None + self._target_id: int | None = None + self._command_type: ApplicationCommandType = self.data["type"] + + def _parse_command_name(self, current_name: str, options: list[ApplicationCommandInteractionDataOption]) -> str: + if options and (child_options := options[0].get("options")): + current_name += " " + options[0]["name"] + return self._parse_command_name(current_name, child_options) + return current_name + + @override + @classmethod + async def _from_data( + cls, payload: ApplicationCommandInteractionPayload, state: ConnectionState + ) -> Self: # ty:ignore[invalid-method-override] + self: ApplicationCommandInteraction = await super()._from_data(payload=payload, state=state) + if self._command_type == ApplicationCommandType.CHAT_INPUT: + ... + else: + self._target_id = int(self.data["target_id"]) + if self._command_type == ApplicationCommandType.USER: + self.target = self.users[self._target_id] + elif self._command_type == ApplicationCommandType.MESSAGE: + self.target = self.messages[self._target_id] + return self -class AutocompleteInteraction(_CommandBoundInteraction[ApplicationCommandAutocompleteInteractionPayload]): + +class AutocompleteInteraction( + BaseInteraction[ApplicationCommandAutocompleteInteractionPayload], _CommandBoundInteractionMixin +): def __init__(self, *, payload: ApplicationCommandAutocompleteInteractionPayload, state: ConnectionState): super().__init__(payload=payload, state=state) options = self.data.get("options", []) @@ -799,6 +827,35 @@ def __init__(self, *, payload: ApplicationCommandAutocompleteInteractionPayload, self.values: dict[str, int | str | float] = {o["name"]: o["value"] for o in options} # type: ignore # this is not called for subcommand autocompletes +Components_t = TypeVarTuple("Components_t", default="Unpack[tuple[AnyTopLevelModalPartialComponent, ...]]") + + +class ModalInteraction(_ResolvedDataInteraction["ModalInteractionPayload"], Generic[Unpack[Components_t]]): + __slots__ = ("components", "custom_id") + + @override + def __init__(self, *, payload: ModalInteractionPayload, state: ConnectionState): + super().__init__(payload=payload, state=state) + self.custom_id: str = self.data["custom_id"] + components_payload = cast("list[PartialComponent]", self.data.get("components", [])) + self.components: ComponentsHolder[Unpack[Components_t]] = ComponentsHolder( + *(_partial_component_factory(component) for component in components_payload) + ) + + +Component_t = TypeVar("Component_t", bound="AnyMessagePartialComponent", default="AnyMessagePartialComponent") + + +class ComponentInteraction(_ResolvedDataInteraction["ComponentInteractionPayload"], Generic[Component_t]): + __slots__ = ("component", "custom_id") + + @override + def __init__(self, *, payload: ComponentInteractionPayload, state: ConnectionState): + super().__init__(payload=payload, state=state) + self.custom_id: str = self.data["custom_id"] + self.component: Component_t = _partial_component_factory(self.data, key="component_type") + + class InteractionResponse: """Represents a Discord interaction response. @@ -813,8 +870,8 @@ class InteractionResponse: "_response_lock", ) - def __init__(self, parent: Interaction): - self._parent: Interaction = parent + def __init__(self, parent: BaseInteraction): + self._parent: BaseInteraction = parent self._responded: bool = False self._response_lock = asyncio.Lock() @@ -956,7 +1013,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, @@ -964,7 +1021,7 @@ async def send_message( files: list[File] = None, poll: Poll = None, delete_after: float = None, - ) -> Interaction: + ) -> BaseInteraction: """|coro| Responds to this interaction by sending a message. @@ -981,11 +1038,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. @@ -1041,12 +1098,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() @@ -1101,14 +1163,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: @@ -1124,7 +1178,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, @@ -1151,9 +1205,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, @@ -1183,7 +1237,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 @@ -1201,9 +1254,8 @@ async def edit_message( if attachments is not MISSING: payload["attachments"] = [a.to_dict() for a in attachments] - if view is not MISSING: - await 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()") @@ -1259,10 +1311,6 @@ async def edit_message( for file in files: file.close() - if view and not view.is_finished(): - view.message = msg - await state.store_view(view, message_id) - self._responded = True await self._process_callback_response(callback_response) if delete_after is not None: @@ -1315,14 +1363,14 @@ async def send_autocomplete_result( self._responded = True await self._process_callback_response(callback_response) - async def send_modal(self, modal: Modal) -> Interaction: + async def send_modal(self, modal: Modal) -> BaseInteraction: """|coro| Responds to this interaction by sending a modal dialog. This cannot be used to respond to another modal dialog submission. Parameters ---------- - modal: :class:`discord.ui.Modal` + modal: :class:`discord.Modal` The modal dialog to display to the user. Raises @@ -1353,45 +1401,6 @@ async def send_modal(self, modal: Modal) -> Interaction: ) self._responded = True await self._process_callback_response(callback_response) - await self._parent._state.store_modal(modal, int(self._parent._payload["user"]["id"])) # type: ignore - 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: @@ -1427,8 +1436,8 @@ async def _locked_response(self, coro: Coroutine[Any, Any, Any]) -> Any: class _InteractionMessageState: __slots__ = ("_parent", "_interaction") - def __init__(self, interaction: Interaction, parent: ConnectionState): - self._interaction: Interaction = interaction + def __init__(self, interaction: BaseInteraction, parent: ConnectionState): + self._interaction: BaseInteraction = interaction self._parent: ConnectionState = parent async def _get_guild(self, guild_id): @@ -1471,7 +1480,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, @@ -1500,9 +1509,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, @@ -1537,7 +1546,7 @@ async def edit( file=file, files=files, attachments=attachments, - view=view, + components=components, allowed_mentions=allowed_mentions, delete_after=delete_after, suppress=suppress, @@ -1566,46 +1575,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 dceaf7cc87..c52cde6f70 100644 --- a/discord/message.py +++ b/discord/message.py @@ -28,14 +28,13 @@ import datetime import io import re -from inspect import isawaitable +from collections.abc import Sequence from os import PathLike from typing import ( TYPE_CHECKING, Any, Callable, ClassVar, - Sequence, TypeVar, Union, overload, @@ -47,7 +46,7 @@ from . import utils from .channel import PartialMessageable from .channel.thread import Thread -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 @@ -75,10 +74,9 @@ from .channel import TextChannel from .channel.base import GuildChannel from .components import Component - from .interactions import MessageInteraction from .mentions import AllowedMentions from .role import Role - 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 @@ -96,7 +94,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") @@ -682,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__( @@ -710,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 @@ -894,18 +896,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. @@ -986,26 +986,28 @@ async def _from_data( data: MessagePayload, ) -> Self: self = cls() - self._state = state - self._raw_data = data - self.id = int(data["id"]) - self.webhook_id = get_as_snowflake(data, "webhook_id") - self.reactions = [Reaction(message=self, data=d) for d in data.get("reactions", [])] - self.attachments = [Attachment(data=a, state=self._state) for a in data["attachments"]] - self.embeds = [Embed.from_dict(a) for a in data["embeds"]] - self.application = data.get("application") - self.activity = data.get("activity") - self.channel = channel - self._edited_timestamp = parse_time(data["edited_timestamp"]) - self.type = try_enum(MessageType, data["type"]) - self.pinned = data["pinned"] - self.flags = MessageFlags._from_value(data.get("flags", 0)) - self.mention_everyone = data["mention_everyone"] - self.tts = data["tts"] - self.content = data["content"] - self.nonce = data.get("nonce") - self.stickers = [StickerItem(data=d, state=state) for d in data.get("sticker_items", [])] - self.components = [_component_factory(d, state=state) for d in data.get("components", [])] + self._state: ConnectionState = state + self._raw_data: MessagePayload = data + self.id: int = int(data["id"]) + self.webhook_id: int | None = get_as_snowflake(data, "webhook_id") + self.reactions: list[Reaction] = [Reaction(message=self, data=d) for d in data.get("reactions", [])] + self.attachments: list[Attachment] = [Attachment(data=a, state=self._state) for a in data["attachments"]] + self.embeds: list[Embed] = [Embed.from_dict(a) for a in data["embeds"]] + self.application: MessageApplicationPayload | None = data.get("application") + self.activity: MessageActivityPayload | None = data.get("activity") + self.channel: MessageableChannel = channel + self._edited_timestamp: datetime.datetime | None = parse_time(data["edited_timestamp"]) + self.type: MessageType = try_enum(MessageType, data["type"]) + self.pinned: bool = data["pinned"] + self.flags: MessageFlags = MessageFlags._from_value(data.get("flags", 0)) + self.mention_everyone: bool = data["mention_everyone"] + self.tts: bool = data["tts"] + 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: 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 @@ -1049,13 +1051,8 @@ async def _from_data( except KeyError: self.snapshots = [] - from .interactions import InteractionMetadata, MessageInteraction # circular import + from .interactions import InteractionMetadata # circular import - 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: @@ -1182,26 +1179,6 @@ def _rebind_cached_references(self, new_guild: Guild, new_channel: TextChannel | 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 @@ -1495,7 +1472,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( @@ -1509,7 +1486,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| @@ -1558,9 +1535,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 ------ @@ -1605,11 +1581,14 @@ async def edit( if attachments is not MISSING: payload["attachments"] = [a.to_dict() for a in attachments] - if view is not MISSING: - await 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()") @@ -1644,12 +1623,6 @@ async def edit( data = await self._state.http.edit_message(self.channel.id, self.id, **payload) message = await Message._from_data(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(): - await self._state.store_view(view, self.id) - if delete_after is not None: await self.delete(delay=delete_after) @@ -2195,11 +2168,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 ------- @@ -2252,10 +2226,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: - await 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) @@ -2266,11 +2244,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(): - await self._state.store_view(view, self.id) return msg async def end_poll(self) -> Message: 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 62b3c35971..927bc3c466 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, Generic, Literal, Union +from typing import TYPE_CHECKING, Dict, Generic, Literal, TypeAlias, TypeVar, Union from ..permissions import Permissions from .channel import Channel, ChannelType -from .components import Component, ComponentType +from .component_types import Component, ComponentType from .embed import Embed from .guild import Guild from .member import Member @@ -285,13 +285,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 10552e60cf..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 - - 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 86d5fd2800..0000000000 --- a/discord/ui/modal.py +++ /dev/null @@ -1,293 +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",) - - -if TYPE_CHECKING: - from typing_extensions import Self - - from ..app.state import ConnectionState - from ..interactions import Interaction - - -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}>" - - 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] diff --git a/discord/ui/section.py b/discord/ui/section.py deleted file mode 100644 index 130c970285..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 - - 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 dec72994ae..0000000000 --- a/discord/ui/select.py +++ /dev/null @@ -1,677 +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 ..channel.thread import Thread -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 ..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 ..channel.base 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 :meth:`Select.get_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 - - async def get_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 = await 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 :meth:`Select.get_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 f15ecba004..0000000000 --- a/discord/ui/view.py +++ /dev/null @@ -1,684 +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 ..app.state import ConnectionState - from ..interactions import Interaction, InteractionMessage - from ..message import Message - 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 - - return Button.from_component(component) - if isinstance(component, SelectComponent): - from .select import Select - - return Select.from_component(component) - if isinstance(component, SectionComponent): - from .section import Section - - return Section.from_component(component) - if isinstance(component, TextDisplayComponent): - from .text_display import TextDisplay - - return TextDisplay.from_component(component) - if isinstance(component, ThumbnailComponent): - from .thumbnail import Thumbnail - - return Thumbnail.from_component(component) - if isinstance(component, MediaGalleryComponent): - from .media_gallery import MediaGallery - - return MediaGallery.from_component(component) - if isinstance(component, FileComponent): - from .file import File - - return File.from_component(component) - if isinstance(component, SeparatorComponent): - from .separator import Separator - - return Separator.from_component(component) - if isinstance(component, ContainerComponent): - from .container import Container - - return Container.from_component(component) - if isinstance(component, ActionRowComponent): - # Handle ActionRow.children manually, or design ui.ActionRow? - - return component - return Item.from_component(component) - - -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 _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 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 37985e5da9..e91c185704 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 @@ -40,6 +41,7 @@ from ..asset import Asset from ..channel import ForumChannel, PartialMessageable from ..channel.thread import Thread +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, @@ -1592,7 +1595,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, @@ -1615,7 +1618,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, @@ -1637,7 +1640,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, @@ -1679,8 +1682,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` @@ -1698,13 +1699,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. @@ -1745,7 +1743,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. """ @@ -1771,11 +1769,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 @@ -1792,7 +1792,7 @@ async def send( embed=embed, embeds=embeds, ephemeral=ephemeral, - view=view, + components=components, poll=poll, applied_tags=applied_tags, allowed_mentions=allowed_mentions, @@ -1822,13 +1822,6 @@ async def send( if wait: msg = self._create_message(data) - if view is not MISSING and not view.is_finished(): - view.message = None if msg is None else msg - if msg: - view.refresh(msg.components) - if view.is_dispatchable(): - await self._state.store_view(view) - if delete_after is not None: async def delete(): @@ -1899,7 +1892,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, @@ -1942,12 +1935,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` @@ -1978,11 +1968,15 @@ 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") - await self._state.prevent_view_updates_for(message_id) + self._state.prevent_view_updates_for(message_id) if self.type is not WebhookType.application: with_components = True @@ -1994,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, @@ -2020,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(): - await self._state.store_view(view) return message async def delete_message(self, message_id: int, *, thread_id: int | None = None) -> None: diff --git a/docs/api/clients.rst b/docs/api/clients.rst index bfde02aac4..7ad26cf991 100644 --- a/docs/api/clients.rst +++ b/docs/api/clients.rst @@ -38,6 +38,7 @@ Clients .. attributetable:: Client .. autoclass:: Client :members: + :inherited-members: :exclude-members: fetch_guilds, listen .. automethod:: Client.fetch_guilds 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 8b7b600d2d..b46ac532a7 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/gears.rst b/docs/api/gears.rst index 160aaaa17c..4f38092d55 100644 --- a/docs/api/gears.rst +++ b/docs/api/gears.rst @@ -16,11 +16,18 @@ Gear .. autoclass:: discord.gears.Gear :members: - :exclude-members: listen + :inherited-members: + :exclude-members: listen,listen_component,listen_modal .. automethod:: discord.gears.Gear.listen(event, once=False) :decorator: + .. automethod:: discord.gears.Gear.listen_component(predicate, once=False) + :decorator: + + .. automethod:: discord.gears.Gear.listen_modal(predicate, once=False) + :decorator: + Basic Usage ----------- @@ -91,6 +98,73 @@ Use the ``once`` parameter to create listeners that are automatically removed af async def on_first_message(self, event: MessageCreate) -> None: print("This will only run once!") +Component Interactions +~~~~~~~~~~~~~~~~~~~~~~ + +Gears can handle component interactions (buttons, select menus, etc.) using the +:meth:`~discord.gears.Gear.listen_component` decorator: + +.. code-block:: python3 + + from discord.gears import Gear + from discord import ComponentInteraction + + class ButtonGear(Gear): + @Gear.listen_component("my_button") + async def handle_button(self, interaction: ComponentInteraction) -> None: + await interaction.respond("Button clicked!") + + @Gear.listen_component(lambda custom_id: custom_id.startswith("page_")) + async def handle_pagination(self, interaction: ComponentInteraction) -> None: + page = interaction.custom_id.split("_")[1] + await interaction.respond(f"Navigating to page {page}") + +The predicate can be: + +- A string for exact custom ID matching +- A function (sync or async) that takes a custom ID and returns a boolean + +You can also add component listeners to gear instances: + +.. code-block:: python3 + + my_gear = ButtonGear() + + @my_gear.listen_component("instance_button") + async def handle_instance_button(interaction: ComponentInteraction) -> None: + await interaction.respond("Instance button clicked!") + +Modal Interactions +~~~~~~~~~~~~~~~~~~ + +Similarly, gears can handle modal submissions using the :meth:`~discord.gears.Gear.listen_modal` decorator: + +.. code-block:: python3 + + from discord.gears import Gear + from discord import ModalInteraction + from discord.components import PartialLabel, PartialTextInput + + class FormGear(Gear): + @Gear.listen_modal("feedback_form") + async def handle_feedback( + self, + interaction: ModalInteraction[ + PartialLabel[PartialTextInput], + PartialLabel[PartialTextInput], + ], + ) -> None: + title = interaction.components[0].component.value + content = interaction.components[1].component.value + await interaction.respond(f"Feedback received: {title}") + + @Gear.listen_modal(lambda custom_id: custom_id.startswith("form_")) + async def handle_dynamic_form(self, interaction: ModalInteraction) -> None: + form_id = interaction.custom_id.split("_")[1] + await interaction.respond(f"Processing form {form_id}") + +Like component listeners, modal listeners can use string or function predicates and support the ``once`` parameter. + Manual Listener Management ~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/api/models.rst b/docs/api/models.rst index d24b587b01..a8ff6cd83c 100644 --- a/docs/api/models.rst +++ b/docs/api/models.rst @@ -353,6 +353,12 @@ Interactions .. autoclass:: Interaction() :members: +.. autoclass:: ComponentInteraction() + :members: + +.. autoclass:: ModalInteraction() + :members: + .. attributetable:: InteractionResponse .. autoclass:: InteractionResponse() @@ -363,11 +369,6 @@ Interactions .. autoclass:: InteractionMessage() :members: -.. attributetable:: MessageInteraction - -.. autoclass:: MessageInteraction() - :members: - .. attributetable:: InteractionMetadata .. autoclass:: InteractionMetadata() @@ -383,73 +384,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..5089d4d28b 100644 --- a/docs/api/ui_kit.rst +++ b/docs/api/ui_kit.rst @@ -3,108 +3,199 @@ 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: + +.. attributetable:: discord.components.Button + +.. autoclass:: discord.components.Button + :members: + :inherited-members: + +.. attributetable:: discord.components.StringSelect + +.. autoclass:: discord.components.StringSelect + :members: + :inherited-members: + +.. attributetable:: discord.components.TextInput + +.. autoclass:: discord.components.TextInput + :members: + :inherited-members: + +.. attributetable:: discord.components.UserSelect -.. autofunction:: discord.ui.select - :decorator: +.. autoclass:: discord.components.UserSelect + :members: + :inherited-members: + +.. attributetable:: discord.components.RoleSelect + +.. autoclass:: discord.components.RoleSelect + :members: + :inherited-members: + +.. attributetable:: discord.components.MentionableSelect + +.. autoclass:: discord.components.MentionableSelect + :members: + :inherited-members: + +.. attributetable:: discord.components.ChannelSelect + +.. autoclass:: discord.components.ChannelSelect + :members: + :inherited-members: -.. autofunction:: discord.ui.string_select - :decorator: +.. attributetable:: discord.components.Section -.. autofunction:: discord.ui.user_select - :decorator: +.. autoclass:: discord.components.Section + :members: + :inherited-members: -.. autofunction:: discord.ui.role_select - :decorator: +.. attributetable:: discord.components.TextDisplay -.. autofunction:: discord.ui.mentionable_select - :decorator: +.. autoclass:: discord.components.TextDisplay + :members: + :inherited-members: -.. autofunction:: discord.ui.channel_select - :decorator: +.. attributetable:: discord.components.Thumbnail -Objects -------- +.. autoclass:: discord.components.Thumbnail + :members: + :inherited-members: -.. attributetable:: discord.ui.View +.. attributetable:: discord.components.MediaGallery -.. autoclass:: discord.ui.View +.. autoclass:: discord.components.MediaGallery :members: + :inherited-members: -.. attributetable:: discord.ui.Item +.. attributetable:: discord.components.FileComponent -.. autoclass:: discord.ui.Item +.. autoclass:: discord.components.FileComponent :members: + :inherited-members: -.. attributetable:: discord.ui.Button +.. attributetable:: discord.components.FileUpload -.. autoclass:: discord.ui.Button +.. autoclass:: discord.components.FileUpload :members: :inherited-members: -.. attributetable:: discord.ui.Select +.. attributetable:: discord.components.Separator +.. autoclass:: discord.components.Separator + :members: + :inherited-members: -.. autoclass:: discord.ui.Select +.. attributetable:: discord.components.Container +.. autoclass:: discord.components.Container :members: :inherited-members: -.. attributetable:: discord.ui.Section +.. attributetable:: discord.components.Label +.. autoclass:: discord.components.Label + :members: + :inherited-members: -.. autoclass:: discord.ui.Section +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.TextDisplay +.. attributetable:: discord.components.PartialStringSelect +.. autoclass:: discord.components.PartialStringSelect + :members: + :inherited-members: -.. autoclass:: discord.ui.TextDisplay +.. attributetable:: discord.components.PartialUserSelect +.. autoclass:: discord.components.PartialUserSelect :members: :inherited-members: -.. attributetable:: discord.ui.Thumbnail +.. attributetable:: discord.components.PartialRoleSelect +.. autoclass:: discord.components.PartialRoleSelect + :members: + :inherited-members: -.. autoclass:: discord.ui.Thumbnail +.. attributetable:: discord.components.PartialMentionableSelect +.. autoclass:: discord.components.PartialMentionableSelect :members: :inherited-members: -.. attributetable:: discord.ui.MediaGallery +.. attributetable:: discord.components.PartialChannelSelect +.. autoclass:: discord.components.PartialChannelSelect + :members: + :inherited-members: -.. autoclass:: discord.ui.MediaGallery +.. attributetable:: discord.components.PartialTextInput +.. autoclass:: discord.components.PartialTextInput :members: :inherited-members: -.. attributetable:: discord.ui.File +.. attributetable:: discord.components.PartialTextDisplay +.. autoclass:: discord.components.PartialTextDisplay + :members: + :inherited-members: -.. autoclass:: discord.ui.File +.. attributetable:: discord.components.PartialFileUpload +.. autoclass:: discord.components.PartialFileUpload :members: :inherited-members: -.. attributetable:: discord.ui.Separator +Additional Objects +------------------ + +.. attributetable:: discord.components.Modal +.. autoclass:: discord.components.Modal + :members: + :inherited-members: -.. autoclass:: discord.ui.Separator +.. attributetable:: discord.components.UnknownComponent +.. autoclass:: discord.components.UnknownComponent :members: :inherited-members: -.. attributetable:: discord.ui.Container +.. attributetable:: discord.components.UnfurledMediaItem +.. autoclass:: discord.components.UnfurledMediaItem + :members: + :inherited-members: -.. autoclass:: discord.ui.Container +.. attributetable:: discord.components.MediaGalleryItem +.. autoclass:: discord.components.MediaGalleryItem :members: :inherited-members: -.. attributetable:: discord.ui.Modal +.. attributetable:: discord.components.ComponentsHolder +.. autoclass:: discord.components.ComponentsHolder + :members: + :inherited-members: -.. autoclass:: discord.ui.Modal +.. attributetable:: discord.components.DefaultSelectOption +.. autoclass:: discord.components.DefaultSelectOption :members: :inherited-members: -.. attributetable:: discord.ui.InputText +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/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..ff98acb4a5 --- /dev/null +++ b/examples/components/simple_modal.py @@ -0,0 +1,136 @@ +import os + +from dotenv import load_dotenv + +import discord +from discord import BaseInteraction, 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/custom_context.py b/examples/custom_context.py index fcf8ac377b..2ef889dcd5 100644 --- a/examples/custom_context.py +++ b/examples/custom_context.py @@ -40,7 +40,7 @@ async def get_context(self, message: discord.Message, *, cls=MyContext): # use the new MyContext class. return await super().get_context(message, cls=cls) - async def get_application_context(self, interaction: discord.Interaction, cls=MyApplicationContext): + async def get_application_context(self, interaction: discord.BaseInteraction, cls=MyApplicationContext): # The same method for custom application context. return await super().get_application_context(interaction, cls=cls) 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 749fbca482..8479e82036 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 5ff616119f..82ae3f07a9 100644 --- a/uv.lock +++ b/uv.lock @@ -1459,7 +1459,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"]