mirror of
https://github.com/Rapptz/discord.py.git
synced 2025-04-19 15:36:02 +00:00
Add support for dynamic items that parse custom_id for state
This commit is contained in:
parent
78ce5f2331
commit
a852f90358
@ -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.
|
||||
|
||||
|
@ -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
|
||||
|
@ -15,3 +15,4 @@ from .item import *
|
||||
from .button import *
|
||||
from .select import *
|
||||
from .text_input import *
|
||||
from .dynamic import *
|
||||
|
205
discord/ui/dynamic.py
Normal file
205
discord/ui/dynamic.py
Normal file
@ -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
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -443,6 +443,15 @@ Item
|
||||
.. autoclass:: discord.ui.Item
|
||||
:members:
|
||||
|
||||
DynamicItem
|
||||
~~~~~~~~~~~~
|
||||
|
||||
.. attributetable:: discord.ui.DynamicItem
|
||||
|
||||
.. autoclass:: discord.ui.DynamicItem
|
||||
:members:
|
||||
:inherited-members:
|
||||
|
||||
Button
|
||||
~~~~~~~
|
||||
|
||||
|
@ -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<id>[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')
|
||||
|
Loading…
x
Reference in New Issue
Block a user