Browse Source

Add initial support for buttons and components

pull/6961/head
Rapptz 4 years ago
parent
commit
98570793e4
  1. 3
      discord/__init__.py
  2. 23
      discord/abc.py
  3. 158
      discord/components.py
  4. 28
      discord/enums.py
  5. 20
      discord/http.py
  6. 28
      discord/interactions.py
  7. 33
      discord/message.py
  8. 13
      discord/state.py
  9. 52
      discord/types/components.py
  10. 15
      discord/types/interactions.py
  11. 2
      discord/types/message.py
  12. 14
      discord/ui/__init__.py
  13. 288
      discord/ui/button.py
  14. 140
      discord/ui/item.py
  15. 270
      discord/ui/view.py
  16. 111
      docs/api.rst
  17. 11
      setup.py

3
discord/__init__.py

@ -43,7 +43,7 @@ from .template import *
from .widget import *
from .object import *
from .reaction import *
from . import utils, opus, abc
from . import utils, opus, abc, ui
from .enums import *
from .embeds import *
from .mentions import *
@ -56,6 +56,7 @@ from .raw_models import *
from .team import *
from .sticker import *
from .interactions import *
from .components import *
VersionInfo = namedtuple('VersionInfo', 'major minor micro releaselevel serial')

23
discord/abc.py

@ -1154,7 +1154,7 @@ class Messageable(Protocol):
async def send(self, content=None, *, tts=False, embed=None, file=None,
files=None, delete_after=None, nonce=None,
allowed_mentions=None, reference=None,
mention_author=None):
mention_author=None, view=None):
"""|coro|
Sends a message to the destination with the content given.
@ -1212,6 +1212,10 @@ class Messageable(Protocol):
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.
.. versionadded:: 2.0
Raises
--------
@ -1255,6 +1259,14 @@ class Messageable(Protocol):
except AttributeError:
raise InvalidArgument('reference parameter must be Message or MessageReference') 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()
else:
components = None
if file is not None and files is not None:
raise InvalidArgument('cannot pass both file and files parameter to send()')
@ -1265,7 +1277,7 @@ class Messageable(Protocol):
try:
data = await state.http.send_files(channel.id, files=[file], allowed_mentions=allowed_mentions,
content=content, tts=tts, embed=embed, nonce=nonce,
message_reference=reference)
message_reference=reference, components=components)
finally:
file.close()
@ -1278,16 +1290,19 @@ class Messageable(Protocol):
try:
data = await state.http.send_files(channel.id, files=files, content=content, tts=tts,
embed=embed, nonce=nonce, allowed_mentions=allowed_mentions,
message_reference=reference)
message_reference=reference, components=components)
finally:
for f in files:
f.close()
else:
data = await state.http.send_message(channel.id, content, tts=tts, embed=embed,
nonce=nonce, allowed_mentions=allowed_mentions,
message_reference=reference)
message_reference=reference, components=components)
ret = state.create_message(channel=channel, data=data)
if view:
state.store_view(view, ret.id)
if delete_after is not None:
await ret.delete(delay=delete_after)
return ret

158
discord/components.py

@ -0,0 +1,158 @@
"""
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 List, Optional, TYPE_CHECKING, Tuple, Type, TypeVar
from .enums import try_enum, ComponentType, ButtonStyle
from .partial_emoji import PartialEmoji
if TYPE_CHECKING:
from .types.components import (
Component as ComponentPayload,
ButtonComponent as ButtonComponentPayload,
ComponentContainer as ComponentContainerPayload,
)
__all__ = (
'Component',
'Button',
)
C = TypeVar('C', bound='Component')
class Component:
"""Represents a Discord Bot UI Kit Component.
Currently, the only components supported by Discord are buttons and button groups.
.. 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, ...] = (
'type',
'children',
)
def __init__(self, data: ComponentPayload):
self.type: ComponentType = try_enum(ComponentType, data['type'])
self.children: List[Component] = [_component_factory(d) for d in data.get('components', [])]
def __repr__(self) -> str:
attrs = ' '.join(f'{key}={getattr(self, key)!r}' for key in self.__slots__)
return f'<{self.__class__.__name__} type={self.type!r} {attrs}>'
def to_dict(self) -> ComponentContainerPayload:
return {
'type': int(self.type),
'components': [child.to_dict() for child in self.children],
} # type: ignore
@classmethod
def _raw_construct(cls: Type[C], **kwargs) -> C:
self: C = cls.__new__(cls)
slots = cls.__slots__
for attr, value in kwargs.items():
if attr in slots:
setattr(self, attr, value)
return self
class Button(Component):
"""Represents a button from the Discord Bot UI Kit.
This inherits from :class:`Component`.
.. versionadded:: 2.0
Attributes
-----------
style: :class:`ComponentButtonStyle`
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: :class:`str`
The label of the button.
emoji: Optional[:class:`PartialEmoji`]
The emoji of the button, if available.
"""
__slots__: Tuple[str, ...] = Component.__slots__ + (
'style',
'custom_id',
'url',
'disabled',
'label',
'emoji',
)
def __init__(self, data: ButtonComponentPayload):
self.type: ComponentType = try_enum(ComponentType, data['type'])
self.style: ButtonStyle = try_enum(ButtonStyle, data['style'])
self.custom_id: Optional[str] = data.get('custom_id')
self.url: Optional[str] = data.get('url')
self.disabled: bool = data.get('disabled', False)
self.label: str = data['label']
self.emoji: Optional[PartialEmoji]
try:
self.emoji = PartialEmoji.from_dict(data['emoji'])
except KeyError:
self.emoji = None
def to_dict(self) -> ButtonComponentPayload:
payload = {
'type': 2,
'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
return payload # type: ignore
def _component_factory(data: ComponentPayload) -> Component:
component_type = data['type']
if component_type == 1:
return Component(data)
elif component_type == 2:
return Button(data) # type: ignore
else:
return Component(data)

28
discord/enums.py

@ -48,6 +48,8 @@ __all__ = (
'StickerType',
'InviteTarget',
'VideoQualityMode',
'ComponentType',
'ButtonStyle',
)
def _create_value_cls(name):
@ -435,6 +437,15 @@ class InviteTarget(Enum):
class InteractionType(Enum):
ping = 1
application_command = 2
component = 3
class InteractionResponseType(Enum):
pong = 1
# ack = 2 (deprecated)
# channel_message = 3 (deprecated)
channel_message = 4 # (with source)
deferred_channel_message = 5 # (with source)
ack = 6 # for components?
class VideoQualityMode(Enum):
auto = 1
@ -443,6 +454,23 @@ class VideoQualityMode(Enum):
def __int__(self):
return self.value
class ComponentType(Enum):
group = 1
button = 2
def __int__(self):
return self.value
class ButtonStyle(Enum):
blurple = 1
grey = 2
green = 3
red = 4
hyperlink = 5
def __int__(self):
return self.value
T = TypeVar('T')
def create_unknown_value(cls: Type[T], val: Any) -> T:

20
discord/http.py

@ -354,6 +354,7 @@ class HTTPClient:
nonce=None,
allowed_mentions=None,
message_reference=None,
components=None,
):
r = Route('POST', '/channels/{channel_id}/messages', channel_id=channel_id)
payload = {}
@ -376,6 +377,9 @@ class HTTPClient:
if message_reference:
payload['message_reference'] = message_reference
if components:
payload['components'] = components
return self.request(r, json=payload)
def send_typing(self, channel_id):
@ -393,6 +397,7 @@ class HTTPClient:
nonce=None,
allowed_mentions=None,
message_reference=None,
components=None,
):
form = []
@ -409,6 +414,8 @@ class HTTPClient:
payload['allowed_mentions'] = allowed_mentions
if message_reference:
payload['message_reference'] = message_reference
if components:
payload['components'] = components
form.append({'name': 'payload_json', 'value': utils.to_json(payload)})
if len(files) == 1:
@ -445,6 +452,7 @@ class HTTPClient:
nonce=None,
allowed_mentions=None,
message_reference=None,
components=None,
):
r = Route('POST', '/channels/{channel_id}/messages', channel_id=channel_id)
return self.send_multipart_helper(
@ -456,6 +464,7 @@ class HTTPClient:
nonce=nonce,
allowed_mentions=allowed_mentions,
message_reference=message_reference,
components=components,
)
def delete_message(self, channel_id, message_id, *, reason=None):
@ -1210,14 +1219,21 @@ class HTTPClient:
return self.request(route, form=form, files=[file])
def create_interaction_response(self, interaction_id, token):
def create_interaction_response(self, interaction_id, token, *, type, data=None):
r = Route(
'POST',
'/interactions/{interaction_id}/{interaction_token}/callback',
interaction_id=interaction_id,
interaction_token=token,
)
return self.request(r)
payload = {
'type': type,
}
if data is not None:
payload['data'] = data
return self.request(r, json=payload)
def get_original_interaction_response(
self,

28
discord/interactions.py

@ -30,6 +30,11 @@ from typing import Optional, TYPE_CHECKING
from . import utils
from .enums import try_enum, InteractionType
from .user import User
from .member import Member
from .message import Message
from .object import Object
__all__ = (
'Interaction',
)
@ -65,6 +70,8 @@ class Interaction:
The application ID that the interaction was for.
user: Optional[Union[:class:`User`, :class:`Member`]]
The user or member that sent the interaction.
message: Optional[:class:`Message`]
The message that sent this interaction.
token: :class:`str`
The token to continue the interaction. These are valid
for 15 minutes.
@ -77,6 +84,7 @@ class Interaction:
'channel_id',
'data',
'application_id',
'message',
'user',
'token',
'version',
@ -97,10 +105,28 @@ class Interaction:
self.guild_id = utils._get_as_snowflake(data, 'guild_id')
self.application_id = utils._get_as_snowflake(data, 'application_id')
channel = self.channel or Object(id=self.channel_id)
try:
self.message = Message(state=self._state, channel=channel, data=data['message'])
except KeyError:
self.message = None
try:
self.user = User(state=self._state, data=data['user'])
except KeyError:
self.user = None
# TODO: there's a potential data loss here
guild = self.guild or Object(id=self.guild_id)
try:
self.user = Member(state=self._state, guild=guild, data=data['member'])
except KeyError:
pass
@property
def guild(self) -> Optional[Guild]:
"""Optional[:class:`Guild`]: The guild the interaction was sent from."""
return self._state and self._state.get_guild(self.guild_id)
return self._state and self._state._get_guild(self.guild_id)
@property
def channel(self) -> Optional[GuildChannel]:

33
discord/message.py

@ -37,6 +37,7 @@ from .emoji import Emoji
from .partial_emoji import PartialEmoji
from .enums import MessageType, ChannelType, try_enum
from .errors import InvalidArgument, HTTPException
from .components import _component_factory
from .embeds import Embed
from .member import Member
from .flags import MessageFlags
@ -56,6 +57,8 @@ if TYPE_CHECKING:
Reaction as ReactionPayload,
)
from .types.components import Component as ComponentPayload
from .types.member import Member as MemberPayload
from .types.user import User as UserPayload
from .types.embed import Embed as EmbedPayload
@ -581,6 +584,10 @@ class Message(Hashable):
A list of stickers given to the message.
.. versionadded:: 1.6
components: List[:class:`Component`]
A list of components in the message.
.. versionadded:: 2.0
"""
__slots__ = (
@ -613,6 +620,7 @@ class Message(Hashable):
'application',
'activity',
'stickers',
'components',
)
if TYPE_CHECKING:
@ -643,7 +651,8 @@ class Message(Hashable):
self.tts = data['tts']
self.content = data['content']
self.nonce = data.get('nonce')
self.stickers = [Sticker(data=data, state=state) for data in data.get('stickers', [])]
self.stickers = [Sticker(data=d, state=state) for d in data.get('stickers', [])]
self.components = [_component_factory(d) for d in data.get('components', [])]
try:
ref = data['message_reference']
@ -837,6 +846,9 @@ class Message(Hashable):
if role is not None:
self.role_mentions.append(role)
def _handle_components(self, components: List[ComponentPayload]):
self.components = [_component_factory(d) for d in components]
def _rebind_channel_reference(self, new_channel: Union[TextChannel, DMChannel, GroupChannel]) -> None:
self.channel = new_channel
@ -1134,6 +1146,11 @@ class Message(Hashable):
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.
.. versionadded:: 2.0
Raises
-------
@ -1191,10 +1208,24 @@ class Message(Hashable):
else:
fields['attachments'] = [a.to_dict() for a in attachments]
try:
view = fields.pop('view')
except KeyError:
# To check for the view afterwards
view = None
else:
if view:
fields['components'] = view.to_components()
else:
fields['components'] = []
if fields:
data = await self._state.http.edit_message(self.channel.id, self.id, **fields)
self._update(data)
if view:
self._state.store_view(view, self.id)
if delete_after is not None:
await self.delete(delay=delete_after)

13
discord/state.py

@ -52,6 +52,7 @@ from .flags import ApplicationFlags, Intents, MemberCacheFlags
from .object import Object
from .invite import Invite
from .interactions import Interaction
from .ui.view import ViewStore
class ChunkRequest:
def __init__(self, guild_id, loop, resolver, *, cache=True):
@ -187,6 +188,7 @@ class ConnectionState:
self._users = weakref.WeakValueDictionary()
self._emojis = {}
self._guilds = {}
self._view_store = ViewStore(self)
self._voice_clients = {}
# LRU of max size 128
@ -278,6 +280,9 @@ class ConnectionState:
self._emojis[emoji_id] = emoji = Emoji(guild=guild, state=self, data=data)
return emoji
def store_view(self, view, message_id=None):
self._view_store.add_view(view, message_id)
@property
def guilds(self):
return list(self._guilds.values())
@ -509,6 +514,9 @@ class ConnectionState:
else:
self.dispatch('raw_message_edit', raw)
if 'components' in data and self._view_store.is_message_tracked(raw.message_id):
self._view_store.update_view(raw.message_id, data['components'])
def parse_message_reaction_add(self, data):
emoji = data['emoji']
emoji_id = utils._get_as_snowflake(emoji, 'id')
@ -581,6 +589,11 @@ class ConnectionState:
def parse_interaction_create(self, data):
interaction = Interaction(data=data, state=self)
if data['type'] == 3: # interaction component
custom_id = interaction.data['custom_id'] # type: ignore
component_type = interaction.data['component_type'] # type: ignore
self._view_store.dispatch(component_type, custom_id, interaction)
self.dispatch('interaction', interaction)
def parse_presence_update(self, data):

52
discord/types/components.py

@ -0,0 +1,52 @@
"""
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 Literal, TypedDict, Union
from .emoji import PartialEmoji
ComponentType = Literal[1, 2]
ButtonStyle = Literal[1, 2, 3, 4, 5]
class ComponentContainer(TypedDict):
type: Literal[1]
components: Component
class _ButtonComponentOptional(TypedDict, total=False):
custom_id: str
url: str
disabled: bool
emoji: PartialEmoji
class ButtonComponent(_ButtonComponentOptional):
type: Literal[2]
style: ButtonStyle
label: str
Component = Union[ComponentContainer, ButtonComponent]

15
discord/types/interactions.py

@ -24,15 +24,18 @@ DEALINGS IN THE SOFTWARE.
from __future__ import annotations
from typing import Dict, TypedDict, Union, List, Literal
from typing import TYPE_CHECKING, Dict, TypedDict, Union, List, Literal
from .snowflake import Snowflake
from .message import AllowedMentions
from .components import ComponentType
from .channel import PartialChannel
from .embed import Embed
from .member import Member
from .role import Role
from .user import User
if TYPE_CHECKING:
from .message import AllowedMentions, Message
class _ApplicationCommandOptional(TypedDict, total=False):
options: List[ApplicationCommandOption]
@ -114,12 +117,18 @@ class ApplicationCommandInteractionData(_ApplicationCommandInteractionDataOption
name: str
class ComponentInteractionData(TypedDict):
custom_id: str
component_type: ComponentType
class _InteractionOptional(TypedDict, total=False):
data: ApplicationCommandInteractionData
data: Union[ApplicationCommandInteractionData, ComponentInteractionData]
guild_id: Snowflake
channel_id: Snowflake
member: Member
user: User
message: Message
class Interaction(_InteractionOptional):

2
discord/types/message.py

@ -31,6 +31,7 @@ from .user import User
from .emoji import PartialEmoji
from .embed import Embed
from .channel import ChannelType
from .components import Component
from .interactions import MessageInteraction
@ -119,6 +120,7 @@ class _MessageOptional(TypedDict, total=False):
stickers: List[Sticker]
referenced_message: Optional[Message]
interaction: MessageInteraction
components: List[Component]
MessageType = Literal[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14, 15, 19, 20]

14
discord/ui/__init__.py

@ -0,0 +1,14 @@
"""
discord.ui
~~~~~~~~~~~
Bot UI Kit helper for the Discord API
:copyright: (c) 2015-present Rapptz
:license: MIT, see LICENSE for more details.
"""
from .view import *
from .item import *
from .button import *

288
discord/ui/button.py

@ -0,0 +1,288 @@
"""
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 Callable, Optional, TYPE_CHECKING, Tuple, Type, TypeVar, Union
import inspect
import re
import os
from .item import Item, ItemCallbackType
from ..enums import ButtonStyle, ComponentType
from ..partial_emoji import PartialEmoji
from ..components import Button as ButtonComponent
__all__ = (
'Button',
'button',
)
if TYPE_CHECKING:
from ..components import Component
_custom_emoji = re.compile(r'<?(?P<animated>a)?:?(?P<name>[A-Za-z0-9\_]+):(?P<id>[0-9]{13,20})>?')
def _to_partial_emoji(obj: Union[str, PartialEmoji], *, _custom_emoji=_custom_emoji) -> PartialEmoji:
if isinstance(obj, PartialEmoji):
return obj
obj = str(obj)
match = _custom_emoji.match(obj)
if match is not None:
groups = match.groupdict()
animated = bool(groups['animated'])
emoji_id = int(groups['id'])
name = groups['name']
return PartialEmoji(name=name, animated=animated, id=emoji_id)
return PartialEmoji(name=obj, id=None, animated=False)
B = TypeVar('B', bound='Button')
class Button(Item):
"""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: :class:`str`
The label of the button.
emoji: Optional[:class:`PartialEmoji`]
The emoji of the button, if available.
"""
__slots__: Tuple[str, ...] = Item.__slots__ + ('_underlying',)
__item_repr_attributes__: Tuple[str, ...] = (
'style',
'url',
'disabled',
'label',
'emoji',
'group_id',
)
def __init__(
self,
*,
style: ButtonStyle,
label: str,
disabled: bool = False,
custom_id: Optional[str] = None,
url: Optional[str] = None,
emoji: Optional[Union[str, PartialEmoji]] = None,
group: Optional[int] = None,
):
super().__init__()
if custom_id is not None and url is not None:
raise TypeError('cannot mix both url and custom_id with Button')
if url is None and custom_id is None:
custom_id = os.urandom(16).hex()
self._underlying = ButtonComponent._raw_construct(
type=ComponentType.button,
custom_id=custom_id,
url=url,
disabled=disabled,
label=label,
style=style,
emoji=None if emoji is None else _to_partial_emoji(emoji),
)
self.group_id = group
@property
def style(self) -> ButtonStyle:
""":class:`discord.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) -> Optional[str]:
"""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.
"""
return self._underlying.custom_id
@custom_id.setter
def custom_id(self, value: Optional[str]):
if value is not None and not isinstance(value, str):
raise TypeError('custom_id must be None or str')
self._underlying.custom_id = value
@property
def url(self) -> Optional[str]:
"""Optional[:class:`str`]: The URL this button sends you to."""
return self._underlying.url
@url.setter
def url(self, value: Optional[str]):
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:
""":class:`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:
""":class:`str`: The label of the button."""
return self._underlying.label
@label.setter
def label(self, value: str):
self._underlying.label = str(value)
@property
def emoji(self) -> Optional[PartialEmoji]:
"""Optional[:class:`PartialEmoji`]: The emoji of the button, if available."""
return self._underlying.emoji
@emoji.setter
def emoji(self, value: Optional[Union[str, PartialEmoji]]): # type: ignore
if value is not None:
self._underlying.emoji = _to_partial_emoji(value)
else:
self._underlying.emoji = None
def copy(self: B) -> B:
button = self.__class__(
style=self.style,
label=self.label,
disabled=self.disabled,
custom_id=self.custom_id,
url=self.url,
emoji=self.emoji,
group=self.group_id,
)
button.callback = self.callback
return button
@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,
group=None,
)
@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 True
def refresh_state(self, button: ButtonComponent) -> None:
self._underlying = button
def button(
label: str,
*,
custom_id: Optional[str] = None,
disabled: bool = False,
style: ButtonStyle = ButtonStyle.grey,
emoji: Optional[Union[str, PartialEmoji]] = None,
group: Optional[int] = None,
) -> Callable[[ItemCallbackType], Button]:
"""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::
Buttons with a URL cannot be created with this function.
Consider creating a :class:`Button` manually instead.
This is because buttons with a URL do not have a callback
associated with them since Discord does not do any processing
with it.
Parameters
------------
label: :class:`str`
The label of the button.
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:`PartialEmoji`]]
The emoji of the button. This can be in string form or a :class:`PartialEmoji`.
group: Optional[:class:`int`]
The relative group this button belongs to. A Discord component can only have 5
groups. By default, items are arranged automatically into those 5 groups. If you'd
like to control the relative positioning of the group then passing an index is advised.
For example, group=1 will show up before group=2. Defaults to ``None``, which is automatic
ordering.
"""
def decorator(func: ItemCallbackType) -> Button:
nonlocal custom_id
if not inspect.iscoroutinefunction(func):
raise TypeError('button function must be a coroutine function')
custom_id = custom_id or os.urandom(32).hex()
button = Button(style=style, custom_id=custom_id, url=None, disabled=disabled, label=label, emoji=emoji, group=group)
button.callback = func
return button
return decorator

140
discord/ui/item.py

@ -0,0 +1,140 @@
"""
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 Any, Callable, Coroutine, Dict, Optional, TYPE_CHECKING, Tuple, Type, TypeVar, Union
import inspect
from ..interactions import Interaction
__all__ = (
'Item',
)
if TYPE_CHECKING:
from ..enums import ComponentType
from .view import View
from ..components import Component
I = TypeVar('I', bound='Item')
ItemCallbackType = Callable[[Any, I, Interaction], Coroutine[Any, Any, Any]]
class Item:
"""Represents the base UI item that all UI components inherit from.
The current UI items supported are:
- :class:`discord.ui.Button`
"""
__slots__: Tuple[str, ...] = (
'_callback',
'_pass_view_arg',
'group_id',
)
__item_repr_attributes__: Tuple[str, ...] = ('group_id',)
def __init__(self):
self._callback: Optional[ItemCallbackType] = None
self._pass_view_arg = True
self.group_id: Optional[int] = None
def to_component_dict(self) -> Dict[str, Any]:
raise NotImplementedError
def copy(self: I) -> I:
raise NotImplementedError
def refresh_state(self, component: Component) -> 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 __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 callback(self) -> Optional[ItemCallbackType]:
"""Returns the underlying callback associated with this interaction."""
return self._callback
@callback.setter
def callback(self, value: Optional[ItemCallbackType]):
if value is None:
self._callback = None
return
# Check if it's a partial function
try:
partial = value.func
except AttributeError:
pass
else:
if not inspect.iscoroutinefunction(value.func):
raise TypeError(f'inner partial function must be a coroutine')
# Check if the partial is bound
try:
bound_partial = partial.__self__
except AttributeError:
pass
else:
self._pass_view_arg = not hasattr(bound_partial, '__discord_ui_view__')
self._callback = value
return
try:
func_self = value.__self__
except AttributeError:
pass
else:
if not isinstance(func_self, Item):
raise TypeError(f'callback bound method must be from Item not {func_self!r}')
else:
value = value.__func__
if not inspect.iscoroutinefunction(value):
raise TypeError(f'callback must be a coroutine not {value!r}')
self._callback = value
async def _do_call(self, view: View, interaction: Interaction):
if self._pass_view_arg:
await self._callback(view, self, interaction)
else:
await self._callback(self, interaction) # type: ignore

270
discord/ui/view.py

@ -0,0 +1,270 @@
"""
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 Any, Callable, ClassVar, Dict, Iterator, List, Optional, TYPE_CHECKING, Tuple
from functools import partial
from itertools import groupby
import asyncio
import sys
import time
import os
from .item import Item
from ..enums import ComponentType
from ..components import (
Component,
_component_factory,
Button as ButtonComponent,
)
__all__ = (
'View',
)
if TYPE_CHECKING:
from ..interactions import Interaction
from ..types.components import Component as ComponentPayload
def _walk_all_components(components: List[Component]) -> Iterator[Component]:
for item in components:
if item.type is ComponentType.group:
yield from item.children
else:
yield item
def _component_to_item(component: Component) -> Item:
if isinstance(component, ButtonComponent):
from .button import Button
return Button.from_component(component)
return Item.from_component(component)
class View:
"""Represents a UI view.
This object must be inherited to create a UI within Discord.
Parameters
-----------
timeout: Optional[:class:`float`]
Timeout from last interaction with the UI before no longer accepting input.
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.
"""
__slots__ = (
'timeout',
'children',
'id',
'_cancel_callback',
)
__discord_ui_view__: ClassVar[bool] = True
if TYPE_CHECKING:
__view_children_items__: ClassVar[List[Item]]
def __init_subclass__(cls) -> None:
children: List[Item] = []
for base in reversed(cls.__mro__):
for member in base.__dict__.values():
if isinstance(member, Item):
children.append(member)
if len(children) > 25:
raise TypeError('View cannot have more than 25 children')
cls.__view_children_items__ = children
def __init__(self, timeout: Optional[float] = 180.0):
self.timeout = timeout
self.children: List[Item] = [i.copy() for i in self.__view_children_items__]
self.id = os.urandom(16).hex()
self._cancel_callback: Optional[Callable[[View], None]] = None
def to_components(self) -> List[Dict[str, Any]]:
def key(item: Item) -> int:
if item.group_id is None:
return sys.maxsize
return item.group_id
children = sorted(self.children, key=key)
components: List[Dict[str, Any]] = []
for _, group in groupby(children, key=key):
group = list(group)
if len(group) <= 5:
components.append(
{
'type': 1,
'components': [item.to_component_dict() for item in group],
}
)
else:
components.extend(
{
'type': 1,
'components': [item.to_component_dict() for item in group[index : index + 5]],
}
for index in range(0, len(group), 5)
)
return components
@property
def _expires_at(self) -> Optional[float]:
if self.timeout:
return time.monotonic() + self.timeout
return None
def add_item(self, item: Item) -> None:
"""Adds an item to the view.
Parameters
-----------
item: :class:`Item`
The item to add to the view.
Raises
--------
TypeError
A :class:`Item` was not passed.
ValueError
Maximum number of children has been exceeded (25).
"""
if len(self.children) > 25:
raise ValueError('maximum number of children exceeded')
if not isinstance(item, Item):
raise TypeError(f'expected Item not {item.__class__!r}')
self.children.append(item)
async def _scheduled_task(self, state: Any, item: Item, interaction: Interaction):
await state.http.create_interaction_response(interaction.id, interaction.token, type=6)
await item._do_call(self, interaction)
def dispatch(self, state: Any, item: Item, interaction: Interaction):
asyncio.create_task(self._scheduled_task(state, item, interaction), name=f'discord-ui-view-dispatch-{self.id}')
def refresh(self, components: List[Component]):
# This is pretty hacky at the moment
# fmt: off
old_state: Dict[Tuple[int, str], Item] = {
(item.type.value, item.custom_id): item # type: ignore
for item in self.children
if item.is_dispatchable()
}
# fmt: on
children: List[Item] = []
for component in _walk_all_components(components):
try:
older = old_state[(component.type.value, component.custom_id)] # type: ignore
except (KeyError, AttributeError):
children.append(_component_to_item(component))
else:
older.refresh_state(component)
children.append(older)
self.children = children
def stop(self) -> None:
"""Stops listening to interaction events from this view.
This operation cannot be undone.
"""
if self._cancel_callback:
self._cancel_callback(self)
class ViewStore:
def __init__(self, state):
# (component_type, custom_id): (View, Item, Expiry)
self._views: Dict[Tuple[int, str], Tuple[View, Item, Optional[float]]] = {}
# message_id: View
self._synced_message_views: Dict[int, View] = {}
self._state = state
def __verify_integrity(self):
to_remove: List[Tuple[int, str]] = []
now = time.monotonic()
for (k, (_, _, expiry)) in self._views.items():
if expiry is not None and now >= expiry:
to_remove.append(k)
for k in to_remove:
del self._views[k]
def add_view(self, view: View, message_id: Optional[int] = None):
self.__verify_integrity()
expiry = view._expires_at
view._cancel_callback = partial(self.remove_view)
for item in view.children:
if item.is_dispatchable():
self._views[(item.type.value, item.custom_id)] = (view, item, expiry) # type: ignore
if message_id is not None:
self._synced_message_views[message_id] = view
def remove_view(self, view: View):
for item in view.children:
if item.is_dispatchable():
self._views.pop((item.type.value, item.custom_id)) # type: ignore
for key, value in self._synced_message_views.items():
if value.id == view.id:
del self._synced_message_views[key]
break
def dispatch(self, component_type: int, custom_id: str, interaction: Interaction):
self.__verify_integrity()
key = (component_type, custom_id)
value = self._views.get(key)
if value is None:
return
view, item, _ = value
self._views[key] = (view, item, view._expires_at)
view.dispatch(self._state, item, interaction)
def is_message_tracked(self, message_id: int):
return message_id in self._synced_message_views
def update_view(self, message_id: int, components: List[ComponentPayload]):
# pre-req: is_message_tracked == true
view = self._synced_message_views[message_id]
view.refresh([_component_factory(d) for d in components])

111
docs/api.rst

@ -1172,10 +1172,66 @@ of :class:`enum.Enum`.
.. attribute:: ping
Represents Discord pinging to see if the interaction response server is alive.
.. attribute:: application_command
Represents a slash command interaction.
.. attribute:: component
Represents a component based interaction, i.e. using the Discord Bot UI Kit.
.. class:: InteractionResponseType
Specifies the response type for the interaction.
.. versionadded:: 2.0
.. attribute:: pong
Pongs the interaction when given a ping.
.. attribute:: channel_message
Respond to a slash command with a message.
.. attribute:: deferred_channel_message
Responds to a slash command with a message at a later time.
.. attribute:: ack
Acknowledges the component interaction.
.. class:: ComponentType
Represents the component type of a component.
.. versionadded:: 2.0
.. attribute:: group
Represents the group component which holds different components.
.. attribute:: button
Represents a button component.
.. class:: ButtonStyle
Represents the style of the button component.
.. versionadded:: 2.0
.. attribute:: blurple::
Represents a blurple button.
.. attribute:: grey::
Represents a grey button.
.. attribute:: green::
Represents a green button.
.. attribute:: red::
Represents a red button.
.. attribute:: hyperlink::
Represents a hyperlink button.
.. class:: VoiceRegion
@ -2794,6 +2850,24 @@ Message
.. autoclass:: Message()
:members:
Component
~~~~~~~~~~
.. attributetable:: Component
.. autoclass:: Component()
:members:
Button
~~~~~~~
.. attributetable:: Button
.. autoclass:: Button()
:members:
:inherited-members:
DeletedReferencedMessage
~~~~~~~~~~~~~~~~~~~~~~~~~
@ -3327,6 +3401,41 @@ PublicUserFlags
.. autoclass:: PublicUserFlags()
:members:
.. _discord_ui_kit:
Bot UI Kit
-------------
The library has helpers to help create component-based UIs.
View
~~~~~~~
.. attributetable:: discord.ui.View
.. autoclass:: discord.ui.View
:members:
Item
~~~~~~~
.. attributetable:: discord.ui.Item
.. autoclass:: discord.ui.Item
:members:
Button
~~~~~~~
.. attributetable:: discord.ui.Button
.. autoclass:: discord.ui.Button
:members:
:inherited-members:
.. autofunction:: discord.ui.button
Exceptions
------------

11
setup.py

@ -42,6 +42,15 @@ extras_require = {
]
}
packages = [
'discord',
'discord.types',
'discord.ui',
'discord.webhook',
'discord.ext.commands',
'discord.ext.tasks',
]
setup(name='discord.py',
author='Rapptz',
url='https://github.com/Rapptz/discord.py',
@ -50,7 +59,7 @@ setup(name='discord.py',
"Issue tracker": "https://github.com/Rapptz/discord.py/issues",
},
version=version,
packages=['discord', 'discord.types', 'discord.webhook', 'discord.ext.commands', 'discord.ext.tasks'],
packages=packages,
license='MIT',
description='A Python wrapper for the Discord API',
long_description=readme,

Loading…
Cancel
Save