Browse Source

Migrate

pull/10109/head
dolfies 4 years ago
parent
commit
3429980d0c
  1. 4
      discord/__init__.py
  2. 48
      discord/abc.py
  3. 33
      discord/activity.py
  4. 3
      discord/appinfo.py
  5. 239
      discord/channel.py
  6. 255
      discord/client.py
  7. 12
      discord/components.py
  8. 10
      discord/errors.py
  9. 53
      discord/flags.py
  10. 194
      discord/gateway.py
  11. 23
      discord/guild.py
  12. 767
      discord/interactions.py

4
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())

48
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

33
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)

3
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
-------------

239
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'<GroupChannel id={self.id} name={self.name!r}>'
@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|

255
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|

12
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

10
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

53
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 auto_subscribe={0.auto_subscribe} concurrent_guilds={0.concurrent_guilds} max_online_count={0.max_online_count}' .format(self)
@classmethod
def all(cls) -> 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

194
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):

23
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)

767
discord/interactions.py

@ -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()
Loading…
Cancel
Save