diff --git a/discord/client.py b/discord/client.py index c4c59e75f..51652b8a6 100644 --- a/discord/client.py +++ b/discord/client.py @@ -72,6 +72,7 @@ from .backoff import ExponentialBackoff from .webhook import Webhook from .appinfo import AppInfo from .ui.view import View +from .ui.dynamic import DynamicItem from .stage_instance import StageInstance from .threads import Thread from .sticker import GuildSticker, StandardSticker, StickerPack, _sticker_factory @@ -111,6 +112,7 @@ if TYPE_CHECKING: from .scheduled_event import ScheduledEvent from .threads import ThreadMember from .types.guild import Guild as GuildPayload + from .ui.item import Item from .voice_client import VoiceProtocol from .audit_logs import AuditLogEntry @@ -2678,6 +2680,30 @@ class Client: data = await state.http.start_private_message(user.id) return state.add_dm_channel(data) + def add_dynamic_items(self, *items: Type[DynamicItem[Item[Any]]]) -> None: + r"""Registers a :class:`~discord.ui.DynamicItem` class for persistent listening. + + This method accepts *class types* rather than instances. + + .. versionadded:: 2.4 + + Parameters + ----------- + \*items: Type[:class:`~discord.ui.DynamicItem`] + The classes of dynamic items to add. + + Raises + ------- + TypeError + The class is not a subclass of :class:`~discord.ui.DynamicItem`. + """ + + for item in items: + if not issubclass(item, DynamicItem): + raise TypeError(f'expected subclass of DynamicItem not {item.__name__}') + + self._connection.store_dynamic_items(*items) + def add_view(self, view: View, *, message_id: Optional[int] = None) -> None: """Registers a :class:`~discord.ui.View` for persistent listening. diff --git a/discord/state.py b/discord/state.py index e04932574..18b115e8a 100644 --- a/discord/state.py +++ b/discord/state.py @@ -32,6 +32,7 @@ from typing import ( Dict, Optional, TYPE_CHECKING, + Type, Union, Callable, Any, @@ -84,6 +85,8 @@ if TYPE_CHECKING: from .http import HTTPClient from .voice_client import VoiceProtocol from .gateway import DiscordWebSocket + from .ui.item import Item + from .ui.dynamic import DynamicItem from .app_commands import CommandTree, Translator from .types.automod import AutoModerationRule, AutoModerationActionExecution @@ -395,6 +398,9 @@ class ConnectionState(Generic[ClientT]): def prevent_view_updates_for(self, message_id: int) -> Optional[View]: return self._view_store.remove_message_tracking(message_id) + def store_dynamic_items(self, *items: Type[DynamicItem[Item[Any]]]) -> None: + self._view_store.add_dynamic_items(*items) + @property def persistent_views(self) -> Sequence[View]: return self._view_store.persistent_views diff --git a/discord/ui/__init__.py b/discord/ui/__init__.py index 0133be6f5..c5a51777c 100644 --- a/discord/ui/__init__.py +++ b/discord/ui/__init__.py @@ -15,3 +15,4 @@ from .item import * from .button import * from .select import * from .text_input import * +from .dynamic import * diff --git a/discord/ui/dynamic.py b/discord/ui/dynamic.py new file mode 100644 index 000000000..636fc8de9 --- /dev/null +++ b/discord/ui/dynamic.py @@ -0,0 +1,205 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations +from typing import ClassVar, Dict, Generic, Optional, Tuple, Type, TypeVar, TYPE_CHECKING, Any, Union +import re + +from .item import Item +from .._types import ClientT + +__all__ = ('DynamicItem',) + +BaseT = TypeVar('BaseT', bound='Item[Any]', covariant=True) + +if TYPE_CHECKING: + from typing_extensions import TypeVar, Self + from ..interactions import Interaction + from ..components import Component + from ..enums import ComponentType + from .view import View + + V = TypeVar('V', bound='View', covariant=True, default=View) +else: + V = TypeVar('V', bound='View', covariant=True) + + +class DynamicItem(Generic[BaseT], Item['View']): + """Represents an item with a dynamic ``custom_id`` that can be used to store state within + that ``custom_id``. + + The ``custom_id`` parsing is done using the ``re`` module by passing a ``template`` + parameter to the class parameter list. + + This item is generated every time the component is dispatched. This means that + any variable that holds an instance of this class will eventually be out of date + and should not be used long term. Their only purpose is to act as a "template" + for the actual dispatched item. + + When this item is generated, :attr:`view` is set to a regular :class:`View` instance + from the original message given from the interaction. This means that custom view + subclasses cannot be accessed from this item. + + .. versionadded:: 2.4 + + Parameters + ------------ + item: :class:`Item` + The item to wrap with dynamic custom ID parsing. + template: Union[:class:`str`, ``re.Pattern``] + The template to use for parsing the ``custom_id``. This can be a string or a compiled + regular expression. This must be passed as a keyword argument to the class creation. + 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). + + Attributes + ----------- + item: :class:`Item` + The item that is wrapped with dynamic custom ID parsing. + """ + + __item_repr_attributes__: Tuple[str, ...] = ( + 'item', + 'template', + ) + + __discord_ui_compiled_template__: ClassVar[re.Pattern[str]] + + def __init_subclass__(cls, *, template: Union[str, re.Pattern[str]]) -> None: + super().__init_subclass__() + cls.__discord_ui_compiled_template__ = re.compile(template) if isinstance(template, str) else template + if not isinstance(cls.__discord_ui_compiled_template__, re.Pattern): + raise TypeError('template must be a str or a re.Pattern') + + def __init__( + self, + item: BaseT, + *, + row: Optional[int] = None, + ) -> None: + super().__init__() + self.item: BaseT = item + self.row = row + + if not self.item.is_dispatchable(): + raise TypeError('item must be dispatchable, e.g. not a URL button') + + if not self.template.match(self.custom_id): + raise ValueError(f'item custom_id must match the template {self.template.pattern!r}') + + @property + def template(self) -> re.Pattern[str]: + """``re.Pattern``: The compiled regular expression that is used to parse the ``custom_id``.""" + return self.__class__.__discord_ui_compiled_template__ + + def to_component_dict(self) -> Dict[str, Any]: + return self.item.to_component_dict() + + def _refresh_component(self, component: Component) -> None: + self.item._refresh_component(component) + + def _refresh_state(self, interaction: Interaction, data: Dict[str, Any]) -> None: + self.item._refresh_state(interaction, data) + + @classmethod + def from_component(cls: Type[Self], component: Component) -> Self: + raise TypeError('Dynamic items cannot be created from components') + + @property + def type(self) -> ComponentType: + return self.item.type + + def is_dispatchable(self) -> bool: + return self.item.is_dispatchable() + + def is_persistent(self) -> bool: + return True + + @property + def custom_id(self) -> str: + """:class:`str`: The ID of the dynamic item that gets received during an interaction.""" + return self.item.custom_id # type: ignore # This attribute exists for dispatchable items + + @custom_id.setter + def custom_id(self, value: str) -> None: + if not isinstance(value, str): + raise TypeError('custom_id must be a str') + + if not self.template.match(value): + raise ValueError(f'custom_id must match the template {self.template.pattern!r}') + + self.item.custom_id = value # type: ignore # This attribute exists for dispatchable items + self._provided_custom_id = True + + @property + def row(self) -> Optional[int]: + return self.item._row + + @row.setter + def row(self, value: Optional[int]) -> None: + self.item.row = value + + @property + def width(self) -> int: + return self.item.width + + @classmethod + async def from_custom_id(cls: Type[Self], interaction: Interaction[ClientT], match: re.Match[str], /) -> Self: + """|coro| + + A classmethod that is called when the ``custom_id`` of a component matches the + ``template`` of the class. This is called when the component is dispatched. + + It must return a new instance of the :class:`DynamicItem`. + + Subclasses *must* implement this method. + + Exceptions raised in this method are logged and ignored. + + .. warning:: + + This method is called before the callback is dispatched, therefore + it means that it is subject to the same timing restrictions as the callback. + Ergo, you must reply to an interaction within 3 seconds of it being + dispatched. + + Parameters + ------------ + interaction: :class:`~discord.Interaction` + The interaction that the component belongs to. + match: ``re.Match`` + The match object that was created from the ``template`` + matching the ``custom_id``. + + Returns + -------- + :class:`DynamicItem` + The new instance of the :class:`DynamicItem` with information + from the ``match`` object. + """ + raise NotImplementedError diff --git a/discord/ui/item.py b/discord/ui/item.py index 2ef42fb9e..1ee549283 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -133,3 +133,36 @@ class Item(Generic[V]): The interaction that triggered this UI item. """ pass + + async def interaction_check(self, interaction: Interaction[ClientT], /) -> bool: + """|coro| + + A callback that is called when an interaction happens within this item + that checks whether the callback should be processed. + + 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``. + + .. note:: + + If an exception occurs within the body then the check + is considered a failure and :meth:`discord.ui.View.on_error` is called. + + For :class:`~discord.ui.DynamicItem` this does not call the ``on_error`` + handler. + + .. versionadded:: 2.4 + + Parameters + ----------- + interaction: :class:`~discord.Interaction` + The interaction that occurred. + + Returns + --------- + :class:`bool` + Whether the callback should be called. + """ + return True diff --git a/discord/ui/view.py b/discord/ui/view.py index 19354478f..9fc12b3ac 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -23,7 +23,7 @@ DEALINGS IN THE SOFTWARE. """ from __future__ import annotations -from typing import Any, Callable, ClassVar, Coroutine, Dict, Iterator, List, Optional, Sequence, TYPE_CHECKING, Tuple +from typing import Any, Callable, ClassVar, Coroutine, Dict, Iterator, List, Optional, Sequence, TYPE_CHECKING, Tuple, Type from functools import partial from itertools import groupby @@ -33,6 +33,7 @@ import sys import time import os from .item import Item, ItemCallbackType +from .dynamic import DynamicItem from ..components import ( Component, ActionRow as ActionRowComponent, @@ -50,6 +51,7 @@ __all__ = ( if TYPE_CHECKING: from typing_extensions import Self + import re from ..interactions import Interaction from ..message import Message @@ -417,7 +419,7 @@ class View: try: item._refresh_state(interaction, interaction.data) # type: ignore - allow = await self.interaction_check(interaction) + allow = await item.interaction_check(interaction) and await self.interaction_check(interaction) if not allow: return @@ -534,6 +536,8 @@ class ViewStore: self._synced_message_views: Dict[int, View] = {} # custom_id: Modal self._modals: Dict[str, Modal] = {} + # component_type is the key + self._dynamic_items: Dict[re.Pattern[str], Type[DynamicItem[Item[Any]]]] = {} self._state: ConnectionState = state @property @@ -548,6 +552,11 @@ class ViewStore: # fmt: on return list(views.values()) + def add_dynamic_items(self, *items: Type[DynamicItem[Item[Any]]]) -> None: + for item in items: + pattern = item.__discord_ui_compiled_template__ + self._dynamic_items[pattern] = item + def add_view(self, view: View, message_id: Optional[int] = None) -> None: view._start_listening_from_store(self) if view.__discord_ui_modal__: @@ -556,7 +565,10 @@ class ViewStore: dispatch_info = self._views.setdefault(message_id, {}) for item in view._children: - if item.is_dispatchable(): + if isinstance(item, DynamicItem): + pattern = item.__discord_ui_compiled_template__ + self._dynamic_items[pattern] = item.__class__ + elif item.is_dispatchable(): dispatch_info[(item.type.value, item.custom_id)] = item # type: ignore view._cache_key = message_id @@ -571,7 +583,10 @@ class ViewStore: dispatch_info = self._views.get(view._cache_key) if dispatch_info: for item in view._children: - if item.is_dispatchable(): + if isinstance(item, DynamicItem): + pattern = item.__discord_ui_compiled_template__ + self._dynamic_items.pop(pattern, None) + elif item.is_dispatchable(): dispatch_info.pop((item.type.value, item.custom_id), None) # type: ignore if len(dispatch_info) == 0: @@ -579,7 +594,57 @@ class ViewStore: self._synced_message_views.pop(view._cache_key, None) # type: ignore + async def schedule_dynamic_item_call( + self, + component_type: int, + factory: Type[DynamicItem[Item[Any]]], + interaction: Interaction, + match: re.Match[str], + ) -> None: + try: + item = await factory.from_custom_id(interaction, match) + except Exception: + _log.exception('Ignoring exception in dynamic item creation for %r', factory) + return + + # Unfortunately cannot set Item.view here... + item._refresh_state(interaction, interaction.data) # type: ignore + + try: + allow = await item.interaction_check(interaction) + except Exception: + allow = False + + if not allow: + return + + if interaction.message is None: + item._view = None + else: + item._view = view = View.from_message(interaction.message) + + # Find the original item and replace it with the dynamic item + for index, child in enumerate(view._children): + if child.type.value == component_type and getattr(child, 'custom_id', None) == item.custom_id: + view._children[index] = item + break + + try: + await item.callback(interaction) + except Exception: + _log.exception('Ignoring exception in dynamic item callback for %r', item) + + def dispatch_dynamic_items(self, component_type: int, custom_id: str, interaction: Interaction) -> None: + for pattern, item in self._dynamic_items.items(): + match = pattern.fullmatch(custom_id) + if match is not None: + asyncio.create_task( + self.schedule_dynamic_item_call(component_type, item, interaction, match), + name=f'discord-ui-dynamic-item-{item.__name__}-{custom_id}', + ) + def dispatch_view(self, component_type: int, custom_id: str, interaction: Interaction) -> None: + self.dispatch_dynamic_items(component_type, custom_id, interaction) interaction_id: Optional[int] = None message_id: Optional[int] = None # Realistically, in a component based interaction the Interaction.message will never be None diff --git a/docs/interactions/api.rst b/docs/interactions/api.rst index 25b08bf26..8e930c6ef 100644 --- a/docs/interactions/api.rst +++ b/docs/interactions/api.rst @@ -443,6 +443,15 @@ Item .. autoclass:: discord.ui.Item :members: +DynamicItem +~~~~~~~~~~~~ + +.. attributetable:: discord.ui.DynamicItem + +.. autoclass:: discord.ui.DynamicItem + :members: + :inherited-members: + Button ~~~~~~~ diff --git a/examples/views/persistent.py b/examples/views/persistent.py index 5eddfab0b..e3dea8114 100644 --- a/examples/views/persistent.py +++ b/examples/views/persistent.py @@ -2,6 +2,7 @@ from discord.ext import commands import discord +import re # Define a simple View that persists between bot restarts @@ -29,6 +30,38 @@ class PersistentView(discord.ui.View): await interaction.response.send_message('This is grey.', ephemeral=True) +# More complicated cases might require parsing state out from the custom_id instead. +# For this use case, the library provides a `DynamicItem` to make this easier. +# The same constraints as above apply to this too. +# For this example, the `template` class parameter is used to give the library a regular +# expression to parse the custom_id with. +# These custom IDs will be in the form of e.g. `button:user:80088516616269824`. +class DynamicButton(discord.ui.DynamicItem[discord.ui.Button], template=r'button:user:(?P[0-9]+)'): + def __init__(self, user_id: int) -> None: + super().__init__( + discord.ui.Button( + label='Do Thing', + style=discord.ButtonStyle.blurple, + custom_id=f'button:user:{user_id}', + emoji='\N{THUMBS UP SIGN}', + ) + ) + self.user_id: int = user_id + + # This is called when the button is clicked and the custom_id matches the template. + @classmethod + async def from_custom_id(cls, interaction: discord.Interaction, match: re.Match[str], /): + user_id = int(match['id']) + return cls(user_id) + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + # Only allow the user who created the button to interact with it. + return interaction.user.id == self.user_id + + async def callback(self, interaction: discord.Interaction) -> None: + await interaction.response.send_message('This is your very own button!', ephemeral=True) + + class PersistentViewBot(commands.Bot): def __init__(self): intents = discord.Intents.default() @@ -43,6 +76,8 @@ class PersistentViewBot(commands.Bot): # 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()) + # For dynamic items, we must register the classes instead of the views. + self.add_dynamic_items(DynamicButton) async def on_ready(self): print(f'Logged in as {self.user} (ID: {self.user.id})') @@ -63,4 +98,13 @@ async def prepare(ctx: commands.Context): await ctx.send("What's your favourite colour?", view=PersistentView()) +@bot.command() +async def dynamic_button(ctx: commands.Context): + """Starts a dynamic button.""" + + view = discord.ui.View(timeout=None) + view.add_item(DynamicButton(ctx.author.id)) + await ctx.send('Here is your very own button!', view=view) + + bot.run('token')