From 7b11d8c256ac5446aaba4718fdf66b4520cf7bac Mon Sep 17 00:00:00 2001 From: dolfies Date: Wed, 10 Nov 2021 11:49:28 -0500 Subject: [PATCH] Get it starting --- .gitignore | 1 + discord/__init__.py | 3 +- discord/abc.py | 8 - discord/activity.py | 2 +- discord/calls.py | 2 + discord/client.py | 17 +- discord/enums.py | 7 + discord/gateway.py | 11 + discord/guild.py | 41 +-- discord/http.py | 8 +- discord/member.py | 3 +- discord/message.py | 35 ++- discord/recorder.py | 2 + discord/relationship.py | 2 + discord/stage_instance.py | 10 +- discord/state.py | 17 +- discord/threads.py | 8 +- discord/ui/__init__.py | 15 -- discord/ui/button.py | 290 --------------------- discord/ui/item.py | 131 ---------- discord/ui/select.py | 357 ------------------------- discord/ui/view.py | 529 -------------------------------------- discord/user.py | 15 +- 23 files changed, 135 insertions(+), 1379 deletions(-) delete mode 100644 discord/ui/__init__.py delete mode 100644 discord/ui/button.py delete mode 100644 discord/ui/item.py delete mode 100644 discord/ui/select.py delete mode 100644 discord/ui/view.py diff --git a/.gitignore b/.gitignore index b556ebbb9..4500d2782 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ docs/crowdin.py *.jpg *.flac *.mo +*test.py diff --git a/discord/__init__.py b/discord/__init__.py index e3dacd63a..f4149fb14 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -43,7 +43,7 @@ from .template import * from .widget import * from .object import * from .reaction import * -from . import utils, opus, abc, ui +from . import utils, opus, abc from .enums import * from .embeds import * from .mentions import * @@ -55,7 +55,6 @@ from .raw_models import * from .team import * from .sticker import * from .stage_instance import * -from .interactions import * from .components import * from .threads import * diff --git a/discord/abc.py b/discord/abc.py index 9b83aeb43..a696de267 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -1167,7 +1167,6 @@ class Messageable: allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: View = ..., ) -> Message: ... @@ -1185,7 +1184,6 @@ class Messageable: allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: View = ..., ) -> Message: ... @@ -1203,7 +1201,6 @@ class Messageable: allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: View = ..., ) -> Message: ... @@ -1221,7 +1218,6 @@ class Messageable: allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: View = ..., ) -> Message: ... @@ -1408,7 +1404,6 @@ class Messageable: allowed_mentions=allowed_mentions, message_reference=reference, stickers=stickers, - components=components, ) finally: for f in files: @@ -1424,12 +1419,9 @@ class Messageable: allowed_mentions=allowed_mentions, message_reference=reference, stickers=stickers, - 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) diff --git a/discord/activity.py b/discord/activity.py index 124132afe..999c46dce 100644 --- a/discord/activity.py +++ b/discord/activity.py @@ -738,7 +738,7 @@ class CustomActivity(BaseActivity): The emoji to pass to the activity, if any. """ - __slots__ = ('name', 'emoji') + __slots__ = ('name', 'emoji', 'state') def __init__(self, name: Optional[str], *, emoji: Optional[PartialEmoji] = None, **extra: Any): super().__init__(**extra) diff --git a/discord/calls.py b/discord/calls.py index 6c3254e23..f5317add2 100644 --- a/discord/calls.py +++ b/discord/calls.py @@ -22,6 +22,8 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ +from __future__ import annotations + import datetime from typing import List, Optional, TYPE_CHECKING, Union diff --git a/discord/client.py b/discord/client.py index 3699213ee..9e0a4e914 100644 --- a/discord/client.py +++ b/discord/client.py @@ -44,6 +44,7 @@ from .enums import ChannelType, Status, VoiceRegion, try_enum from .mentions import AllowedMentions from .errors import * from .gateway import * +from .gateway import ConnectionClosed from .activity import ActivityTypes, BaseActivity, create_activity from .voice_client import VoiceClient from .http import HTTPClient @@ -236,7 +237,7 @@ class Client: def _handle_connect(self) -> None: state = self._connection activity = create_activity(state._activity) - status = try_enum(Status, state._status) + status = state._status and try_enum(Status, state._status) if status is not None or activity is not None: self.loop.create_task(self.change_presence(activity=activity, status=status)) @@ -433,9 +434,10 @@ class Client: _log.info('Logging in using static token.') - data = await self.http.static_login(token.strip()) - self._state.analytics_token = data.get('analytics_token') - self._connection.user = ClientUser(state=self._connection, data=data) + state = self._connection + data = await state.http.static_login(token.strip()) + state.analytics_token = data.get('analytics_token', '') + self._connection.user = ClientUser(state=state, data=data) async def connect(self, *, reconnect: bool = True) -> None: """|coro| @@ -803,7 +805,7 @@ class Client: .. note:: To retrieve standard stickers, use :meth:`.fetch_sticker`. - or :meth:`.fetch_premium_sticker_packs`. + or :meth:`.fetch_sticker_packs`. Returns -------- @@ -1041,6 +1043,7 @@ class Client: status_str = 'invisible' status = Status.offline else: + breakpoint() status_str = str(status) await self.ws.change_presence(activity=activity, status=status_str, afk=afk) @@ -1607,7 +1610,7 @@ class Client: return cls(state=self._connection, data=data) # type: ignore async def fetch_sticker_packs( - self, *, country='US', locale='en-US', *, payment_source_id: int = MISSING + self, *, country='US', locale='en-US', payment_source_id: int = MISSING ) -> List[StickerPack]: """|coro| @@ -1697,8 +1700,8 @@ class Client: List[:class:`PrivateChannel`] All your private channels. """ - channels = await self._state.http.get_private_channels() state = self._connection + channels = await state.http.get_private_channels() return [_private_channel_factory(data['type'])(me=self.user, data=data, state=state) for data in channels] async def create_dm(self, user: Snowflake) -> DMChannel: diff --git a/discord/enums.py b/discord/enums.py index 59d69f87a..1d91d1a26 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -367,6 +367,13 @@ class DefaultAvatar(Enum): return self.name +class RelationshipType(Enum): + friend = 1 + blocked = 2 + incoming_request = 3 + outgoing_request = 4 + + class NotificationLevel(Enum, comparable=True): all_messages = 0 only_mentions = 1 diff --git a/discord/gateway.py b/discord/gateway.py index 6a49b9e0a..7d484646a 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -689,6 +689,17 @@ class DiscordWebSocket: _log.debug('Updating %s voice state to %s.', guild_id or 'client', payload) await self.send_as_json(payload) + async def access_dm(self, channel_id): + payload = { + 'op': self.ACCESS_DM, + 'd': { + 'channel_id': channel_id + } + } + + _log.debug('Sending ACCESS_DM for channel %s.', channel_id) + await self.send_as_json(payload) + async def close(self, code=4000): if self._keep_alive: self._keep_alive.stop() diff --git a/discord/guild.py b/discord/guild.py index 5af2da176..9fc5afabb 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -68,7 +68,7 @@ from .enums import ( from .mixins import Hashable from .user import User from .invite import Invite -from .iterators import AuditLogIterator, MemberIterator +from .iterators import AuditLogIterator from .widget import Widget from .asset import Asset from .flags import SystemChannelFlags @@ -234,7 +234,6 @@ class Guild(Hashable): __slots__ = ( 'afk_timeout', - 'afk_channel', 'name', 'id', 'unavailable', @@ -255,6 +254,7 @@ class Guild(Hashable): 'premium_subscription_count', 'preferred_locale', 'nsfw_level', + 'owner_application_id', '_members', '_channels', '_icon', @@ -265,6 +265,8 @@ class Guild(Hashable): '_large', '_splash', '_voice_states', + '_afk_channel_id', + '_widget_channel_id', '_system_channel_id', '_system_channel_flags', '_discovery_splash', @@ -294,6 +296,13 @@ class Guild(Hashable): self._state: ConnectionState = state self._from_data(data) + # Get it running + @property + def subscribed(self): + return False + async def subscribe(self, *args, **kwargs): + pass + def _add_channel(self, channel: GuildChannel, /) -> None: self._channels[channel.id] = channel @@ -1334,7 +1343,6 @@ class Guild(Hashable): preferred_locale: str = MISSING, rules_channel: Optional[TextChannel] = MISSING, public_updates_channel: Optional[TextChannel] = MISSING, - features: List[str] = MISSING, ) -> Guild: r"""|coro| @@ -1432,7 +1440,6 @@ class Guild(Hashable): The newly updated guild. Note that this has the same limitations as mentioned in :meth:`Client.fetch_guild` and may not have full data. """ - # TODO: see what fields are sent no matter if they're changed or not http = self._state.http @@ -1735,7 +1742,6 @@ class Guild(Hashable): List[:class:`BanEntry`] A list of :class:`BanEntry` objects. """ - data: List[BanPayload] = await self._state.http.get_bans(self.id) return [BanEntry(user=User(state=self._state, data=e['user']), reason=e['reason']) for e in data] @@ -1795,7 +1801,6 @@ class Guild(Hashable): The number of members pruned. If ``compute_prune_count`` is ``False`` then this returns ``None``. """ - if not isinstance(days, int): raise InvalidArgument(f'Expected int for ``days``, received {days.__class__.__name__} instead.') @@ -1850,7 +1855,6 @@ class Guild(Hashable): List[:class:`Webhook`] The webhooks for this guild. """ - from .webhook import Webhook data = await self._state.http.guild_webhooks(self.id) @@ -1887,7 +1891,6 @@ class Guild(Hashable): :class:`int` The number of members estimated to be pruned. """ - if not isinstance(days, int): raise InvalidArgument(f'Expected int for ``days``, received {days.__class__.__name__} instead.') @@ -1919,7 +1922,6 @@ class Guild(Hashable): List[:class:`Invite`] The list of invites that are currently active. """ - data = await self._state.http.invites_from(self.id) result = [] for invite in data: @@ -2257,7 +2259,6 @@ class Guild(Hashable): :class:`Emoji` The created emoji. """ - img = utils._bytes_to_base64_data(image) if roles: role_ids = [role.id for role in roles] @@ -2289,7 +2290,6 @@ class Guild(Hashable): HTTPException An error occurred deleting the emoji. """ - await self._state.http.delete_custom_emoji(self.id, emoji.id, reason=reason) async def fetch_roles(self) -> List[Role]: @@ -2597,14 +2597,12 @@ class Guild(Hashable): The special vanity invite. If ``None`` then the guild does not have a vanity invite set. """ - - # we start with { code: abc } + # We start with { code: abc } payload = await self._state.http.get_vanity_code(self.id) if not payload['code']: - return None + return - # get the vanity URL channel since default channels aren't - # reliable or a thing anymore + # Get the vanity channel & uses data = await self._state.http.get_invite(payload['code']) channel = self.get_channel(int(data['channel']['id'])) @@ -2692,7 +2690,7 @@ class Guild(Hashable): self, before=before, after=after, limit=limit, oldest_first=oldest_first, user_id=user_id, action_type=action ) - async def ack(self): + async def ack(self) -> None: """|coro| Marks every message in this guild as read. @@ -2781,6 +2779,12 @@ class Guild(Hashable): ClientException Insufficient permissions. """ + if not self.me or not any({ + self.me.guild_permissions.kick_members, + self.me.guild_permissions.manage_roles, + self.me.guild_permissions.ban_members + }): + raise ClientException('You don\'t have permission to chunk this guild') if not self._state.is_guild_evicted(self): return await self._state.chunk_guild(self, cache=cache) @@ -2837,7 +2841,6 @@ class Guild(Hashable): List[:class:`Member`] The list of members that have matched the query. """ - if query is None: if query == '': raise ValueError('Cannot pass empty query string.') @@ -2914,7 +2917,6 @@ class Guild(Hashable): HTTPException Muting failed. """ - fields = { 'muted': True } @@ -2940,5 +2942,4 @@ class Guild(Hashable): HTTPException Unmuting failed. """ - await self._state.http.edit_guild_settings(self.id, muted=False) \ No newline at end of file diff --git a/discord/http.py b/discord/http.py index aba1c328e..6305a4b72 100644 --- a/discord/http.py +++ b/discord/http.py @@ -49,7 +49,6 @@ from urllib.parse import quote as _uriquote import weakref import aiohttp -from types import snowflake from .enums import RelationshipAction from .errors import HTTPException, Forbidden, NotFound, LoginFailure, DiscordServerError, GatewayNotFound, InvalidArgument @@ -84,6 +83,7 @@ if TYPE_CHECKING: widget, threads, voice, + snowflake, sticker, ) from .types.snowflake import Snowflake, SnowflakeList @@ -295,7 +295,7 @@ class HTTPClient: if 'json' in kwargs: headers['Content-Type'] = 'application/json' - kwargs['data'] = utils.to_json(kwargs.pop('json')) + kwargs['data'] = utils._to_json(kwargs.pop('json')) if 'context_properties' in kwargs: context_properties = kwargs.pop('context_properties') @@ -444,10 +444,10 @@ class HTTPClient: return self.request(Route('GET', '/users/@me'), params=params) async def static_login(self, token: str) -> user.User: - # Necessary to get aiohttp to stop complaining about session creation - self.__session = aiohttp.ClientSession(connector=self.connector) old_token, self.token = self.token, token + await self.startup() + try: data = await self.get_me() except HTTPException as exc: diff --git a/discord/member.py b/discord/member.py index 7c666c71d..3a8cc9fe5 100644 --- a/discord/member.py +++ b/discord/member.py @@ -205,7 +205,7 @@ M = TypeVar('M', bound='Member') @flatten_user -class Member(discord.abc.Messageable, discord.abc.connectable, _UserTag): +class Member(discord.abc.Messageable, discord.abc.Connectable, _UserTag): """Represents a Discord member to a :class:`Guild`. This implements a lot of the functionality of :class:`User`. @@ -269,6 +269,7 @@ class Member(discord.abc.Messageable, discord.abc.connectable, _UserTag): '_user', '_state', '_avatar', + '_index', # Member list index ) if TYPE_CHECKING: diff --git a/discord/message.py b/discord/message.py index debf808e8..6b1dad811 100644 --- a/discord/message.py +++ b/discord/message.py @@ -35,6 +35,7 @@ from . import utils from .reaction import Reaction from .emoji import Emoji from .partial_emoji import PartialEmoji +from .calls import CallMessage from .enums import MessageType, ChannelType, try_enum from .errors import InvalidArgument, HTTPException from .components import _component_factory @@ -525,6 +526,9 @@ class Message(Hashable): channel: Union[:class:`TextChannel`, :class:`Thread`, :class:`DMChannel`, :class:`GroupChannel`, :class:`PartialMessageable`] The :class:`TextChannel` or :class:`Thread` that the message was sent from. Could be a :class:`DMChannel` or :class:`GroupChannel` if it's a private message. + call: Optional[:class:`CallMessage`] + The call that the message refers to. This is only applicable to messages of type + :attr:`MessageType.call`. reference: Optional[:class:`~discord.MessageReference`] The message that this message references. This is only applicable to messages of type :attr:`MessageType.pins_add`, crossposted messages created by a @@ -633,6 +637,7 @@ class Message(Hashable): 'stickers', 'components', 'guild', + 'call', ) if TYPE_CHECKING: @@ -670,6 +675,7 @@ class Message(Hashable): self.nonce: Optional[Union[int, str]] = data.get('nonce') self.stickers: List[StickerItem] = [StickerItem(data=d, state=state) for d in data.get('sticker_items', [])] self.components: List[Component] = [_component_factory(d) for d in data.get('components', [])] + self.call: Optional[CallMessage] = None try: # if the channel doesn't have a guild attribute, we handle that @@ -700,7 +706,7 @@ class Message(Hashable): # the channel will be the correct type here ref.resolved = self.__class__(channel=chan, data=resolved, state=state) # type: ignore - for handler in ('author', 'member', 'mentions', 'mention_roles'): + for handler in ('author', 'member', 'mentions', 'mention_roles', 'call'): try: getattr(self, f'_handle_{handler}')(data[handler]) except KeyError: @@ -871,6 +877,23 @@ class Message(Hashable): if role is not None: self.role_mentions.append(role) + def _handle_call(self, call) -> None: + if call is None or self.type is not MessageType.call: + self.call = None + return + + participants = [] + for uid in map(int, call.get('participants', [])): + if uid == self.author.id: + participants.append(self.author) + else: + user = utils.find(lambda u: u.id == uid, self.mentions) + if user is not None: + participants.append(user) + + call['participants'] = participants + self.call = CallMessage(message=self, **call) + def _handle_components(self, components: List[ComponentPayload]): self.components = [_component_factory(d) for d in components] @@ -1047,6 +1070,16 @@ class Message(Hashable): created_at_ms = int(self.created_at.timestamp() * 1000) return formats[created_at_ms % len(formats)].format(self.author.name) + if self.type is MessageType.call: + call_ended = self.call.ended_timestamp is not None + + if self.channel.me in self.call.participants: + return f'{self.author.name} started a call.' + elif call_ended: + return f'You missed a call from {self.author.name}' + else: + return f'{self.author.name} started a call \N{EM DASH} Join the call.' + if self.type is MessageType.premium_guild_subscription: if not self.content: return f'{self.author.name} just boosted the server!' diff --git a/discord/recorder.py b/discord/recorder.py index 087c16fac..07f1f842a 100644 --- a/discord/recorder.py +++ b/discord/recorder.py @@ -22,6 +22,8 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ +from __future__ import annotations + import struct from typing import TYPE_CHECKING diff --git a/discord/relationship.py b/discord/relationship.py index 65c53ec7b..a1ceedb2d 100644 --- a/discord/relationship.py +++ b/discord/relationship.py @@ -22,6 +22,8 @@ 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 Optional, TYPE_CHECKING from .enums import RelationshipAction, RelationshipType, try_enum diff --git a/discord/stage_instance.py b/discord/stage_instance.py index 479e89f2c..346a601a0 100644 --- a/discord/stage_instance.py +++ b/discord/stage_instance.py @@ -103,11 +103,16 @@ class StageInstance(Hashable): def __repr__(self) -> str: return f'' + @property + def discoverable(self) -> bool: + """Whether the stage instance is discoverable.""" + return not self.discoverable_disabled + @cached_slot_property('_cs_channel') def channel(self) -> Optional[StageChannel]: """Optional[:class:`StageChannel`]: The channel that stage instance is running in.""" - # the returned channel will always be a StageChannel or None - return self._state.get_channel(self.channel_id) # type: ignore + # The returned channel will always be a StageChannel or None + return self._state.get_channel(self.channel_id) # type: ignore def is_public(self) -> bool: return self.privacy_level is StagePrivacyLevel.public @@ -138,7 +143,6 @@ class StageInstance(Hashable): HTTPException Editing a stage instance failed. """ - payload = {} if topic is not MISSING: diff --git a/discord/state.py b/discord/state.py index 8839aa30a..ee7773eab 100644 --- a/discord/state.py +++ b/discord/state.py @@ -257,6 +257,7 @@ class ConnectionState: self._voice_clients: Dict[int, VoiceProtocol] = {} self._voice_states: Dict[int, VoiceState] = {} + self._relationships: Dict[int, Relationship] = {} self._private_channels: Dict[int, PrivateChannel] = {} self._private_channels_by_user: Dict[int, DMChannel] = {} self._last_private_channel: tuple = (None, None) @@ -500,11 +501,13 @@ class ConnectionState: asyncio.ensure_future(self.request_guild(guild_id), loop=self.loop) def _guild_needs_chunking(self, guild: Guild) -> bool: - return self._chunk_guilds and not guild.chunked and any( + if not guild.me: # Dear god this will break everything + return False + return self._chunk_guilds and not guild.chunked and any({ guild.me.guild_permissions.kick_members, guild.me.guild_permissions.manage_roles, guild.me.guild_permissions.ban_members - ) + }) def _guild_needs_subscribing(self, guild): # TODO: rework return not guild.subscribed and self._subscribe_guilds @@ -633,7 +636,7 @@ class ConnectionState: else: if 'user' not in relationship: relationship['user'] = temp_users[int(relationship.pop('user_id'))] - user._relationships[r_id] = Relationship(state=self, data=relationship) + self._relationships[r_id] = Relationship(state=self, data=relationship) # Private channel parsing for pm in data.get('private_channels', []): @@ -1251,7 +1254,7 @@ class ConnectionState: try: await asyncio.wait_for(self.chunk_guild(guild), timeout=60.0) except asyncio.TimeoutError: - log.info('Somehow timed out waiting for chunks.') + _log.info('Somehow timed out waiting for chunks for guild %s.', guild.id) if subscribe: await guild.subscribe(max_online=self._subscription_options.max_online) @@ -1503,7 +1506,7 @@ class ConnectionState: coro = voice.on_voice_state_update(data) asyncio.create_task(logging_coroutine(coro, info='Voice Protocol voice state update handler')) - if guild is not None + if guild is not None: member, before, after = guild._update_voice_state(data, channel_id) # type: ignore if member is not None: if flags.voice: @@ -1566,7 +1569,7 @@ class ConnectionState: key = int(data['id']) old = self.user.get_relationship(key) new = Relationship(state=self, data=data) - self.user._relationships[key] = new + self._relationships[key] = new if old is not None: self.dispatch('relationship_update', old, new) else: @@ -1575,7 +1578,7 @@ class ConnectionState: def parse_relationship_remove(self, data) -> None: key = int(data['id']) try: - old = self.user._relationships.pop(key) + old = self._relationships.pop(key) except KeyError: pass else: diff --git a/discord/threads.py b/discord/threads.py index 892910d9e..dfc89a1cb 100644 --- a/discord/threads.py +++ b/discord/threads.py @@ -681,7 +681,6 @@ class Thread(Messageable, Hashable): List[:class:`ThreadMember`] All thread members in the thread. """ - members = await self._state.http.get_thread_members(self.id) return [ThreadMember(parent=self, data=data) for data in members] @@ -800,3 +799,10 @@ class ThreadMember(Hashable): def thread(self) -> Thread: """:class:`Thread`: The thread this member belongs to.""" return self.parent + + @property + def member(self) -> Optional[Member]: + """Optional[:class:`Member`]: The member this member represents. If the member + is not cached then this will be ``None``. + """ + return self.parent.guild.get_member(self.id) diff --git a/discord/ui/__init__.py b/discord/ui/__init__.py deleted file mode 100644 index 9f5a22811..000000000 --- a/discord/ui/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -""" -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 * -from .select import * diff --git a/discord/ui/button.py b/discord/ui/button.py deleted file mode 100644 index fedeac680..000000000 --- a/discord/ui/button.py +++ /dev/null @@ -1,290 +0,0 @@ -""" -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 os - - -from .item import Item, ItemCallbackType -from ..enums import ButtonStyle, ComponentType -from ..partial_emoji import PartialEmoji, _EmojiTag -from ..components import Button as ButtonComponent - -__all__ = ( - 'Button', - 'button', -) - -if TYPE_CHECKING: - from .view import View - from ..emoji import Emoji - -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. - emoji: Optional[Union[:class:`.PartialEmoji`, :class:`.Emoji`, :class:`str`]] - The emoji of the button, if available. - 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). - """ - - __item_repr_attributes__: Tuple[str, ...] = ( - 'style', - 'url', - 'disabled', - 'label', - 'emoji', - 'row', - ) - - def __init__( - self, - *, - style: ButtonStyle = ButtonStyle.secondary, - label: Optional[str] = None, - disabled: bool = False, - custom_id: Optional[str] = None, - url: Optional[str] = None, - emoji: Optional[Union[str, Emoji, PartialEmoji]] = None, - row: 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') - - self._provided_custom_id = custom_id is not None - if url is None and custom_id is None: - custom_id = os.urandom(16).hex() - - if url is not None: - style = ButtonStyle.link - - 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, Emoji, 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, - ) - self.row = row - - @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) -> Optional[str]: - """Optional[:class:`str`]: The label of the button, if available.""" - return self._underlying.label - - @label.setter - def label(self, value: Optional[str]): - self._underlying.label = str(value) if value is not None else 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, Emoji, PartialEmoji]]): # type: ignore - if value is not None: - if 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, Emoji, or PartialEmoji, received {value.__class__} instead') - else: - self._underlying.emoji = None - - @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, - row=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 self.custom_id is not None - - 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: Optional[str] = None, - custom_id: Optional[str] = None, - disabled: bool = False, - style: ButtonStyle = ButtonStyle.secondary, - emoji: Optional[Union[str, Emoji, PartialEmoji]] = None, - row: Optional[int] = 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:: - - 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: 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:`.Emoji`, :class:`.PartialEmoji`]] - The emoji of the button. This can be in string form or a :class:`.PartialEmoji` - or a full :class:`.Emoji`. - 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, - } - return func - - return decorator diff --git a/discord/ui/item.py b/discord/ui/item.py deleted file mode 100644 index 46c529707..000000000 --- a/discord/ui/item.py +++ /dev/null @@ -1,131 +0,0 @@ -""" -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, Generic, Optional, TYPE_CHECKING, Tuple, Type, TypeVar - -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') -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 current UI items supported are: - - - :class:`discord.ui.Button` - - :class:`discord.ui.Select` - - .. versionadded:: 2.0 - """ - - __item_repr_attributes__: Tuple[str, ...] = ('row',) - - def __init__(self): - self._view: Optional[V] = None - self._row: Optional[int] = None - self._rendered_row: Optional[int] = 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 - - def to_component_dict(self) -> Dict[str, Any]: - raise NotImplementedError - - def refresh_component(self, component: Component) -> None: - return None - - 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_persistent(self) -> bool: - return self._provided_custom_id - - 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) -> Optional[int]: - return self._row - - @row.setter - def row(self, value: Optional[int]): - if value is None: - self._row = None - elif 5 > value >= 0: - self._row = value - else: - raise ValueError('row cannot be negative or greater than or equal to 5') - - @property - def width(self) -> int: - return 1 - - @property - def view(self) -> Optional[V]: - """Optional[:class:`View`]: The underlying view for this item.""" - return self._view - - async def callback(self, interaction: Interaction): - """|coro| - - The callback associated with this UI item. - - This can be overriden by subclasses. - - Parameters - ----------- - interaction: :class:`.Interaction` - The interaction that triggered this UI item. - """ - pass diff --git a/discord/ui/select.py b/discord/ui/select.py deleted file mode 100644 index 8479ca157..000000000 --- a/discord/ui/select.py +++ /dev/null @@ -1,357 +0,0 @@ -""" -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, TypeVar, Type, Callable, Union -import inspect -import os - -from .item import Item, ItemCallbackType -from ..enums import ComponentType -from ..partial_emoji import PartialEmoji -from ..emoji import Emoji -from ..interactions import Interaction -from ..utils import MISSING -from ..components import ( - SelectOption, - SelectMenu, -) - -__all__ = ( - 'Select', - 'select', -) - -if TYPE_CHECKING: - from .view import View - from ..types.components import SelectMenu as SelectMenuPayload - from ..types.interactions import ( - ComponentInteractionData, - ) - -S = TypeVar('S', bound='Select') -V = TypeVar('V', bound='View', covariant=True) - - -class Select(Item[V]): - """Represents a UI select menu. - - This is usually represented as a drop down menu. - - In order to get the selected items that the user has chosen, use :attr:`Select.values`. - - .. versionadded:: 2.0 - - Parameters - ------------ - 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. - 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). - """ - - __item_repr_attributes__: Tuple[str, ...] = ( - 'placeholder', - 'min_values', - 'max_values', - 'options', - 'disabled', - ) - - def __init__( - self, - *, - custom_id: str = MISSING, - placeholder: Optional[str] = None, - min_values: int = 1, - max_values: int = 1, - options: List[SelectOption] = MISSING, - disabled: bool = False, - row: Optional[int] = None, - ) -> None: - super().__init__() - self._selected_values: List[str] = [] - self._provided_custom_id = custom_id is not MISSING - custom_id = os.urandom(16).hex() if custom_id is MISSING else custom_id - options = [] if options is MISSING else options - self._underlying = SelectMenu._raw_construct( - custom_id=custom_id, - type=ComponentType.select, - placeholder=placeholder, - min_values=min_values, - max_values=max_values, - options=options, - disabled=disabled, - ) - self.row = row - - @property - def custom_id(self) -> str: - """:class:`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') - - self._underlying.custom_id = value - - @property - def placeholder(self) -> Optional[str]: - """Optional[:class:`str`]: The placeholder text that is shown if nothing is selected, if any.""" - return self._underlying.placeholder - - @placeholder.setter - def placeholder(self, value: Optional[str]): - if value is not None and not isinstance(value, str): - raise TypeError('placeholder must be None or str') - - self._underlying.placeholder = value - - @property - def min_values(self) -> int: - """:class:`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): - self._underlying.min_values = int(value) - - @property - def max_values(self) -> int: - """:class:`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): - self._underlying.max_values = int(value) - - @property - def options(self) -> List[SelectOption]: - """List[:class:`discord.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 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 = MISSING, - description: Optional[str] = None, - emoji: Optional[Union[str, Emoji, PartialEmoji]] = None, - default: bool = False, - ): - """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:`.Emoji`, :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` or :class:`.Emoji`. - default: :class:`bool` - Whether this option is selected by default. - - Raises - ------- - ValueError - The number of options exceeds 25. - """ - - option = SelectOption( - label=label, - value=value, - description=description, - emoji=emoji, - default=default, - ) - - - self.append_option(option) - - def append_option(self, option: SelectOption): - """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 len(self._underlying.options) > 25: - raise ValueError('maximum number of options already provided') - - self._underlying.options.append(option) - - @property - def disabled(self) -> bool: - """:class:`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 values(self) -> List[str]: - """List[:class:`str`]: A list of values that have been selected by the user.""" - return self._selected_values - - @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', []) - - @classmethod - def from_component(cls: Type[S], component: SelectMenu) -> S: - return cls( - custom_id=component.custom_id, - placeholder=component.placeholder, - min_values=component.min_values, - max_values=component.max_values, - options=component.options, - disabled=component.disabled, - row=None, - ) - - @property - def type(self) -> ComponentType: - return self._underlying.type - - def is_dispatchable(self) -> bool: - return True - - -def select( - *, - placeholder: Optional[str] = None, - custom_id: str = MISSING, - min_values: int = 1, - max_values: int = 1, - options: List[SelectOption] = MISSING, - disabled: bool = False, - row: Optional[int] = None, -) -> Callable[[ItemCallbackType], ItemCallbackType]: - """A decorator that attaches a select menu to a component. - - The function being decorated should have three parameters, ``self`` representing - the :class:`discord.ui.View`, the :class:`discord.ui.Select` being pressed and - the :class:`discord.Interaction` you receive. - - In order to get the selected items that the user has chosen within the callback - use :attr:`Select.values`. - - Parameters - ------------ - 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 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. - disabled: :class:`bool` - Whether the select is disabled or not. Defaults to ``False``. - """ - - def decorator(func: ItemCallbackType) -> ItemCallbackType: - if not inspect.iscoroutinefunction(func): - raise TypeError('select function must be a coroutine function') - - func.__discord_ui_model_type__ = Select - func.__discord_ui_model_kwargs__ = { - 'placeholder': placeholder, - 'custom_id': custom_id, - 'row': row, - 'min_values': min_values, - 'max_values': max_values, - 'options': options, - 'disabled': disabled, - } - return func - - return decorator diff --git a/discord/ui/view.py b/discord/ui/view.py deleted file mode 100644 index 13510eeaf..000000000 --- a/discord/ui/view.py +++ /dev/null @@ -1,529 +0,0 @@ -""" -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, Sequence, TYPE_CHECKING, Tuple -from functools import partial -from itertools import groupby - -import traceback -import asyncio -import sys -import time -import os -from .item import Item, ItemCallbackType -from ..components import ( - Component, - ActionRow as ActionRowComponent, - _component_factory, - Button as ButtonComponent, - SelectMenu as SelectComponent, -) - -__all__ = ( - 'View', -) - - -if TYPE_CHECKING: - from ..interactions import Interaction - from ..message import Message - from ..types.components import Component as ComponentPayload - from ..state import ConnectionState - - -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 _component_to_item(component: Component) -> Item: - 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) - return Item.from_component(component) - - -class _ViewWeights: - __slots__ = ( - 'weights', - ) - - def __init__(self, children: List[Item]): - 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 row, group in groupby(children, key=key): - for item in group: - self.add_item(item) - - def find_open_space(self, item: Item) -> 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: Item) -> 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: Item) -> None: - if item._rendered_row is not None: - self.weights[item._rendered_row] -= item.width - item._rendered_row = None - - def clear(self) -> None: - self.weights = [0, 0, 0, 0, 0] - - -class View: - """Represents a UI view. - - This object must be inherited to create a UI within Discord. - - .. versionadded:: 2.0 - - Parameters - ----------- - 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. - - 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. - """ - - __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) > 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] = [] - for func in self.__view_children_items__: - item: Item = func.__discord_ui_model_type__(**func.__discord_ui_model_kwargs__) - item.callback = partial(func, self, item) - item._view = self - setattr(self, func.__name__, item) - self.children.append(item) - - self.__weights = _ViewWeights(self.children) - loop = asyncio.get_running_loop() - self.id: str = os.urandom(16).hex() - self.__cancel_callback: Optional[Callable[[View], None]] = None - self.__timeout_expiry: Optional[float] = None - self.__timeout_task: Optional[asyncio.Task[None]] = None - self.__stopped: asyncio.Future[bool] = loop.create_future() - - 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) -> 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 - - @classmethod - def from_message(cls, message: Message, /, *, timeout: Optional[float] = 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:`discord.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 - - @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 - An :class:`Item` was not passed. - ValueError - Maximum number of children has been exceeded (25) - or the row the item is trying to be added to is full. - """ - - 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.__weights.add_item(item) - - item._view = self - self.children.append(item) - - def remove_item(self, item: Item) -> None: - """Removes an item from the view. - - Parameters - ----------- - item: :class:`Item` - The item to remove from the view. - """ - - try: - self.children.remove(item) - except ValueError: - pass - else: - self.__weights.remove_item(item) - - def clear_items(self) -> None: - """Removes all items from the view.""" - self.children.clear() - self.__weights.clear() - - 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``. - - .. 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. - """ - pass - - async def on_error(self, error: Exception, item: Item, 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. - """ - print(f'Ignoring exception in view {self} for item {item}:', file=sys.stderr) - traceback.print_exception(error.__class__, error, error.__traceback__, file=sys.stderr) - - async def _scheduled_task(self, item: Item, interaction: Interaction): - try: - if self.timeout: - self.__timeout_expiry = time.monotonic() + self.timeout - - allow = await self.interaction_check(interaction) - if not allow: - return - - await item.callback(interaction) - if not interaction.response._responded: - await interaction.response.defer() - except Exception as e: - return await self.on_error(e, item, interaction) - - def _start_listening_from_store(self, store: ViewStore) -> None: - self.__cancel_callback = partial(store.remove_view) - if self.timeout: - loop = asyncio.get_running_loop() - if self.__timeout_task is not None: - self.__timeout_task.cancel() - - self.__timeout_expiry = time.monotonic() + self.timeout - self.__timeout_task = loop.create_task(self.__timeout_task_impl()) - - def _dispatch_timeout(self): - if self.__stopped.done(): - return - - self.__stopped.set_result(True) - asyncio.create_task(self.on_timeout(), name=f'discord-ui-view-timeout-{self.id}') - - def _dispatch_item(self, item: Item, interaction: Interaction): - if self.__stopped.done(): - return - - asyncio.create_task(self._scheduled_task(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_component(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 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: - """:class:`bool`: Whether the view has finished interacting.""" - return self.__stopped.done() - - def is_dispatching(self) -> bool: - """:class:`bool`: Whether the view has been added for dispatching purposes.""" - return self.__cancel_callback is not None - - def is_persistent(self) -> bool: - """:class:`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) - - 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 - - -class ViewStore: - def __init__(self, state: ConnectionState): - # (component_type, message_id, custom_id): (View, Item) - self._views: Dict[Tuple[int, Optional[int], str], Tuple[View, Item]] = {} - # message_id: View - self._synced_message_views: Dict[int, View] = {} - self._state: ConnectionState = state - - @property - def persistent_views(self) -> Sequence[View]: - # fmt: off - views = { - view.id: view - for (_, (view, _)) in self._views.items() - if view.is_persistent() - } - # fmt: on - return list(views.values()) - - def __verify_integrity(self): - to_remove: List[Tuple[int, Optional[int], str]] = [] - for (k, (view, _)) in self._views.items(): - if view.is_finished(): - to_remove.append(k) - - for k in to_remove: - del self._views[k] - - def add_view(self, view: View, message_id: Optional[int] = None): - self.__verify_integrity() - - view._start_listening_from_store(self) - for item in view.children: - if item.is_dispatchable(): - self._views[(item.type.value, message_id, item.custom_id)] = (view, item) # type: ignore - - if message_id is not None: - self._synced_message_views[message_id] = view - - def remove_view(self, view: View): - for item in view.children: - if item.is_dispatchable(): - self._views.pop((item.type.value, item.custom_id), None) # type: ignore - - for key, value in self._synced_message_views.items(): - if value.id == view.id: - del self._synced_message_views[key] - break - - def dispatch(self, component_type: int, custom_id: str, interaction: Interaction): - self.__verify_integrity() - message_id: Optional[int] = interaction.message and interaction.message.id - key = (component_type, message_id, custom_id) - # Fallback to None message_id searches in case a persistent view - # was added without an associated message_id - value = self._views.get(key) or self._views.get((component_type, None, custom_id)) - if value is None: - return - - view, item = value - item.refresh_state(interaction) - view._dispatch_item(item, interaction) - - def is_message_tracked(self, message_id: int): - return message_id in self._synced_message_views - - def remove_message_tracking(self, message_id: int) -> Optional[View]: - return self._synced_message_views.pop(message_id, None) - - def update_from_message(self, message_id: int, components: List[ComponentPayload]): - # pre-req: is_message_tracked == true - view = self._synced_message_views[message_id] - view.refresh([_component_factory(d) for d in components]) diff --git a/discord/user.py b/discord/user.py index 0c9db2f9e..f8853bfc8 100644 --- a/discord/user.py +++ b/discord/user.py @@ -28,7 +28,6 @@ from copy import copy from typing import Any, Dict, List, Optional, Type, TypeVar, TYPE_CHECKING, Union import discord.abc -from types.snowflake import Snowflake from .asset import Asset from .colour import Colour from .enums import DefaultAvatar, HypeSquadHouse, PremiumType, RelationshipAction, RelationshipType, try_enum, UserFlags @@ -49,6 +48,7 @@ if TYPE_CHECKING: from .message import Message from .state import ConnectionState from .types.channel import DMChannel as DMChannelPayload + from .types.snowflake import Snowflake from .types.user import User as UserPayload @@ -597,7 +597,18 @@ class ClientUser(BaseUser): The user's note. Not pre-fetched. """ - __slots__ = ('locale', '_flags', 'verified', 'mfa_enabled', '__weakref__') + __slots__ = ( + 'locale', + '_flags', + 'verified', + 'mfa_enabled', + 'email', + 'phone', + 'premium_type', + 'note', + 'premium', + 'bio', + ) if TYPE_CHECKING: verified: bool