From 3429980d0c32d610f41ffc67bf1bf921086e829f Mon Sep 17 00:00:00 2001 From: dolfies Date: Fri, 5 Nov 2021 15:10:08 -0400 Subject: [PATCH] Migrate --- discord/__init__.py | 4 +- discord/abc.py | 48 ++- discord/activity.py | 33 +- discord/appinfo.py | 3 +- discord/channel.py | 239 +++++++++---- discord/client.py | 255 +++++++++++-- discord/components.py | 12 - discord/errors.py | 10 +- discord/flags.py | 53 +++ discord/gateway.py | 194 +++++----- discord/guild.py | 23 +- discord/interactions.py | 767 ---------------------------------------- 12 files changed, 628 insertions(+), 1013 deletions(-) delete mode 100644 discord/interactions.py diff --git a/discord/__init__.py b/discord/__init__.py index 9a6f6e97f..e3dacd63a 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -13,7 +13,7 @@ __title__ = 'discord.py-self' __author__ = 'Dolfies' __license__ = 'MIT' __copyright__ = 'Copyright 2015-present Rapptz' -__version__ = '2.0.0a' +__version__ = '2.0.0a1' __path__ = __import__('pkgutil').extend_path(__path__, __name__) @@ -67,6 +67,6 @@ class _VersionInfo(NamedTuple): releaselevel: Literal['alpha', 'beta', 'candidate', 'final'] serial: int -version_info: _VersionInfo = _VersionInfo(major=2, minor=0, micro=0, releaselevel='alpha', serial=0) +version_info: _VersionInfo = _VersionInfo(major=2, minor=0, micro=0, releaselevel='alpha', serial=1) logging.getLogger(__name__).addHandler(logging.NullHandler()) diff --git a/discord/abc.py b/discord/abc.py index a2ca1e489..806406685 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -26,6 +26,7 @@ from __future__ import annotations import copy import asyncio +from datetime import datetime from typing import ( Any, Callable, @@ -70,7 +71,7 @@ if TYPE_CHECKING: from datetime import datetime from .client import Client - from .user import ClientUser + from .user import ClientUser, User from .asset import Asset from .state import ConnectionState from .guild import Guild @@ -78,7 +79,7 @@ if TYPE_CHECKING: from .channel import CategoryChannel from .embeds import Embed from .message import Message, MessageReference, PartialMessage - from .channel import TextChannel, DMChannel, GroupChannel, PartialMessageable + from .channel import TextChannel, DMChannel, GroupChannel, PartialMessageable, VocalGuildChannel from .threads import Thread from .enums import InviteTarget from .ui.view import View @@ -92,6 +93,7 @@ if TYPE_CHECKING: PartialMessageableChannel = Union[TextChannel, Thread, DMChannel, PartialMessageable] MessageableChannel = Union[PartialMessageableChannel, GroupChannel] SnowflakeTime = Union["Snowflake", datetime] + ConnectableChannel = Union[VocalGuildChannel, PrivateChannel, User] MISSING = utils.MISSING @@ -146,6 +148,8 @@ class User(Snowflake, Protocol): The avatar asset the user has. bot: :class:`bool` If the user is a bot account. + system: :class:`bool` + If the user is a system user (i.e. represents Discord officially). """ __slots__ = () @@ -1025,7 +1029,7 @@ class GuildChannel: await self._state.http.bulk_channel_update(self.guild.id, payload, reason=reason) - async def create_invite( + async def create_invite( # TODO: add validate self, *, reason: Optional[str] = None, @@ -1232,11 +1236,10 @@ class Messageable: files=None, stickers=None, delete_after=None, - nonce=None, + nonce=MISSING, allowed_mentions=None, reference=None, mention_author=None, - view=None, ): """|coro| @@ -1270,7 +1273,7 @@ class Messageable: A list of files to upload. Must be a maximum of 10. nonce: :class:`int` The nonce to use for sending this message. If the message was successfully sent, - then the message will have a nonce with this value. + then the message will have a nonce with this value. Generates one by default. delete_after: :class:`float` If provided, the number of seconds to wait in the background before deleting the message we just sent. If the deletion fails, @@ -1297,8 +1300,6 @@ class Messageable: 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. embeds: List[:class:`~discord.Embed`] A list of embeds to upload. Must be a maximum of 10. @@ -1332,7 +1333,7 @@ class Messageable: content = str(content) if content is not None else None if embed is not None and embeds is not None: - raise InvalidArgument('cannot pass both embed and embeds parameter to send()') + raise InvalidArgument('Cannot pass both embed and embeds parameter to send()') if embed is not None: embed = embed.to_dict() @@ -1363,16 +1364,11 @@ class Messageable: except AttributeError: raise InvalidArgument('reference parameter must be Message, MessageReference, or PartialMessage') from None - if view: - if not hasattr(view, '__discord_ui_view__'): - raise InvalidArgument(f'view parameter must be View not {view.__class__!r}') - - components = view.to_components() - else: - components = None + if nonce is MISSING: + nonce = utils.time_snowflake(datetime.utcnow()) if file is not None and files is not None: - raise InvalidArgument('cannot pass both file and files parameter to send()') + raise InvalidArgument('Cannot pass both file and files parameter to send()') if file is not None: if not isinstance(file, File): @@ -1390,7 +1386,6 @@ class Messageable: nonce=nonce, message_reference=reference, stickers=stickers, - components=components, ) finally: file.close() @@ -1615,6 +1610,9 @@ class Connectable(Protocol): __slots__ = () _state: ConnectionState + async def _get_channel(self) -> ConnectableChannel: + return self + def _get_voice_client_key(self) -> Tuple[int, str]: raise NotImplementedError @@ -1627,6 +1625,7 @@ class Connectable(Protocol): timeout: float = 60.0, reconnect: bool = True, cls: Callable[[Client, Connectable], T] = VoiceClient, + _channel: Optional[Connectable] = None ) -> T: """|coro| @@ -1662,15 +1661,15 @@ class Connectable(Protocol): key_id, _ = self._get_voice_client_key() state = self._state + channel = await self._get_channel() if state._get_voice_client(key_id): - raise ClientException('Already connected to a voice channel.') + raise ClientException('Already connected to a voice channel') - client = state._get_client() - voice = cls(client, self) + voice = cls(state.client, channel) if not isinstance(voice, VoiceProtocol): - raise TypeError('Type must meet VoiceProtocol abstract base class.') + raise TypeError('Type must meet VoiceProtocol abstract base class') state._add_voice_client(key_id, voice) @@ -1680,8 +1679,7 @@ class Connectable(Protocol): try: await voice.disconnect(force=True) except Exception: - # we don't care if disconnect failed because connection failed - pass - raise # re-raise + pass # We don't care if disconnect failed because connection failed + raise # Re-raise return voice diff --git a/discord/activity.py b/discord/activity.py index 512053777..2f8ebf294 100644 --- a/discord/activity.py +++ b/discord/activity.py @@ -738,14 +738,14 @@ class CustomActivity(BaseActivity): The emoji to pass to the activity, if any. """ - __slots__ = ('name', 'emoji', 'state') + __slots__ = ('name', 'emoji') def __init__(self, name: Optional[str], *, emoji: Optional[PartialEmoji] = None, **extra: Any): super().__init__(**extra) self.name: Optional[str] = name - self.state: Optional[str] = extra.pop('state', None) - if self.name == 'Custom Status': - self.name = self.state + state = extra.pop('state', None) + if self.name == 'Custom Activity': + self.name = state self.emoji: Optional[PartialEmoji] if emoji is None: @@ -768,18 +768,11 @@ class CustomActivity(BaseActivity): return ActivityType.custom def to_dict(self) -> Dict[str, Any]: - if self.name == self.state: - o = { - 'type': ActivityType.custom.value, - 'state': self.name, - 'name': 'Custom Status', - } - else: - o = { - 'type': ActivityType.custom.value, - 'name': self.name, - } - + o = { + 'type': ActivityType.custom.value, + 'state': self.name, + 'name': 'Custom Status', + } if self.emoji: o['emoji'] = self.emoji.to_dict() return o @@ -830,12 +823,12 @@ def create_activity(data: Optional[ActivityPayload]) -> Optional[ActivityTypes]: except KeyError: return Activity(**data) else: - # we removed the name key from data already - return CustomActivity(name=name, **data) # type: ignore + # We removed the name key from data already + return CustomActivity(name=name, **data) # type: ignore elif game_type is ActivityType.streaming: if 'url' in data: - # the url won't be None here - return Streaming(**data) # type: ignore + # The url won't be None here + return Streaming(**data) # type: ignore return Activity(**data) elif game_type is ActivityType.listening and 'sync_id' in data and 'session_id' in data: return Spotify(**data) diff --git a/discord/appinfo.py b/discord/appinfo.py index de1f7a73f..7eda5ee51 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -46,8 +46,7 @@ __all__ = ( class AppInfo: - """Represents the application info for the bot provided by Discord. - + """Represents application info for an application/bot. Attributes ------------- diff --git a/discord/channel.py b/discord/channel.py index be3315cf1..1effb9623 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -24,7 +24,6 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations -import time import asyncio from typing import ( Any, @@ -44,6 +43,7 @@ from typing import ( import datetime import discord.abc +from .calls import PrivateCall, GroupCall from .permissions import PermissionOverwrite, Permissions from .enums import ChannelType, StagePrivacyLevel, try_enum, VoiceRegion, VideoQualityMode from .mixins import Hashable @@ -71,7 +71,7 @@ if TYPE_CHECKING: from .types.threads import ThreadArchiveDuration from .role import Role from .member import Member, VoiceState - from .abc import Snowflake, SnowflakeTime + from .abc import Snowflake, SnowflakeTime, T as ConnectReturn from .message import Message, PartialMessage from .webhook import Webhook from .state import ConnectionState @@ -89,9 +89,13 @@ if TYPE_CHECKING: from .types.snowflake import SnowflakeList -async def _single_delete_strategy(messages: Iterable[Message]): - for m in messages: - await m.delete() +async def _delete_messages(state, channel_id, messages): + delete_message = state.http.delete_message + for msg in messages: + try: + await delete_message(channel_id, msg.id) + except NotFound: + pass class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable): @@ -366,15 +370,13 @@ class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable): Deletes a list of messages. This is similar to :meth:`Message.delete` except it bulk deletes multiple messages. - As a special case, if the number of messages is 0, then nothing - is done. If the number of messages is 1 then single message - delete is done. If it's more than two, then bulk delete is used. - - You cannot bulk delete more than 100 messages or messages that - are older than 14 days old. - You must have the :attr:`~Permissions.manage_messages` permission to - use this. + use this (unless they're your own). + + .. note:: + Users do not have access to the message bulk-delete endpoint. + Since messages are just iterated over and deleted one-by-one, + it's easy to get ratelimited using this method. Parameters ----------- @@ -383,12 +385,8 @@ class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable): Raises ------ - ClientException - The number of messages to delete was more than 100. Forbidden You do not have proper permissions to delete the messages. - NotFound - If single delete, then the message was already deleted. HTTPException Deleting the messages failed. """ @@ -398,16 +396,7 @@ class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable): if len(messages) == 0: return # do nothing - if len(messages) == 1: - message_id: int = messages[0].id - await self._state.http.delete_message(self.id, message_id) - return - - if len(messages) > 100: - raise ClientException('Can only bulk delete messages up to 100 messages') - - message_ids: SnowflakeList = [m.id for m in messages] - await self._state.http.delete_messages(self.id, message_ids) + await _delete_messages(self._state, self.id, messages) async def purge( self, @@ -418,7 +407,6 @@ class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable): after: Optional[SnowflakeTime] = None, around: Optional[SnowflakeTime] = None, oldest_first: Optional[bool] = False, - bulk: bool = True, ) -> List[Message]: """|coro| @@ -426,10 +414,8 @@ class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable): ``check``. If a ``check`` is not provided then all messages are deleted without discrimination. - You must have the :attr:`~Permissions.manage_messages` permission to - delete messages even if they are your own. - The :attr:`~Permissions.read_message_history` permission is - also needed to retrieve message history. + The :attr:`~Permissions.read_message_history` permission is needed to + retrieve message history. Examples --------- @@ -458,10 +444,6 @@ class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable): Same as ``around`` in :meth:`history`. oldest_first: Optional[:class:`bool`] Same as ``oldest_first`` in :meth:`history`. - bulk: :class:`bool` - If ``True``, use bulk delete. Setting this to ``False`` is useful for mass-deleting - a bot's own messages without :attr:`Permissions.manage_messages`. When ``True``, will - fall back to single delete if messages are older than two weeks. Raises ------- @@ -479,45 +461,27 @@ class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable): if check is MISSING: check = lambda m: True + state = self._state + channel_id = self.id iterator = self.history(limit=limit, before=before, after=after, oldest_first=oldest_first, around=around) ret: List[Message] = [] count = 0 - minimum_time = int((time.time() - 14 * 24 * 60 * 60) * 1000.0 - 1420070400000) << 22 - strategy = self.delete_messages if bulk else _single_delete_strategy - async for message in iterator: - if count == 100: - to_delete = ret[-100:] - await strategy(to_delete) + if count == 50: + to_delete = ret[-50:] + await _delete_messages(state, channel_id, to_delete) count = 0 - await asyncio.sleep(1) if not check(message): continue - if message.id < minimum_time: - # older than 14 days old - if count == 1: - await ret[-1].delete() - elif count >= 2: - to_delete = ret[-count:] - await strategy(to_delete) - - count = 0 - strategy = _single_delete_strategy - count += 1 ret.append(message) - # SOme messages remaining to poll - if count >= 2: - # more than 2 messages -> bulk delete - to_delete = ret[-count:] - await strategy(to_delete) - elif count == 1: - # delete a single message - await ret[-1].delete() + # Some messages remaining to poll + to_delete = ret[-count:] + await _delete_messages(state, channel_id, to_delete) return ret @@ -1707,7 +1671,7 @@ class StoreChannel(discord.abc.GuildChannel, Hashable): DMC = TypeVar('DMC', bound='DMChannel') -class DMChannel(discord.abc.Messageable, Hashable): +class DMChannel(discord.abc.Messageable, discord.abc.Connectable, Hashable): """Represents a Discord direct message channel. .. container:: operations @@ -1748,9 +1712,22 @@ class DMChannel(discord.abc.Messageable, Hashable): self.me: ClientUser = me self.id: int = int(data['id']) + def _get_voice_client_key(self) -> Tuple[int, str]: + return self.me.id, 'self_id' + + def _get_voice_state_pair(self) -> Tuple[int, int]: + return self.me.id, self.id + + def _add_call(self, **kwargs) -> PrivateCall: + return PrivateCall(**kwargs) + async def _get_channel(self): + await self._state.access_private_channel(self.id) return self + def _initial_ring(self) -> None: + return self._state.http.ring(self.id) + def __str__(self) -> str: if self.recipient: return f'Direct Message with {self.recipient}' @@ -1769,6 +1746,11 @@ class DMChannel(discord.abc.Messageable, Hashable): self.me = state.user # type: ignore return self + @property + def call(self) -> Optional[PrivateCall]: + """Optional[:class:`PrivateCall`]: The channel's currently active call.""" + return self._state._calls.get(self.id) + @property def type(self) -> ChannelType: """:class:`ChannelType`: The channel's Discord type.""" @@ -1832,8 +1814,15 @@ class DMChannel(discord.abc.Messageable, Hashable): return PartialMessage(channel=self, id=message_id) + async def connect(self, *, ring=True, **kwargs): + await self._get_channel() + call = self.call + if call is None and ring: + await self._initial_ring() + await super().connect(**kwargs) -class GroupChannel(discord.abc.Messageable, Hashable): + +class GroupChannel(discord.abc.Messageable, discord.abc.Connectable, Hashable): """Represents a Discord group channel. .. container:: operations @@ -1892,9 +1881,22 @@ class GroupChannel(discord.abc.Messageable, Hashable): else: self.owner = utils.find(lambda u: u.id == self.owner_id, self.recipients) + def _get_voice_client_key(self) -> Tuple[int, str]: + return self.me.id, 'self_id' + + def _get_voice_state_pair(self) Tuple[int, int]: + return self.me.id, self.id + async def _get_channel(self): + await self._state.access_private_channel(self.id) return self + def _initial_ring(self) -> None: + return self._state.http.ring(self.id) + + def _add_call(self, **kwargs) -> GroupCall: + return GroupCall(**kwargs) + def __str__(self) -> str: if self.name: return self.name @@ -1907,6 +1909,11 @@ class GroupChannel(discord.abc.Messageable, Hashable): def __repr__(self) -> str: return f'' + @property + def call(self) -> Optional[PrivateCall]: + """Optional[:class:`PrivateCall`]: The channel's currently active call.""" + return self._state._calls.get(self.id) + @property def type(self) -> ChannelType: """:class:`ChannelType`: The channel's Discord type.""" @@ -1960,6 +1967,110 @@ class GroupChannel(discord.abc.Messageable, Hashable): return base + async def connect(self, *, ring=True, **kwargs) -> ConnectReturn: + await self._get_channel() + call = self.call + if call is None and ring: + await self._initial_ring() + await super().connect(**kwargs) + + async def add_recipients(self, *recipients) -> None: + r"""|coro| + + Adds recipients to this group. + + A group can only have a maximum of 10 members. + Attempting to add more ends up in an exception. To + add a recipient to the group, you must have a relationship + with the user of type :attr:`RelationshipType.friend`. + + Parameters + ----------- + \*recipients: :class:`User` + An argument list of users to add to this group. + + Raises + ------- + HTTPException + Adding a recipient to this group failed. + """ + + # TODO: wait for the corresponding WS event + await self._get_channel() + req = self._state.http.add_group_recipient + for recipient in recipients: + await req(self.id, recipient.id) + + async def remove_recipients(self, *recipients) -> None: + r"""|coro| + + Removes recipients from this group. + + Parameters + ----------- + \*recipients: :class:`User` + An argument list of users to remove from this group. + + Raises + ------- + HTTPException + Removing a recipient from this group failed. + """ + + # TODO: wait for the corresponding WS event + await self._get_channel() + req = self._state.http.remove_group_recipient + for recipient in recipients: + await req(self.id, recipient.id) + + @overload + async def edit( + self, *, name: Optional[str] = ..., icon: Optional[bytes] = ..., + ) -> Optional[GroupChannel]: + ... + + @overload + async def edit(self) -> Optional[GroupChannel]: + ... + + async def edit(self, **fields): + """|coro| + + Edits the group. + + .. versionchanged:: 2.0 + Edits are no longer in-place, the newly edited channel is returned instead. + + Parameters + ----------- + name: Optional[:class:`str`] + The new name to change the group to. + Could be ``None`` to remove the name. + icon: Optional[:class:`bytes`] + A :term:`py:bytes-like object` representing the new icon. + Could be ``None`` to remove the icon. + + Raises + ------- + HTTPException + Editing the group failed. + """ + + await self._get_channel() + + try: + icon_bytes = fields['icon'] + except KeyError: + pass + else: + if icon_bytes is not None: + fields['icon'] = utils._bytes_to_base64_data(icon_bytes) + + data = await self._state.http.edit_group(self.id, **fields) + if data is not None: + # the payload will always be the proper channel payload + return self.__class__(me=self.me, state=self._state, data=payload) # type: ignore + async def leave(self) -> None: """|coro| diff --git a/discord/client.py b/discord/client.py index f2389d640..3ba1395c4 100644 --- a/discord/client.py +++ b/discord/client.py @@ -33,17 +33,16 @@ from typing import Any, Callable, Coroutine, Dict, Generator, List, Optional, Se import aiohttp -from .user import User, ClientUser +from .user import User, ClientUser, Profile, Note from .invite import Invite from .template import Template from .widget import Widget from .guild import Guild from .emoji import Emoji from .channel import _threaded_channel_factory, PartialMessageable -from .enums import ChannelType +from .enums import ChannelType, Status, VoiceRegion, try_enum from .mentions import AllowedMentions from .errors import * -from .enums import Status, VoiceRegion from .gateway import * from .activity import ActivityTypes, BaseActivity, create_activity from .voice_client import VoiceClient @@ -143,6 +142,16 @@ class Client: amounts of guilds. The default is ``True``. .. versionadded:: 1.5 + guild_subscription_options: :class:`GuildSubscriptionOptions` + Allows for control over the library's auto-subscribing. + If not given, defaults to off. + + .. versionadded:: 1.9 + request_guilds :class:`bool` + Whether to request guilds at startup (behaves similarly to the old + guild_subscriptions option). Defaults to True. + + .. versionadded:: 1.10 status: Optional[:class:`.Status`] A status to start your presence with upon logging on to Discord. activity: Optional[:class:`.BaseActivity`] @@ -198,7 +207,8 @@ class Client: self.http: HTTPClient = HTTPClient(connector, proxy=proxy, proxy_auth=proxy_auth, unsync_clock=unsync_clock, loop=self.loop) self._handlers: Dict[str, Callable] = { - 'ready': self._handle_ready + 'ready': self._handle_ready, + 'connect': self._handle_connect } self._hooks: Dict[str, Callable] = { @@ -209,8 +219,6 @@ class Client: self._connection: ConnectionState = self._get_state(**options) self._closed: bool = False self._ready: asyncio.Event = asyncio.Event() - self._connection._get_websocket = self._get_websocket - self._connection._get_client = lambda: self if VoiceClient.warn_nacl: VoiceClient.warn_nacl = False @@ -218,16 +226,21 @@ class Client: # Internals - def _get_websocket(self, guild_id: Optional[int] = None) -> DiscordWebSocket: - return self.ws - def _get_state(self, **options: Any) -> ConnectionState: return ConnectionState(dispatch=self.dispatch, handlers=self._handlers, - hooks=self._hooks, http=self.http, loop=self.loop, **options) + hooks=self._hooks, http=self.http, loop=self.loop, + client=self, **options) def _handle_ready(self) -> None: self._ready.set() + def _handle_connect(self) -> None: + state = self._connection + activity = create_activity(state._activity) + status = 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)) + @property def latency(self) -> float: """:class:`float`: Measures latency between a HEARTBEAT and a HEARTBEAT_ACK in seconds. @@ -424,6 +437,7 @@ class Client: _log.info('Logging in using static token.') data = await self.http.static_login(token.strip()) + self._state.analytics_token = data.get('') self._connection.user = ClientUser(state=self._connection, data=data) async def connect(self, *, reconnect: bool = True) -> None: @@ -546,11 +560,6 @@ class Client: """|coro| A shorthand coroutine for :meth:`login` + :meth:`connect`. - - Raises - ------- - TypeError - An unexpected keyword argument was received. """ await self.login(token) await self.connect(reconnect=reconnect) @@ -621,6 +630,11 @@ class Client: """:class:`bool`: Indicates if the websocket connection is closed.""" return self._closed + @property + def voice_client(self) -> Optional[VoiceProtocol]: + """Optional[:class:`VoiceProtocol`]: Returns the :class:`VoiceProtocol` associated with private calls, if any.""" + return self._connection._get_voice_client(self.user.id) + @property def activity(self) -> Optional[ActivityTypes]: """Optional[:class:`.BaseActivity`]: The activity being used upon @@ -991,6 +1005,7 @@ class Client: *, activity: Optional[BaseActivity] = None, status: Optional[Status] = None, + afk: bool = False ): """|coro| @@ -1004,9 +1019,6 @@ class Client: game = discord.Game("with the API") await client.change_presence(status=discord.Status.idle, activity=game) - .. versionchanged:: 2.0 - Removed the ``afk`` keyword-only parameter. - Parameters ---------- activity: Optional[:class:`.BaseActivity`] @@ -1014,6 +1026,10 @@ class Client: status: Optional[:class:`.Status`] Indicates what status to change to. If ``None``, then :attr:`.Status.online` is used. + afk: Optional[:class:`bool`] + Indicates if you are going AFK. This allows the Discord + client to know how to handle push notifications better + for you in case you are actually idle and not lying. Raises ------ @@ -1030,7 +1046,14 @@ class Client: else: status_str = str(status) - await self.ws.change_presence(activity=activity, status=status_str) + await self.ws.change_presence(activity=activity, status=status_str, afk=afk) + + # TODO: do the same for custom status and check which comes first + if status: + try: + await self._connection.user.edit_settings(status=status_enum) + except Exception: # Not essential to actually changing status... + pass for guild in self._connection.guilds: me = guild.me @@ -1044,12 +1067,51 @@ class Client: me.status = status + async def change_voice_state( + self, + *, + channel: Optional[PrivateChannel], + self_mute: bool = False, + self_deaf: bool = False, + self_video: bool = False, + preferred_region: Optional[VoiceRegion] = MISSING + ) -> None: + """|coro| + + Changes client's voice state in the guild. + + .. versionadded:: 1.4 + + Parameters + ----------- + channel: Optional[:class:`VoiceChannel`] + Channel the client wants to join. Use ``None`` to disconnect. + self_mute: :class:`bool` + Indicates if the client should be self-muted. + self_deaf: :class:`bool` + Indicates if the client should be self-deafened. + self_video: :class:`bool` + Indicates if the client is using video. Untested & unconfirmed + (do not use). + preferred_region: Optional[:class:`VoiceRegion`] + The preferred region to connect to. + """ + ws = self._state._get_websocket(self.id) + channel_id = channel.id if channel else None + + if preferred_region is None or channel_id is None: + region = None + else: + region = str(preferred_region) if preferred_region else str(state.preferred_region) + + await ws.voice_state(None, channel_id, self_mute, self_deaf, self_video, region) + # Guild stuff def fetch_guilds( self, *, - limit: Optional[int] = 100, + limit: Optional[int] = None, before: SnowflakeTime = None, after: SnowflakeTime = None ) -> GuildIterator: @@ -1069,12 +1131,12 @@ class Client: Usage :: - async for guild in client.fetch_guilds(limit=150): + async for guild in client.fetch_guilds(): print(guild.name) Flattening into a list :: - guilds = await client.fetch_guilds(limit=150).flatten() + guilds = await client.fetch_guilds().flatten() # guilds is now a list of Guild... All parameters are optional. @@ -1083,9 +1145,8 @@ class Client: ----------- limit: Optional[:class:`int`] The number of guilds to retrieve. - If ``None``, it retrieves every guild you have access to. Note, however, - that this would make it a slow operation. - Defaults to ``100``. + If ``None``, it retrieves every guild you have access to. + Defaults to ``None``. before: Union[:class:`.abc.Snowflake`, :class:`datetime.datetime`] Retrieves guilds before this date or object. If a datetime is provided, it is recommended to use a UTC aware datetime. @@ -1131,7 +1192,7 @@ class Client: """ code = utils.resolve_template(code) data = await self.http.get_template(code) - return Template(data=data, state=self._connection) # type: ignore + return Template(data=data, state=self._connection) # type: ignore async def fetch_guild(self, guild_id: int, /) -> Guild: """|coro| @@ -1182,9 +1243,6 @@ class Client: ---------- name: :class:`str` The name of the guild. - region: :class:`.VoiceRegion` - The region for the voice communication server. - Defaults to :attr:`.VoiceRegion.us_west`. icon: Optional[:class:`bytes`] The :term:`py:bytes-like object` representing the icon. See :meth:`.ClientUser.edit` for more details on what is expected. @@ -1211,12 +1269,10 @@ class Client: else: icon_base64 = None - region_value = str(region) - if code: - data = await self.http.create_from_template(code, name, region_value, icon_base64) + data = await self.http.create_from_template(code, name, icon_base64) else: - data = await self.http.create_guild(name, region_value, icon_base64) + data = await self.http.create_guild(name, icon_base64) return Guild(data=data, state=self._connection) async def fetch_stage_instance(self, channel_id: int, /) -> StageInstance: @@ -1317,6 +1373,38 @@ class Client: invite_id = utils.resolve_invite(invite) await self.http.delete_invite(invite_id) + async def accept_invite(self, invite: Union[Invite, str]) -> Guild: + """|coro| + + Accepts an invite and joins a guild. + + .. versionadded:: 1.9 + + Parameters + ---------- + invite: Union[:class:`.Invite`, :class:`str`] + The Discord invite ID, URL (must be a discord.gg URL), or :class:`.Invite`. + + Raises + ------ + :exc:`.HTTPException` + Joining the guild failed. + + Returns + ------- + :class:`.Guild` + The guild joined. This is not the same guild that is + added to cache. + """ + + if not isinstance(invite, Invite): + invite = await self.fetch_invite(invite, with_counts=False, with_expiration=False) + + data = await self.http.join_guild(invite.code, guild_id=invite.guild.id, channel_id=invite.channel.id, channel_type=invite.channel.type.value) + return Guild(data=data['guild'], state=self._connection) + + use_invite = accept_invite + # Miscellaneous stuff async def fetch_widget(self, guild_id: int, /) -> Widget: @@ -1346,15 +1434,14 @@ class Client: The guild's widget. """ data = await self.http.get_widget(guild_id) - return Widget(state=self._connection, data=data) async def fetch_user(self, user_id: int, /) -> User: """|coro| Retrieves a :class:`~discord.User` based on their ID. - You do not have to share any guilds with the user to get this information, - however many operations do require that you do. + You do not have to share any guilds with the user to get + this information, however many operations do require that you do. .. note:: @@ -1380,6 +1467,57 @@ class Client: data = await self.http.get_user(user_id) return User(state=self._connection, data=data) + + async def fetch_user_profile( + self, user_id: int, *, with_mutuals: bool = True, fetch_note: bool = True + ) -> Profile: + """|coro| + + Gets an arbitrary user's profile. + + You must share a guild or be friends with this user to + get this information. + + Parameters + ------------ + user_id: :class:`int` + The ID of the user to fetch their profile for. + with_mutuals: :class:`bool` + Whether to fetch mutual guilds and friends. + This fills in :attr:`mutual_guilds` & :attr:`mutual_friends`. + fetch_note: :class:`bool` + Whether to pre-fetch the user's note. + + Raises + ------- + :exc:`.NotFound` + A user with this ID does not exist. + :exc:`.Forbidden` + Not allowed to fetch this profile. + :exc:`.HTTPException` + Fetching the profile failed. + + Returns + -------- + :class:`.Profile` + The profile of the user. + """ + + state = self._connection + data = await self.http.get_user_profile(user_id, with_mutual_guilds=with_mutuals) + + if with_mutuals: + data['mutual_friends'] = await self.http.get_mutual_friends(user_id) + + profile = Profile(state, data) + + if fetch_note: + await profile.note.fetch() + + return profile + + fetch_profile = fetch_user_profile + async def fetch_channel(self, channel_id: int, /) -> Union[GuildChannel, PrivateChannel, Thread]: """|coro| @@ -1490,6 +1628,51 @@ class Client: data = await self.http.list_premium_sticker_packs() return [StickerPack(state=self._connection, data=pack) for pack in data['sticker_packs']] + async def fetch_notes(self) -> List[Note]: + """|coro| + + Retrieves a list of :class:`Note` objects representing all your notes. + + Raises + ------- + :exc:`.HTTPException` + Retreiving the notes failed. + + Returns + -------- + List[:class:`Note`] + All your notes. + """ + state = self._connection + data = await self.http.get_notes() + return [Note(state, int(id), note=note) for id, note in data.items()] + + async def fetch_note(self, user_id: int) -> Note: + """|coro| + + Retrieves a :class:`Note` for the specified user ID. + + Parameters + ----------- + user_id: :class:`int` + The ID of the user to fetch the note for. + + Raises + ------- + :exc:`.HTTPException` + Retreiving the note failed. + + Returns + -------- + :class:`Note` + The note you requested. + """ + try: + data = await self.http.get_note(user_id) + except NotFound: + data = {'note': 0} + return Note(self._connection, int(user_id), note=data['note']) + async def create_dm(self, user: Snowflake) -> DMChannel: """|coro| diff --git a/discord/components.py b/discord/components.py index 74c7be3d0..491716076 100644 --- a/discord/components.py +++ b/discord/components.py @@ -132,11 +132,6 @@ class Button(Component): This inherits from :class:`Component`. - .. note:: - - The user constructible and usable type to create a button is :class:`discord.ui.Button` - not this one. - .. versionadded:: 2.0 Attributes @@ -205,11 +200,6 @@ class SelectMenu(Component): A select menu is functionally the same as a dropdown, however on mobile it renders a bit differently. - .. note:: - - The user constructible and usable type to create a select menu is - :class:`discord.ui.Select` not this one. - .. versionadded:: 2.0 Attributes @@ -269,8 +259,6 @@ class SelectMenu(Component): class SelectOption: """Represents a select menu's option. - These can be created by users. - .. versionadded:: 2.0 Attributes diff --git a/discord/errors.py b/discord/errors.py index e344c9f8f..4210947fa 100644 --- a/discord/errors.py +++ b/discord/errors.py @@ -80,7 +80,7 @@ class GatewayNotFound(DiscordException): """An exception that is raised when the gateway for Discord could not be found""" def __init__(self): - message = 'The gateway to connect to discord was not found.' + message = 'The gateway to connect to Discord was not found.' super().__init__(message) @@ -111,13 +111,14 @@ class HTTPException(DiscordException): The response of the failed HTTP request. This is an instance of :class:`aiohttp.ClientResponse`. In some cases this could also be a :class:`requests.Response`. - text: :class:`str` The text of the error. Could be an empty string. status: :class:`int` The status code of the HTTP request. code: :class:`int` The Discord specific error code for the failure. + json: Dict[any, any] + The raw error JSON. """ def __init__(self, response: _ResponseType, message: Optional[Union[str, Dict[str, Any]]]): @@ -126,6 +127,7 @@ class HTTPException(DiscordException): self.code: int self.text: str if isinstance(message, dict): + self.json = message self.code = message.get('code', 0) base = message.get('message', '') errors = message.get('errors') @@ -195,7 +197,7 @@ class InvalidArgument(ClientException): pass -class LoginFailure(ClientException): +class AuthFailure(ClientException): """Exception that's raised when the :meth:`Client.login` function fails to log you in from improper credentials or some other misc. failure. @@ -203,6 +205,8 @@ class LoginFailure(ClientException): pass +LoginFailure = AuthFailure + class ConnectionClosed(ClientException): """Exception that's raised when the gateway connection is diff --git a/discord/flags.py b/discord/flags.py index 391ec3b37..08e99faa6 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -34,6 +34,7 @@ __all__ = ( 'PublicUserFlags', 'MemberCacheFlags', 'ApplicationFlags', + 'GuildSubscriptionOptions', ) FV = TypeVar('FV', bound='flag_value') @@ -573,3 +574,55 @@ class ApplicationFlags(BaseFlags): def embedded(self): """:class:`bool`: Returns ``True`` if the application is embedded within the Discord client.""" return 1 << 17 + + +class GuildSubscriptionOptions: + """Controls the library's auto-subscribing feature. + + Subscribing refers to abusing the member sidebar to scrape all* guild + members. However, you can only request 200 members per OPCode 14. + + Once you send a proper OPCode 14, Discord responds with a + GUILD_MEMBER_LIST_UPDATE. You then also get subsequent GUILD_MEMBER_LIST_UPDATEs + that act (kind of) like GUILD_MEMBER_UPDATE/ADD/REMOVEs. + + *Discord doesn't provide offline members for "large" guilds. + *As this is dependent on the member sidebar, guilds that don't have + a channel (of any type, surprisingly) that @everyone or some other + role everyone has can't access don't get the full online member list. + + To construct an object you can pass keyword arguments denoting the options + and their values. If you don't pass a value, the default is used. + """ + + def __init__( + self, *, auto_subscribe: bool = True, concurrent_guilds: int = 2, max_online: int = 6000 + ) -> None: + if concurrent_guilds < 1: + raise TypeError('concurrent_guilds must be positive') + if max_online < 1: + raise TypeError('max_online must be positive') + + self.auto_subscribe = auto_subscribe + self.concurrent_guilds = concurrent_guilds + self.max_online = max_online + + def __repr__(self) -> str: + return ' GuildSubscriptionOptions: + """A factory method that creates a :class:`GuildSubscriptionOptions` that subscribes every guild. Not recommended in the slightest.""" + return cls(max_online=10000000) + + @classmethod + def default(cls) -> GuildSubscriptionOptions: + """A factory method that creates a :class:`GuildSubscriptionOptions` with default values.""" + return cls() + + @classmethod + def disabled(cls) -> GuildSubscriptionOptions: + """A factory method that creates a :class:`GuildSubscriptionOptions` with subscribing disabled.""" + return cls(auto_subscribe=False) + + off = disabled diff --git a/discord/gateway.py b/discord/gateway.py index 92128893c..dd1107a8c 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -103,61 +103,59 @@ class GatewayRatelimiter: await asyncio.sleep(delta) -class KeepAliveHandler(threading.Thread): - def __init__(self, *args, **kwargs): - ws = kwargs.pop('ws', None) - interval = kwargs.pop('interval', None) - threading.Thread.__init__(self, *args, **kwargs) +class KeepAliveHandler: # Inspired by enhanced-discord.py/Gnome + def __init__(self, *, ws, interval=None): self.ws = ws - self._main_thread_id = ws.thread_id self.interval = interval - self.daemon = True - self.msg = 'Keeping websocket alive with sequence %s.' + self.heartbeat_timeout = self.ws._max_heartbeat_timeout + + self.msg = 'Keeping websocket alive.' self.block_msg = 'Heartbeat blocked for more than %s seconds.' self.behind_msg = 'Can\'t keep up, websocket is %.1fs behind.' - self._stop_ev = threading.Event() - self._last_ack = time.perf_counter() + self.not_responding_msg = 'Gateway has stopped responding. Closing and restarting.' + self.no_stop_msg = 'An error occurred while stopping the gateway. Ignoring.' + + self._stop_ev = asyncio.Event() self._last_send = time.perf_counter() self._last_recv = time.perf_counter() + self._last_ack = time.perf_counter() self.latency = float('inf') - self.heartbeat_timeout = ws._max_heartbeat_timeout - def run(self): - while not self._stop_ev.wait(self.interval): + async def run(self): + while True: + try: + await asyncio.wait_for(self._stop_ev.wait(), timeout=self.interval) + except asyncio.TimeoutError: + pass + else: + return + if self._last_recv + self.heartbeat_timeout < time.perf_counter(): - _log.warning('Gateway has stopped responding. Closing and restarting.') - coro = self.ws.close(4000) - f = asyncio.run_coroutine_threadsafe(coro, loop=self.ws.loop) + log.warning(self.not_responding_msg) try: - f.result() + await self.ws.close(4000) except Exception: - _log.exception('An error occurred while stopping the gateway. Ignoring.') + log.exception(self.no_stop_msg) finally: self.stop() return data = self.get_payload() - _log.debug(self.msg, data['d']) - coro = self.ws.send_heartbeat(data) - f = asyncio.run_coroutine_threadsafe(coro, loop=self.ws.loop) + log.debug(self.msg) try: - # block until sending is complete + # Block until sending is complete total = 0 while True: try: - f.result(10) + await asyncio.wait_for(self.ws.send_heartbeat(data), timeout=10) break - except concurrent.futures.TimeoutError: + except asyncio.TimeoutError: total += 10 - try: - frame = sys._current_frames()[self._main_thread_id] - except KeyError: - msg = self.block_msg - else: - stack = ''.join(traceback.format_stack(frame)) - msg = f'{self.block_msg}\nLoop thread traceback (most recent call last):\n{stack}' - _log.warning(msg, total) + + stack = ''.join(traceback.format_stack()) + msg = f'{self.block_msg}\nLoop traceback (most recent call last):\n{stack}' + log.warning(msg, total) except Exception: self.stop() @@ -167,9 +165,12 @@ class KeepAliveHandler(threading.Thread): def get_payload(self): return { 'op': self.ws.HEARTBEAT, - 'd': self.ws.sequence + 'd': self.ws.sequence, } + def start(self): + self.ws.loop.create_task(self.run()) + def stop(self): self._stop_ev.set() @@ -181,15 +182,18 @@ class KeepAliveHandler(threading.Thread): self._last_ack = ack_time self.latency = ack_time - self._last_send if self.latency > 10: - _log.warning(self.behind_msg, self.latency) + log.warning(self.behind_msg, self.latency) + class VoiceKeepAliveHandler(KeepAliveHandler): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.recent_ack_latencies = deque(maxlen=20) - self.msg = 'Keeping voice websocket alive with timestamp %s.' - self.block_msg = 'Voice heartbeat blocked for more than %s seconds.' - self.behind_msg = 'High socket latency, voice websocket is %.1fs behind.' + self.msg = 'Keeping voice websocket alive.' + self.block_msg = 'Voice heartbeat blocked for more than %s seconds' + self.behind_msg = 'High socket latency, heartbeat is %.1fs behind' + self.not_responding_msg = 'Voice gateway has stopped responding. Closing and restarting.' + self.no_stop_msg = 'An error occurred while stopping the voice gateway. Ignoring.' def get_payload(self): return { @@ -203,10 +207,9 @@ class VoiceKeepAliveHandler(KeepAliveHandler): self._last_recv = ack_time self.latency = ack_time - self._last_send self.recent_ack_latencies.append(self.latency) + if self.latency > 10: + log.warning(self.behind_msg, self.latency) -class DiscordClientWebSocketResponse(aiohttp.ClientWebSocketResponse): - async def close(self, *, code: int = 4000, message: bytes = b'') -> bool: - return await super().close(code=code, message=message) class DiscordWebSocket: """Implements a WebSocket for Discord's gateway v6. @@ -241,7 +244,11 @@ class DiscordWebSocket: Receive only. Confirms receiving of a heartbeat. Not having it implies a connection issue. GUILD_SYNC - Send only. Requests a guild sync. + Send only. Requests a guild sync. This is unfortunately no longer functional. + ACCESS_DM + Send only. Tracking. + GUILD_SUBSCRIBE + Send only. Subscribes you to guilds/guild members. Might respond with GUILD_MEMBER_LIST_UPDATE. gateway The gateway we are currently connected to. token @@ -261,6 +268,8 @@ class DiscordWebSocket: HELLO = 10 HEARTBEAT_ACK = 11 GUILD_SYNC = 12 + ACCESS_DM = 13 + GUILD_SUBSCRIBE = 14 def __init__(self, socket, *, loop): self.socket = socket @@ -316,6 +325,10 @@ class DiscordWebSocket: ws.session_id = session ws.sequence = sequence ws._max_heartbeat_timeout = client._connection.heartbeat_timeout + ws._user_agent = client.http.user_agent + ws._super_properties = client.http.super_properties + ws._zlib_enabled = client.http.zlib + if client._enable_debug_events: ws.send = ws.debug_send @@ -323,9 +336,9 @@ class DiscordWebSocket: client._connection._update_references(ws) - _log.debug('Created websocket connected to %s', gateway) + _log.debug('Connected to %s.', gateway) - # poll event for OP Hello + # Poll for Hello await ws.poll_event() if not resume: @@ -366,31 +379,30 @@ class DiscordWebSocket: 'op': self.IDENTIFY, 'd': { 'token': self.token, - 'properties': { - '$os': sys.platform, - '$browser': 'discord.py', - '$device': 'discord.py', - '$referrer': '', - '$referring_domain': '' + 'capabilities': 125, + 'properties': self._super_properties, + 'presence': { + 'status': 'online', + 'since': 0, + 'activities': [], + 'afk': False }, - 'compress': True, - 'large_threshold': 250, - 'v': 3 + 'compress': False, + 'client_state': { + 'guild_hashes': {}, + 'highest_last_message_id': '0', + 'read_state_version': 0, + 'user_guild_settings_version': -1 + } } } - state = self._connection - if state._activity is not None or state._status is not None: - payload['d']['presence'] = { - 'status': state._status, - 'game': state._activity, - 'since': 0, - 'afk': False - } + if not self._zlib_enabled: + payload['d']['compress'] = True await self.call_hooks('before_identify', initial=self._initial_identify) await self.send_as_json(payload) - _log.info('Gateway has sent the IDENTIFY payload.') + log.info('Gateway has sent the IDENTIFY payload.') async def resume(self): """Sends the RESUME packet.""" @@ -419,7 +431,7 @@ class DiscordWebSocket: self.log_receive(msg) msg = utils._from_json(msg) - _log.debug('WebSocket Event: %s.', msg) + _log.debug('Gateway event: %s.', msg) event = msg.get('t') if event: self._dispatch('socket_event_type', event) @@ -456,7 +468,7 @@ class DiscordWebSocket: if op == self.HELLO: interval = data['heartbeat_interval'] / 1000.0 self._keep_alive = KeepAliveHandler(ws=self, interval=interval) - # send a heartbeat immediately + # Send a heartbeat immediately await self.send_as_json(self._keep_alive.get_payload()) self._keep_alive.start() return @@ -480,21 +492,22 @@ class DiscordWebSocket: self.sequence = msg['s'] self.session_id = data['session_id'] _log.info('Connected to Gateway: %s (Session ID: %s).', - ', '.join(trace), self.session_id) + ', '.join(trace), self.session_id) elif event == 'RESUMED': self._trace = trace = data.get('_trace', []) _log.info('Gateway has successfully RESUMED session %s under trace %s.', - self.session_id, ', '.join(trace)) + self.session_id, ', '.join(trace)) try: func = self._discord_parsers[event] except KeyError: _log.debug('Unknown event %s.', event) else: + _log.debug('Parsing event %s.', event) func(data) - # remove the dispatched listeners + # Remove the dispatched listeners removed = [] for index, entry in enumerate(self._dispatch_listeners): if entry.event != event: @@ -616,40 +629,63 @@ class DiscordWebSocket: _log.debug('Sending "%s" to change status', sent) await self.send(sent) - async def request_chunks(self, guild_id, query=None, *, limit, user_ids=None, presences=False, nonce=None): + async def request_lazy_guild(self, guild_id, *, typing=None, threads=None, activities=None, members=None, channels=None, thread_member_lists=None): payload = { - 'op': self.REQUEST_MEMBERS, + 'op': self.GUILD_SUBSCRIBE, 'd': { 'guild_id': guild_id, - 'presences': presences, - 'limit': limit } } - if nonce: - payload['d']['nonce'] = nonce + data = payload['d'] + if typing is not None: + data['typing'] = typing + if threads is not None: + data['threads'] = threads + if activities is not None: + data['activities'] = activities + if members is not None: + data['members'] = members + if channels is not None: + data['channels'] = channels + if thread_member_lists is not None: + data['thread_member_lists'] = thread_member_lists - if user_ids: - payload['d']['user_ids'] = user_ids + await self.send_as_json(payload) - if query is not None: - payload['d']['query'] = query + async def request_chunks(self, guild_ids, query=None, *, limit=None, user_ids=None, presences=True, nonce=None): + payload = { + 'op': self.REQUEST_MEMBERS, + 'd': { + 'guild_id': guild_ids, + 'query': query, + 'limit': limit, + 'presences': presences, + 'user_ids': user_ids, + } + } + if nonce: + payload['d']['nonce'] = nonce await self.send_as_json(payload) - async def voice_state(self, guild_id, channel_id, self_mute=False, self_deaf=False): + async def voice_state(self, guild_id=None, channel_id=None, self_mute=False, self_deaf=False, self_video=False, *, preferred_region=None): payload = { 'op': self.VOICE_STATE, 'd': { 'guild_id': guild_id, 'channel_id': channel_id, 'self_mute': self_mute, - 'self_deaf': self_deaf + 'self_deaf': self_deaf, + 'self_video': self_video, } } - _log.debug('Updating our voice state to %s.', payload) + if preferred_region is not None: + payload['d']['preferred_region'] = preferred_region + + _log.debug('Updating %s voice state to %s.', guild_id or 'client', payload) await self.send_as_json(payload) async def close(self, code=4000): diff --git a/discord/guild.py b/discord/guild.py index b2132960f..10bc3aea2 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -2850,8 +2850,14 @@ class Guild(Hashable): ) async def change_voice_state( - self, *, channel: Optional[VocalGuildChannel], self_mute: bool = False, self_deaf: bool = False - ): + self, + *, + channel: Optional[VocalGuildChannel], + self_mute: bool = False, + self_deaf: bool = False, + self_video: bool = False, + preferred_region: Optional[VoiceRegion] = MISSING + ) -> None: """|coro| Changes client's voice state in the guild. @@ -2866,7 +2872,18 @@ class Guild(Hashable): Indicates if the client should be self-muted. self_deaf: :class:`bool` Indicates if the client should be self-deafened. + self_video: :class:`bool` + Indicates if the client is using video. Untested & unconfirmed + (do not use). + preferred_region: Optional[:class:`VoiceRegion`] + The preferred region to connect to. """ ws = self._state._get_websocket(self.id) channel_id = channel.id if channel else None - await ws.voice_state(self.id, channel_id, self_mute, self_deaf) + + if preferred_region is None or channel_id is None: + region = None + else: + region = str(preferred_region) if preferred_region else str(state.preferred_region) + + await ws.voice_state(self.id, channel_id, self_mute, self_deaf, self_video, region) diff --git a/discord/interactions.py b/discord/interactions.py deleted file mode 100644 index b89d49f53..000000000 --- a/discord/interactions.py +++ /dev/null @@ -1,767 +0,0 @@ -# -*- coding: utf-8 -*- - -""" -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, Dict, List, Optional, TYPE_CHECKING, Tuple, Union -import asyncio - -from . import utils -from .enums import try_enum, InteractionType, InteractionResponseType -from .errors import InteractionResponded, HTTPException, ClientException -from .channel import PartialMessageable, ChannelType - -from .user import User -from .member import Member -from .message import Message, Attachment -from .object import Object -from .permissions import Permissions -from .webhook.async_ import async_context, Webhook, handle_message_parameters - -__all__ = ( - 'Interaction', - 'InteractionMessage', - 'InteractionResponse', -) - -if TYPE_CHECKING: - from .types.interactions import ( - Interaction as InteractionPayload, - InteractionData, - ) - from .guild import Guild - from .state import ConnectionState - from .file import File - from .mentions import AllowedMentions - from aiohttp import ClientSession - from .embeds import Embed - from .ui.view import View - from .channel import VoiceChannel, StageChannel, TextChannel, CategoryChannel, StoreChannel, PartialMessageable - from .threads import Thread - - InteractionChannel = Union[ - VoiceChannel, StageChannel, TextChannel, CategoryChannel, StoreChannel, Thread, PartialMessageable - ] - -MISSING: Any = utils.MISSING - - -class Interaction: - """Represents a Discord interaction. - - An interaction happens when a user does an action that needs to - be notified. Current examples are slash commands and components. - - .. versionadded:: 2.0 - - Attributes - ----------- - id: :class:`int` - The interaction's ID. - type: :class:`InteractionType` - The interaction type. - guild_id: Optional[:class:`int`] - The guild ID the interaction was sent from. - channel_id: Optional[:class:`int`] - The channel ID the interaction was sent from. - application_id: :class:`int` - 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. - data: :class:`dict` - The raw interaction data. - """ - - __slots__: Tuple[str, ...] = ( - 'id', - 'type', - 'guild_id', - 'channel_id', - 'data', - 'application_id', - 'message', - 'user', - 'token', - 'version', - '_permissions', - '_state', - '_session', - '_original_message', - '_cs_response', - '_cs_followup', - '_cs_channel', - ) - - def __init__(self, *, data: InteractionPayload, state: ConnectionState): - self._state: ConnectionState = state - self._session: ClientSession = state.http._HTTPClient__session - self._original_message: Optional[InteractionMessage] = None - self._from_data(data) - - def _from_data(self, data: InteractionPayload): - self.id: int = int(data['id']) - self.type: InteractionType = try_enum(InteractionType, data['type']) - self.data: Optional[InteractionData] = data.get('data') - self.token: str = data['token'] - self.version: int = data['version'] - self.channel_id: Optional[int] = utils._get_as_snowflake(data, 'channel_id') - self.guild_id: Optional[int] = utils._get_as_snowflake(data, 'guild_id') - self.application_id: int = int(data['application_id']) - - self.message: Optional[Message] - try: - self.message = Message(state=self._state, channel=self.channel, data=data['message']) # type: ignore - except KeyError: - self.message = None - - self.user: Optional[Union[User, Member]] = None - self._permissions: int = 0 - - # TODO: there's a potential data loss here - if self.guild_id: - guild = self.guild or Object(id=self.guild_id) - try: - member = data['member'] # type: ignore - except KeyError: - pass - else: - self.user = Member(state=self._state, guild=guild, data=member) # type: ignore - self._permissions = int(member.get('permissions', 0)) - else: - try: - self.user = User(state=self._state, data=data['user']) - 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) - - @utils.cached_slot_property('_cs_channel') - def channel(self) -> Optional[InteractionChannel]: - """Optional[Union[:class:`abc.GuildChannel`, :class:`PartialMessageable`, :class:`Thread`]]: The channel the interaction was sent from. - - Note that due to a Discord limitation, DM channels are not resolved since there is - no data to complete them. These are :class:`PartialMessageable` instead. - """ - guild = self.guild - channel = guild and guild._resolve_channel(self.channel_id) - if channel is None: - if self.channel_id is not None: - type = ChannelType.text if self.guild_id is not None else ChannelType.private - return PartialMessageable(state=self._state, id=self.channel_id, type=type) - return None - return channel - - @property - def permissions(self) -> Permissions: - """:class:`Permissions`: The resolved permissions of the member in the channel, including overwrites. - - In a non-guild context where this doesn't apply, an empty permissions object is returned. - """ - return Permissions(self._permissions) - - @utils.cached_slot_property('_cs_response') - def response(self) -> InteractionResponse: - """:class:`InteractionResponse`: Returns an object responsible for handling responding to the interaction. - - A response can only be done once. If secondary messages need to be sent, consider using :attr:`followup` - instead. - """ - return InteractionResponse(self) - - @utils.cached_slot_property('_cs_followup') - def followup(self) -> Webhook: - """:class:`Webhook`: Returns the follow up webhook for follow up interactions.""" - payload = { - 'id': self.application_id, - 'type': 3, - 'token': self.token, - } - return Webhook.from_state(data=payload, state=self._state) - - async def original_message(self) -> InteractionMessage: - """|coro| - - Fetches the original interaction response message associated with the interaction. - - If the interaction response was :meth:`InteractionResponse.send_message` then this would - return the message that was sent using that response. Otherwise, this would return - the message that triggered the interaction. - - Repeated calls to this will return a cached value. - - Raises - ------- - HTTPException - Fetching the original response message failed. - ClientException - The channel for the message could not be resolved. - - Returns - -------- - InteractionMessage - The original interaction response message. - """ - - if self._original_message is not None: - return self._original_message - - # TODO: fix later to not raise? - channel = self.channel - if channel is None: - raise ClientException('Channel for message could not be resolved') - - adapter = async_context.get() - data = await adapter.get_original_interaction_response( - application_id=self.application_id, - token=self.token, - session=self._session, - ) - state = _InteractionMessageState(self, self._state) - message = InteractionMessage(state=state, channel=channel, data=data) # type: ignore - self._original_message = message - return message - - async def edit_original_message( - self, - *, - content: Optional[str] = MISSING, - embeds: List[Embed] = MISSING, - embed: Optional[Embed] = MISSING, - file: File = MISSING, - files: List[File] = MISSING, - view: Optional[View] = MISSING, - allowed_mentions: Optional[AllowedMentions] = None, - ) -> InteractionMessage: - """|coro| - - Edits the original interaction response message. - - This is a lower level interface to :meth:`InteractionMessage.edit` in case - you do not want to fetch the message and save an HTTP request. - - This method is also the only way to edit the original message if - the message sent was ephemeral. - - Parameters - ------------ - content: Optional[:class:`str`] - The content to edit the message with or ``None`` to clear it. - embeds: List[:class:`Embed`] - A list of embeds to edit the message with. - embed: Optional[:class:`Embed`] - The embed to edit the message with. ``None`` suppresses the embeds. - This should not be mixed with the ``embeds`` parameter. - file: :class:`File` - The file to upload. This cannot be mixed with ``files`` parameter. - files: List[:class:`File`] - A list of files to send with the content. This cannot be mixed with the - ``file`` parameter. - allowed_mentions: :class:`AllowedMentions` - Controls the mentions being processed in this message. - See :meth:`.abc.Messageable.send` for more information. - view: Optional[:class:`~discord.ui.View`] - The updated view to update this message with. If ``None`` is passed then - the view is removed. - - Raises - ------- - HTTPException - Editing the message failed. - Forbidden - Edited a message that is not yours. - TypeError - You specified both ``embed`` and ``embeds`` or ``file`` and ``files`` - ValueError - The length of ``embeds`` was invalid. - - Returns - -------- - :class:`InteractionMessage` - The newly edited message. - """ - - previous_mentions: Optional[AllowedMentions] = self._state.allowed_mentions - params = handle_message_parameters( - content=content, - file=file, - files=files, - embed=embed, - embeds=embeds, - view=view, - allowed_mentions=allowed_mentions, - previous_allowed_mentions=previous_mentions, - ) - adapter = async_context.get() - data = await adapter.edit_original_interaction_response( - self.application_id, - self.token, - session=self._session, - payload=params.payload, - multipart=params.multipart, - files=params.files, - ) - - # The message channel types should always match - message = InteractionMessage(state=self._state, channel=self.channel, data=data) # type: ignore - if view and not view.is_finished(): - self._state.store_view(view, message.id) - return message - - async def delete_original_message(self) -> None: - """|coro| - - Deletes the original interaction response message. - - This is a lower level interface to :meth:`InteractionMessage.delete` in case - you do not want to fetch the message and save an HTTP request. - - Raises - ------- - HTTPException - Deleting the message failed. - Forbidden - Deleted a message that is not yours. - """ - adapter = async_context.get() - await adapter.delete_original_interaction_response( - self.application_id, - self.token, - session=self._session, - ) - - -class InteractionResponse: - """Represents a Discord interaction response. - - This type can be accessed through :attr:`Interaction.response`. - - .. versionadded:: 2.0 - """ - - __slots__: Tuple[str, ...] = ( - '_responded', - '_parent', - ) - - def __init__(self, parent: Interaction): - self._parent: Interaction = parent - self._responded: bool = False - - def is_done(self) -> bool: - """:class:`bool`: Indicates whether an interaction response has been done before. - - An interaction can only be responded to once. - """ - return self._responded - - async def defer(self, *, ephemeral: bool = False) -> None: - """|coro| - - Defers the interaction response. - - This is typically used when the interaction is acknowledged - and a secondary action will be done later. - - Parameters - ----------- - ephemeral: :class:`bool` - Indicates whether the deferred message will eventually be ephemeral. - This only applies for interactions of type :attr:`InteractionType.application_command`. - - Raises - ------- - HTTPException - Deferring the interaction failed. - InteractionResponded - This interaction has already been responded to before. - """ - if self._responded: - raise InteractionResponded(self._parent) - - defer_type: int = 0 - data: Optional[Dict[str, Any]] = None - parent = self._parent - if parent.type is InteractionType.component: - defer_type = InteractionResponseType.deferred_message_update.value - elif parent.type is InteractionType.application_command: - defer_type = InteractionResponseType.deferred_channel_message.value - if ephemeral: - data = {'flags': 64} - - if defer_type: - adapter = async_context.get() - await adapter.create_interaction_response( - parent.id, parent.token, session=parent._session, type=defer_type, data=data - ) - self._responded = True - - async def pong(self) -> None: - """|coro| - - Pongs the ping interaction. - - This should rarely be used. - - Raises - ------- - HTTPException - Ponging the interaction failed. - InteractionResponded - This interaction has already been responded to before. - """ - if self._responded: - raise InteractionResponded(self._parent) - - parent = self._parent - if parent.type is InteractionType.ping: - adapter = async_context.get() - await adapter.create_interaction_response( - parent.id, parent.token, session=parent._session, type=InteractionResponseType.pong.value - ) - self._responded = True - - async def send_message( - self, - content: Optional[Any] = None, - *, - embed: Embed = MISSING, - embeds: List[Embed] = MISSING, - view: View = MISSING, - tts: bool = False, - ephemeral: bool = False, - ) -> None: - """|coro| - - Responds to this interaction by sending a message. - - Parameters - ----------- - content: Optional[:class:`str`] - The content of the message to send. - embeds: List[:class:`Embed`] - A list of embeds to send with the content. Maximum of 10. This cannot - be mixed with the ``embed`` parameter. - embed: :class:`Embed` - The rich embed for the content to send. This cannot be mixed with - ``embeds`` parameter. - tts: :class:`bool` - Indicates if the message should be sent using text-to-speech. - view: :class:`discord.ui.View` - The view to send with the message. - ephemeral: :class:`bool` - Indicates if the message should only be visible to the user who started the interaction. - If a view is sent with an ephemeral message and it has no timeout set then the timeout - is set to 15 minutes. - - Raises - ------- - HTTPException - Sending the message failed. - TypeError - You specified both ``embed`` and ``embeds``. - ValueError - The length of ``embeds`` was invalid. - InteractionResponded - This interaction has already been responded to before. - """ - if self._responded: - raise InteractionResponded(self._parent) - - payload: Dict[str, Any] = { - 'tts': tts, - } - - if embed is not MISSING and embeds is not MISSING: - raise TypeError('cannot mix embed and embeds keyword arguments') - - if embed is not MISSING: - embeds = [embed] - - if embeds: - if len(embeds) > 10: - raise ValueError('embeds cannot exceed maximum of 10 elements') - payload['embeds'] = [e.to_dict() for e in embeds] - - if content is not None: - payload['content'] = str(content) - - if ephemeral: - payload['flags'] = 64 - - if view is not MISSING: - payload['components'] = view.to_components() - - parent = self._parent - adapter = async_context.get() - await adapter.create_interaction_response( - parent.id, - parent.token, - session=parent._session, - type=InteractionResponseType.channel_message.value, - data=payload, - ) - - if view is not MISSING: - if ephemeral and view.timeout is None: - view.timeout = 15 * 60.0 - - self._parent._state.store_view(view) - - self._responded = True - - async def edit_message( - self, - *, - content: Optional[Any] = MISSING, - embed: Optional[Embed] = MISSING, - embeds: List[Embed] = MISSING, - attachments: List[Attachment] = MISSING, - view: Optional[View] = MISSING, - ) -> None: - """|coro| - - Responds to this interaction by editing the original message of - a component interaction. - - Parameters - ----------- - content: Optional[:class:`str`] - The new content to replace the message with. ``None`` removes the content. - embeds: List[:class:`Embed`] - A list of embeds to edit the message with. - embed: Optional[:class:`Embed`] - The embed to edit the message with. ``None`` suppresses the embeds. - This should not be mixed with the ``embeds`` parameter. - attachments: List[:class:`Attachment`] - A list of attachments to keep in the message. If ``[]`` is passed - then all attachments are removed. - view: Optional[:class:`~discord.ui.View`] - The updated view to update this message with. If ``None`` is passed then - the view is removed. - - Raises - ------- - HTTPException - Editing the message failed. - TypeError - You specified both ``embed`` and ``embeds``. - InteractionResponded - This interaction has already been responded to before. - """ - if self._responded: - raise InteractionResponded(self._parent) - - parent = self._parent - msg = parent.message - state = parent._state - message_id = msg.id if msg else None - if parent.type is not InteractionType.component: - return - - payload = {} - if content is not MISSING: - if content is None: - payload['content'] = None - else: - payload['content'] = str(content) - - if embed is not MISSING and embeds is not MISSING: - raise TypeError('cannot mix both embed and embeds keyword arguments') - - if embed is not MISSING: - if embed is None: - embeds = [] - else: - embeds = [embed] - - if embeds is not MISSING: - payload['embeds'] = [e.to_dict() for e in embeds] - - if attachments is not MISSING: - payload['attachments'] = [a.to_dict() for a in attachments] - - if view is not MISSING: - state.prevent_view_updates_for(message_id) - if view is None: - payload['components'] = [] - else: - payload['components'] = view.to_components() - - adapter = async_context.get() - await adapter.create_interaction_response( - parent.id, - parent.token, - session=parent._session, - type=InteractionResponseType.message_update.value, - data=payload, - ) - - if view and not view.is_finished(): - state.store_view(view, message_id) - - self._responded = True - - -class _InteractionMessageState: - __slots__ = ('_parent', '_interaction') - - def __init__(self, interaction: Interaction, parent: ConnectionState): - self._interaction: Interaction = interaction - self._parent: ConnectionState = parent - - def _get_guild(self, guild_id): - return self._parent._get_guild(guild_id) - - def store_user(self, data): - return self._parent.store_user(data) - - def create_user(self, data): - return self._parent.create_user(data) - - @property - def http(self): - return self._parent.http - - def __getattr__(self, attr): - return getattr(self._parent, attr) - - -class InteractionMessage(Message): - """Represents the original interaction response message. - - This allows you to edit or delete the message associated with - the interaction response. To retrieve this object see :meth:`Interaction.original_message`. - - This inherits from :class:`discord.Message` with changes to - :meth:`edit` and :meth:`delete` to work. - - .. versionadded:: 2.0 - """ - - __slots__ = () - _state: _InteractionMessageState - - async def edit( - self, - content: Optional[str] = MISSING, - embeds: List[Embed] = MISSING, - embed: Optional[Embed] = MISSING, - file: File = MISSING, - files: List[File] = MISSING, - view: Optional[View] = MISSING, - allowed_mentions: Optional[AllowedMentions] = None, - ) -> InteractionMessage: - """|coro| - - Edits the message. - - Parameters - ------------ - content: Optional[:class:`str`] - The content to edit the message with or ``None`` to clear it. - embeds: List[:class:`Embed`] - A list of embeds to edit the message with. - embed: Optional[:class:`Embed`] - The embed to edit the message with. ``None`` suppresses the embeds. - This should not be mixed with the ``embeds`` parameter. - file: :class:`File` - The file to upload. This cannot be mixed with ``files`` parameter. - files: List[:class:`File`] - A list of files to send with the content. This cannot be mixed with the - ``file`` parameter. - allowed_mentions: :class:`AllowedMentions` - Controls the mentions being processed in this message. - See :meth:`.abc.Messageable.send` for more information. - view: Optional[:class:`~discord.ui.View`] - The updated view to update this message with. If ``None`` is passed then - the view is removed. - - Raises - ------- - HTTPException - Editing the message failed. - Forbidden - Edited a message that is not yours. - TypeError - You specified both ``embed`` and ``embeds`` or ``file`` and ``files`` - ValueError - The length of ``embeds`` was invalid. - - Returns - --------- - :class:`InteractionMessage` - The newly edited message. - """ - return await self._state._interaction.edit_original_message( - content=content, - embeds=embeds, - embed=embed, - file=file, - files=files, - view=view, - allowed_mentions=allowed_mentions, - ) - - async def delete(self, *, delay: Optional[float] = None) -> None: - """|coro| - - Deletes the message. - - Parameters - ----------- - delay: Optional[:class:`float`] - If provided, the number of seconds to wait before deleting the message. - The waiting is done in the background and deletion failures are ignored. - - Raises - ------ - Forbidden - You do not have proper permissions to delete the message. - NotFound - The message was deleted already. - HTTPException - Deleting the message failed. - """ - - if delay is not None: - - async def inner_call(delay: float = delay): - await asyncio.sleep(delay) - try: - await self._state._interaction.delete_original_message() - except HTTPException: - pass - - asyncio.create_task(inner_call()) - else: - await self._state._interaction.delete_original_message()