From a4acbd2e0852cb748a565e8f885a78590d1b4886 Mon Sep 17 00:00:00 2001 From: Moksej <58531286+TheMoksej@users.noreply.github.com> Date: Sat, 28 Aug 2021 22:10:58 +0200 Subject: [PATCH 01/30] versionadded needs to be added here --- discord/guild.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/discord/guild.py b/discord/guild.py index 9ab81276..23926974 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -738,12 +738,16 @@ class Guild(Hashable): @property def humans(self) -> List[Member]: - """List[:class:`Member`]: A list of human members that belong to this guild.""" + """List[:class:`Member`]: A list of human members that belong to this guild. + + .. versionadded:: 2.0 """ return [member for member in self.members if not member.bot] @property def bots(self) -> List[Member]: - """List[:class:`Member`]: A list of bots that belong to this guild.""" + """List[:class:`Member`]: A list of bots that belong to this guild. + + .. versionadded:: 2.0 """ return [member for member in self.members if member.bot] def get_member(self, user_id: int, /) -> Optional[Member]: From 406f0ffe04066a444bea326d5c2010fe6acdf0ec Mon Sep 17 00:00:00 2001 From: Arthur Jovart Date: Sat, 28 Aug 2021 23:14:26 +0200 Subject: [PATCH 02/30] Make `Embed.image` and `Embed.thumbnail` full-featured properties This avoids the need for set_* methods. --- discord/embeds.py | 80 +++++++++++++++++++++++++++++++---------------- 1 file changed, 53 insertions(+), 27 deletions(-) diff --git a/discord/embeds.py b/discord/embeds.py index 7033a10e..e2119258 100644 --- a/discord/embeds.py +++ b/discord/embeds.py @@ -72,30 +72,36 @@ if TYPE_CHECKING: T = TypeVar('T') MaybeEmpty = Union[T, _EmptyEmbed] + class _EmbedFooterProxy(Protocol): text: MaybeEmpty[str] icon_url: MaybeEmpty[str] + class _EmbedFieldProxy(Protocol): name: MaybeEmpty[str] value: MaybeEmpty[str] inline: bool + class _EmbedMediaProxy(Protocol): url: MaybeEmpty[str] proxy_url: MaybeEmpty[str] height: MaybeEmpty[int] width: MaybeEmpty[int] + class _EmbedVideoProxy(Protocol): url: MaybeEmpty[str] height: MaybeEmpty[int] width: MaybeEmpty[int] + class _EmbedProviderProxy(Protocol): name: MaybeEmpty[str] url: MaybeEmpty[str] + class _EmbedAuthorProxy(Protocol): name: MaybeEmpty[str] url: MaybeEmpty[str] @@ -175,15 +181,15 @@ class Embed: Empty: Final = EmptyEmbed def __init__( - self, - *, - colour: Union[int, Colour, _EmptyEmbed] = EmptyEmbed, - color: Union[int, Colour, _EmptyEmbed] = EmptyEmbed, - title: MaybeEmpty[Any] = EmptyEmbed, - type: EmbedType = 'rich', - url: MaybeEmpty[Any] = EmptyEmbed, - description: MaybeEmpty[Any] = EmptyEmbed, - timestamp: datetime.datetime = None, + self, + *, + colour: Union[int, Colour, _EmptyEmbed] = EmptyEmbed, + color: Union[int, Colour, _EmptyEmbed] = EmptyEmbed, + title: MaybeEmpty[Any] = EmptyEmbed, + type: EmbedType = 'rich', + url: MaybeEmpty[Any] = EmptyEmbed, + description: MaybeEmpty[Any] = EmptyEmbed, + timestamp: datetime.datetime = None, ): self.colour = colour if colour is not EmptyEmbed else color @@ -366,7 +372,7 @@ class Embed: self._footer['icon_url'] = str(icon_url) return self - + def remove_footer(self: E) -> E: """Clears embed's footer information. @@ -381,7 +387,7 @@ class Embed: pass return self - + @property def image(self) -> _EmbedMediaProxy: """Returns an ``EmbedProxy`` denoting the image contents. @@ -397,6 +403,19 @@ class Embed: """ return EmbedProxy(getattr(self, '_image', {})) # type: ignore + @image.setter + def image(self: E, *, url: Any): + self._image = { + 'url': str(url), + } + + @image.deleter + def image(self: E): + try: + del self._image + except AttributeError: + pass + def set_image(self: E, *, url: MaybeEmpty[Any]) -> E: """Sets the image for the embed content. @@ -413,14 +432,9 @@ class Embed: """ if url is EmptyEmbed: - try: - del self._image - except AttributeError: - pass + del self.image else: - self._image = { - 'url': str(url), - } + self.image = url return self @@ -439,7 +453,25 @@ class Embed: """ return EmbedProxy(getattr(self, '_thumbnail', {})) # type: ignore - def set_thumbnail(self: E, *, url: MaybeEmpty[Any]) -> E: + @thumbnail.setter + def thumbnail(self: E, *, url: Any): + """Sets the thumbnail for the embed content. + """ + + self._thumbnail = { + 'url': str(url), + } + + return + + @thumbnail.deleter + def thumbnail(self): + try: + del self.thumbnail + except AttributeError: + pass + + def set_embed(self: E, *, url: MaybeEmpty[Any]): """Sets the thumbnail for the embed content. This function returns the class instance to allow for fluent-style @@ -453,16 +485,10 @@ class Embed: url: :class:`str` The source URL for the thumbnail. Only HTTP(S) is supported. """ - if url is EmptyEmbed: - try: - del self._thumbnail - except AttributeError: - pass + del self.thumbnail else: - self._thumbnail = { - 'url': str(url), - } + self.thumbnail = url return self From c8cdb275c513cdaba364903a4aa8fba7ba65c78f Mon Sep 17 00:00:00 2001 From: Arthur Jovart Date: Sat, 28 Aug 2021 23:29:49 +0200 Subject: [PATCH 03/30] Fix set_* function name --- discord/embeds.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/embeds.py b/discord/embeds.py index e2119258..25f05aef 100644 --- a/discord/embeds.py +++ b/discord/embeds.py @@ -471,7 +471,7 @@ class Embed: except AttributeError: pass - def set_embed(self: E, *, url: MaybeEmpty[Any]): + def set_thumbnail(self: E, *, url: MaybeEmpty[Any]): """Sets the thumbnail for the embed content. This function returns the class instance to allow for fluent-style From 75f052b8c91e91347cfe94b7c5b339311cd941f2 Mon Sep 17 00:00:00 2001 From: Sonic4999 Date: Sat, 28 Aug 2021 18:24:05 -0400 Subject: [PATCH 04/30] Prefer `static_format` over `format` with static assets --- discord/asset.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/discord/asset.py b/discord/asset.py index c58cfd36..25c72648 100644 --- a/discord/asset.py +++ b/discord/asset.py @@ -313,10 +313,11 @@ class Asset(AssetMixin): if self._animated: if format not in VALID_ASSET_FORMATS: raise InvalidArgument(f'format must be one of {VALID_ASSET_FORMATS}') - else: + url = url.with_path(f'{path}.{format}') + elif static_format is MISSING: if format not in VALID_STATIC_FORMATS: raise InvalidArgument(f'format must be one of {VALID_STATIC_FORMATS}') - url = url.with_path(f'{path}.{format}') + url = url.with_path(f'{path}.{format}') if static_format is not MISSING and not self._animated: if static_format not in VALID_STATIC_FORMATS: From 8cdc1f4ad9628196dcaf3f9d78ada4aad055f266 Mon Sep 17 00:00:00 2001 From: Arthur Jovart Date: Sun, 29 Aug 2021 00:26:26 +0200 Subject: [PATCH 05/30] Alias admin to administrators in permissions. This needs to be tested, but should be working. --- discord/permissions.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/discord/permissions.py b/discord/permissions.py index 9d40ca33..ff383431 100644 --- a/discord/permissions.py +++ b/discord/permissions.py @@ -299,6 +299,8 @@ class Permissions(BaseFlags): """ return 1 << 3 + admin = administrator + @flag_value def manage_channels(self) -> int: """:class:`bool`: Returns ``True`` if a user can edit, delete, or create channels in the guild. From cc90d312f5a834f6cc55771f56072dd382053c7a Mon Sep 17 00:00:00 2001 From: Jadon <47833807+WhoTheOOF@users.noreply.github.com> Date: Sat, 28 Aug 2021 17:26:46 -0500 Subject: [PATCH 06/30] Add original dark blurple This adds the old discord dark blurple color as a classmethod for embeds and whatever. --- discord/colour.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/discord/colour.py b/discord/colour.py index 2833e622..927addc1 100644 --- a/discord/colour.py +++ b/discord/colour.py @@ -324,6 +324,15 @@ class Colour: .. versionadded:: 2.0 """ return cls(0xFEE75C) + + @classmethod + def dark_blurple(cls: Type[CT]) -> CT: + """A factory method that returns a :class:`Colour` with a value of ``0x4E5D94``. + This is the original Dark Blurple branding. + + .. versionadded:: 2.0 + """ + return cls(0x4E5D94) Color = Colour From 31e3e99c2b131a72d8822aa80ef5802d3869b335 Mon Sep 17 00:00:00 2001 From: Arthur Jovart Date: Sun, 29 Aug 2021 00:59:29 +0200 Subject: [PATCH 07/30] Add __int__ special method to User and Member --- discord/member.py | 7 +++++++ discord/user.py | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/discord/member.py b/discord/member.py index a8c868d8..49b7e3ab 100644 --- a/discord/member.py +++ b/discord/member.py @@ -226,6 +226,10 @@ class Member(discord.abc.Messageable, _UserTag): Returns the member's name with the discriminator. + .. describe:: int(x) + + Returns the user's ID. + Attributes ---------- joined_at: Optional[:class:`datetime.datetime`] @@ -300,6 +304,9 @@ class Member(discord.abc.Messageable, _UserTag): def __str__(self) -> str: return str(self._user) + def __int__(self) -> int: + return self.id + def __repr__(self) -> str: return ( f' str: return f'{self.name}#{self.discriminator}' + def __int__(self) -> int: + return self.id + def __eq__(self, other: Any) -> bool: return isinstance(other, _UserTag) and other.id == self.id @@ -415,6 +418,10 @@ class User(BaseUser, discord.abc.Messageable): Returns the user's name with discriminator. + .. describe:: int(x) + + Returns the user's ID. + Attributes ----------- name: :class:`str` From 3ce86f6cde2126ee180703ec0499f685ab8a9114 Mon Sep 17 00:00:00 2001 From: Arthur Jovart Date: Sun, 29 Aug 2021 01:05:28 +0200 Subject: [PATCH 08/30] Add int() support to Emoji --- discord/emoji.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/discord/emoji.py b/discord/emoji.py index 8b51d0fd..39fa0218 100644 --- a/discord/emoji.py +++ b/discord/emoji.py @@ -72,6 +72,10 @@ class Emoji(_EmojiTag, AssetMixin): Returns the emoji rendered for discord. + .. describe:: int(x) + + Returns the emoji ID. + Attributes ----------- name: :class:`str` @@ -137,6 +141,9 @@ class Emoji(_EmojiTag, AssetMixin): return f'' return f'<:{self.name}:{self.id}>' + def __int__(self) -> int: + return self.id + def __repr__(self) -> str: return f'' From 9d1df65af37204f635d26d559e1d12b37d51716f Mon Sep 17 00:00:00 2001 From: Arthur Jovart Date: Sun, 29 Aug 2021 01:06:18 +0200 Subject: [PATCH 09/30] Add int() support to Role --- discord/role.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/discord/role.py b/discord/role.py index 0427ddc3..ee9ec1be 100644 --- a/discord/role.py +++ b/discord/role.py @@ -141,6 +141,10 @@ class Role(Hashable): Returns the role's name. + .. describe:: str(x) + + Returns the role's ID. + Attributes ---------- id: :class:`int` @@ -195,6 +199,9 @@ class Role(Hashable): def __str__(self) -> str: return self.name + def __int__(self) -> int: + return self.id + def __repr__(self) -> str: return f'' From fa7f8efc8ed8aa83b04d357c0ad79412e4a42259 Mon Sep 17 00:00:00 2001 From: Arthur Jovart Date: Sun, 29 Aug 2021 01:07:26 +0200 Subject: [PATCH 10/30] Add int() support to Guild --- discord/guild.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/discord/guild.py b/discord/guild.py index 9ab81276..ba911c06 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -140,6 +140,10 @@ class Guild(Hashable): Returns the guild's name. + .. describe:: int(x) + + Returns the guild's ID. + Attributes ---------- name: :class:`str` @@ -335,6 +339,9 @@ class Guild(Hashable): def __str__(self) -> str: return self.name or '' + def __int__(self) -> int: + return self.id + def __repr__(self) -> str: attrs = ( ('id', self.id), From 22de755059ae9a039c71091b92aed1645202224d Mon Sep 17 00:00:00 2001 From: Arthur Jovart Date: Sun, 29 Aug 2021 01:09:05 +0200 Subject: [PATCH 11/30] Add int() and str() support to Message --- discord/message.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/discord/message.py b/discord/message.py index 304c807d..8c78a3d2 100644 --- a/discord/message.py +++ b/discord/message.py @@ -503,6 +503,14 @@ class Message(Hashable): Returns the message's hash. + .. describe:: str(x) + + Returns the message's content. + + .. describe:: int(x) + + Returns the message's ID. + Attributes ----------- tts: :class:`bool` @@ -712,6 +720,12 @@ class Message(Hashable): f'<{name} id={self.id} channel={self.channel!r} type={self.type!r} author={self.author!r} flags={self.flags!r}>' ) + def __int__(self) -> int: + return self.id + + def __str__(self) -> Optional[str]: + return self.content + def _try_patch(self, data, key, transform=None) -> None: try: value = data[key] From 64ee79239106fcb507aab31f10db4aabb1b40f38 Mon Sep 17 00:00:00 2001 From: Arthur Jovart Date: Sun, 29 Aug 2021 01:21:20 +0200 Subject: [PATCH 12/30] Add int() support to Hashable, making it available across the board for AuditLogEntry, *Channel, Guild, Object, Message, ... --- discord/audit_logs.py | 4 ++++ discord/channel.py | 24 ++++++++++++++++++++++++ discord/guild.py | 3 --- discord/invite.py | 4 ++++ discord/message.py | 10 ++++++++-- discord/mixins.py | 3 +++ discord/object.py | 4 ++++ discord/role.py | 4 ++++ discord/stage_instance.py | 4 ++++ discord/sticker.py | 8 ++++++++ discord/threads.py | 8 ++++++++ discord/webhook/async_.py | 4 ++++ discord/webhook/sync.py | 4 ++++ 13 files changed, 79 insertions(+), 5 deletions(-) diff --git a/discord/audit_logs.py b/discord/audit_logs.py index 1870620f..b74bbfef 100644 --- a/discord/audit_logs.py +++ b/discord/audit_logs.py @@ -330,6 +330,10 @@ class AuditLogEntry(Hashable): Returns the entry's hash. + .. describe:: int(x) + + Returns the entry's ID. + .. versionchanged:: 1.7 Audit log entries are now comparable and hashable. diff --git a/discord/channel.py b/discord/channel.py index be3315cf..dc3967c4 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -115,6 +115,10 @@ class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable): Returns the channel's name. + .. describe:: int(x) + + Returns the channel's ID. + Attributes ----------- name: :class:`str` @@ -1334,6 +1338,10 @@ class CategoryChannel(discord.abc.GuildChannel, Hashable): Returns the category's name. + .. describe:: int(x) + + Returns the category's ID. + Attributes ----------- name: :class:`str` @@ -1556,6 +1564,10 @@ class StoreChannel(discord.abc.GuildChannel, Hashable): Returns the channel's name. + .. describe:: int(x) + + Returns the channel's ID. + Attributes ----------- name: :class:`str` @@ -1728,6 +1740,10 @@ class DMChannel(discord.abc.Messageable, Hashable): Returns a string representation of the channel + .. describe:: int(x) + + Returns the channel's ID. + Attributes ---------- recipient: Optional[:class:`User`] @@ -1854,6 +1870,10 @@ class GroupChannel(discord.abc.Messageable, Hashable): Returns a string representation of the channel + .. describe:: int(x) + + Returns the channel's ID. + Attributes ---------- recipients: List[:class:`User`] @@ -2000,6 +2020,10 @@ class PartialMessageable(discord.abc.Messageable, Hashable): Returns the partial messageable's hash. + .. describe:: int(x) + + Returns the messageable's ID. + Attributes ----------- id: :class:`int` diff --git a/discord/guild.py b/discord/guild.py index ba911c06..efb7a118 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -339,9 +339,6 @@ class Guild(Hashable): def __str__(self) -> str: return self.name or '' - def __int__(self) -> int: - return self.id - def __repr__(self) -> str: attrs = ( ('id', self.id), diff --git a/discord/invite.py b/discord/invite.py index d02fa680..050d2b83 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -230,6 +230,7 @@ class Invite(Hashable): Returns the invite URL. + The following table illustrates what methods will obtain the attributes: +------------------------------------+------------------------------------------------------------+ @@ -433,6 +434,9 @@ class Invite(Hashable): def __str__(self) -> str: return self.url + def __int__(self) -> int: + return 0 # To keep the object compatible with the hashable abc. + def __repr__(self) -> str: return ( f'' ) - def __int__(self) -> int: - return self.id def __str__(self) -> Optional[str]: return self.content @@ -1639,6 +1641,10 @@ class PartialMessage(Hashable): Returns the partial message's hash. + .. describe:: int(x) + + Returns the partial message's ID. + Attributes ----------- channel: Union[:class:`TextChannel`, :class:`Thread`, :class:`DMChannel`] diff --git a/discord/mixins.py b/discord/mixins.py index 32ee222b..fdacf863 100644 --- a/discord/mixins.py +++ b/discord/mixins.py @@ -43,5 +43,8 @@ class EqualityComparable: class Hashable(EqualityComparable): __slots__ = () + def __int__(self) -> int: + return self.id + def __hash__(self) -> int: return self.id >> 22 diff --git a/discord/object.py b/discord/object.py index 3795425f..8061a8be 100644 --- a/discord/object.py +++ b/discord/object.py @@ -69,6 +69,10 @@ class Object(Hashable): Returns the object's hash. + .. describe:: int(x) + + Returns the object's ID. + Attributes ----------- id: :class:`int` diff --git a/discord/role.py b/discord/role.py index ee9ec1be..b690cbe4 100644 --- a/discord/role.py +++ b/discord/role.py @@ -145,6 +145,10 @@ class Role(Hashable): Returns the role's ID. + .. describe:: int(x) + + Returns the role's ID. + Attributes ---------- id: :class:`int` diff --git a/discord/stage_instance.py b/discord/stage_instance.py index 479e89f2..b538eec3 100644 --- a/discord/stage_instance.py +++ b/discord/stage_instance.py @@ -61,6 +61,10 @@ class StageInstance(Hashable): Returns the stage instance's hash. + .. describe:: int(x) + + Returns the stage instance's ID. + Attributes ----------- id: :class:`int` diff --git a/discord/sticker.py b/discord/sticker.py index b0b5c678..8ec2ad01 100644 --- a/discord/sticker.py +++ b/discord/sticker.py @@ -67,6 +67,14 @@ class StickerPack(Hashable): Returns the name of the sticker pack. + .. describe:: hash(x) + + Returns the hash of the sticker pack. + + .. describe:: int(x) + + Returns the ID of the sticker pack. + .. describe:: x == y Checks if the sticker pack is equal to another sticker pack. diff --git a/discord/threads.py b/discord/threads.py index 892910d9..6f8ffb00 100644 --- a/discord/threads.py +++ b/discord/threads.py @@ -74,6 +74,10 @@ class Thread(Messageable, Hashable): Returns the thread's hash. + .. describe:: int(x) + + Returns the thread's ID. + .. describe:: str(x) Returns the thread's name. @@ -748,6 +752,10 @@ class ThreadMember(Hashable): Returns the thread member's hash. + .. describe:: int(x) + + Returns the thread member's ID. + .. describe:: str(x) Returns the thread member's name. diff --git a/discord/webhook/async_.py b/discord/webhook/async_.py index d1ed1ade..1aec6544 100644 --- a/discord/webhook/async_.py +++ b/discord/webhook/async_.py @@ -886,6 +886,10 @@ class Webhook(BaseWebhook): Returns the webhooks's hash. + .. describe:: int(x) + + Returns the webhooks's ID. + .. versionchanged:: 1.4 Webhooks are now comparable and hashable. diff --git a/discord/webhook/sync.py b/discord/webhook/sync.py index 5c85a036..62fb2d5f 100644 --- a/discord/webhook/sync.py +++ b/discord/webhook/sync.py @@ -475,6 +475,10 @@ class SyncWebhook(BaseWebhook): Returns the webhooks's hash. + .. describe:: int(x) + + Returns the webhooks's ID. + .. versionchanged:: 1.4 Webhooks are now comparable and hashable. From dba9a8abb9a27674c0bd2830152a12539bd3dea4 Mon Sep 17 00:00:00 2001 From: classerase <60030956+classerase@users.noreply.github.com> Date: Wed, 1 Sep 2021 23:30:15 +0100 Subject: [PATCH 13/30] Update README.rst (#48) --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index b8fdf99a..c9c2b6e6 100644 --- a/README.rst +++ b/README.rst @@ -59,7 +59,7 @@ To install the development version, do the following: .. code:: sh $ git clone https://github.com/iDevision/enhanced-discord.py - $ cd discord.py + $ cd enhanced-discord.py $ python3 -m pip install -U .[voice] From c485e08ea012be73fc44b136080d651a54896452 Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 2 Sep 2021 02:47:15 +0200 Subject: [PATCH 14/30] Add try_member to guild. (#14) * Add try_member to guild. This also fix an omission in the fetch_member docs. fetch_member raises NotFound if the given user isn't in the guild. * Optimize imports. --- discord/guild.py | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/discord/guild.py b/discord/guild.py index 4ed89821..c9132f5a 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -46,7 +46,7 @@ from . import utils, abc from .role import Role from .member import Member, VoiceState from .emoji import Emoji -from .errors import InvalidData +from .errors import InvalidData, NotFound from .permissions import PermissionOverwrite from .colour import Colour from .errors import InvalidArgument, ClientException @@ -1723,6 +1723,8 @@ class Guild(Hashable): You do not have access to the guild. HTTPException Fetching the member failed. + NotFound + A member with that ID does not exist. Returns -------- @@ -1732,6 +1734,34 @@ class Guild(Hashable): data = await self._state.http.get_member(self.id, member_id) return Member(data=data, state=self._state, guild=self) + async def try_member(self, member_id: int, /) -> Optional[Member]: + """|coro| + + Returns a member with the given ID. This uses the cache first, and if not found, it'll request using :meth:`fetch_member`. + + .. note:: + This method might result in an API call. + + Parameters + ----------- + member_id: :class:`int` + The ID to search for. + + Returns + -------- + Optional[:class:`Member`] + The member or ``None`` if not found. + """ + member = self.get_member(member_id) + + if member: + return member + else: + try: + return await self.fetch_member(member_id) + except NotFound: + return None + async def fetch_ban(self, user: Snowflake) -> BanEntry: """|coro| From 630a842556f695e346b6f7af1cca0af745b9ba03 Mon Sep 17 00:00:00 2001 From: NightSlasher35 <49624805+NightSlasher35@users.noreply.github.com> Date: Thu, 2 Sep 2021 01:49:41 +0100 Subject: [PATCH 15/30] Update CONTRIBUTING.md correctly (#29) * Update CONTRIBUTING.md * Update CONTRIBUTING.md Co-authored-by: Tom <47765953+IAmTomahawkx@users.noreply.github.com> --- .github/CONTRIBUTING.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 68f037c3..bb457a35 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,5 +1,7 @@ ## Contributing to discord.py +Credits to the `original lib` by Rapptz + First off, thanks for taking the time to contribute. It makes the library substantially better. :+1: The following is a set of guidelines for contributing to the repository. These are guidelines, not hard rules. @@ -8,9 +10,9 @@ The following is a set of guidelines for contributing to the repository. These a Generally speaking questions are better suited in our resources below. -- The official support server: https://discord.gg/r3sSKJJ +- The official support server: https://discord.gg/TvqYBrGXEm - The Discord API server under #python_discord-py: https://discord.gg/discord-api -- [The FAQ in the documentation](https://discordpy.readthedocs.io/en/latest/faq.html) +- [The FAQ in the documentation](https://enhanced-dpy.readthedocs.io/en/latest/faq.html) - [StackOverflow's `discord.py` tag](https://stackoverflow.com/questions/tagged/discord.py) Please try your best not to ask questions in our issue tracker. Most of them don't belong there unless they provide value to a larger audience. From b75be64044706d87c4117e135b5882e114b139dd Mon Sep 17 00:00:00 2001 From: iDutchy <42503862+iDutchy@users.noreply.github.com> Date: Thu, 2 Sep 2021 03:05:49 +0200 Subject: [PATCH 16/30] Update permissions.py A better implementation :) --- discord/permissions.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/discord/permissions.py b/discord/permissions.py index ff383431..4b3d9830 100644 --- a/discord/permissions.py +++ b/discord/permissions.py @@ -299,7 +299,12 @@ class Permissions(BaseFlags): """ return 1 << 3 - admin = administrator + @make_permission_alias('administrator') + def admin(self) -> int: + """:class:`bool`: An alias for :attr:`administrator`. + .. versionadded:: 2.0 + """ + return 1 << 3 @flag_value def manage_channels(self) -> int: From fc0188d7bcbd4fe93bd41331e405a2eb69583b42 Mon Sep 17 00:00:00 2001 From: Daud <78969148+Daudd@users.noreply.github.com> Date: Fri, 3 Sep 2021 02:17:19 +0700 Subject: [PATCH 17/30] Merge pull request #49 * Change README title to enhanced-discord.py --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index c9c2b6e6..9f222e10 100644 --- a/README.rst +++ b/README.rst @@ -1,5 +1,5 @@ -discord.py -========== +enhanced-discord.py +=================== .. image:: https://discord.com/api/guilds/514232441498763279/embed.png :target: https://discord.gg/PYAfZzpsjG From 5d1038457618576290dd23dbbe983293284f1441 Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 2 Sep 2021 21:18:26 +0200 Subject: [PATCH 18/30] Merge pull request #27 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add author_permissions to the Context object as a shortcut to return … --- discord/ext/commands/context.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/discord/ext/commands/context.py b/discord/ext/commands/context.py index 38a24d1d..fa16c74a 100644 --- a/discord/ext/commands/context.py +++ b/discord/ext/commands/context.py @@ -32,6 +32,7 @@ import discord.abc import discord.utils from discord.message import Message +from discord import Permissions if TYPE_CHECKING: from typing_extensions import ParamSpec @@ -314,6 +315,13 @@ class Context(discord.abc.Messageable, Generic[BotT]): g = self.guild return g.voice_client if g else None + def author_permissions(self) -> Permissions: + """Returns the author permissions in the given channel. + + .. versionadded:: 2.0 + """ + return self.channel.permissions_for(self.author) + async def send_help(self, *args: Any) -> Any: """send_help(entity=) From 13834d114726f7acc4d093d754568e4d5107c78f Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 2 Sep 2021 21:24:52 +0200 Subject: [PATCH 19/30] Merge pull request #7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add try_user to get a user from cache or from the gateway. * Extract populate_owners into a new coroutine. * Add a try_owners coroutine to get a list of owners of the bot. * Fix coding-style. * Fix a bug where None would be returned in try_owners if the cache was… * Fix docstring * Add spacing in the code --- discord/client.py | 32 +++++++++++++++++++++ discord/ext/commands/bot.py | 57 +++++++++++++++++++++++++++++++++---- 2 files changed, 83 insertions(+), 6 deletions(-) diff --git a/discord/client.py b/discord/client.py index b4f1db17..746dd995 100644 --- a/discord/client.py +++ b/discord/client.py @@ -829,6 +829,38 @@ class Client: """ return self._connection.get_user(id) + async def try_user(self, id: int, /) -> Optional[User]: + """|coro| + Returns a user with the given ID. If not from cache, the user will be requested from the API. + + You do not have to share any guilds with the user to get this information from the API, + however many operations do require that you do. + + .. note:: + This method is an API call. If you have :attr:`discord.Intents.members` and member cache enabled, consider :meth:`get_user` instead. + + .. versionadded:: 2.0 + + Parameters + ----------- + id: :class:`int` + The ID to search for. + + Returns + -------- + Optional[:class:`~discord.User`] + The user or ``None`` if not found. + """ + maybe_user = self.get_user(id) + + if maybe_user is not None: + return maybe_user + + try: + return await self.fetch_user(id) + except NotFound: + return None + def get_emoji(self, id: int, /) -> Optional[Emoji]: """Returns an emoji with the given ID. diff --git a/discord/ext/commands/bot.py b/discord/ext/commands/bot.py index c089b87d..e169577f 100644 --- a/discord/ext/commands/bot.py +++ b/discord/ext/commands/bot.py @@ -344,14 +344,59 @@ class BotBase(GroupMixin): elif self.owner_ids: return user.id in self.owner_ids else: + # Populate the used fields, then retry the check. This is only done at-most once in the bot lifetime. + await self.populate_owners() + return await self.is_owner(user) - app = await self.application_info() # type: ignore - if app.team: - self.owner_ids = ids = {m.id for m in app.team.members} - return user.id in ids + async def try_owners(self) -> List[discord.User]: + """|coro| + + Returns a list of :class:`~discord.User` representing the owners of the bot. + It uses the :attr:`owner_id` and :attr:`owner_ids`, if set. + + .. versionadded:: 2.0 + The function also checks if the application is team-owned if + :attr:`owner_ids` is not set. + + Returns + -------- + List[:class:`~discord.User`] + List of owners of the bot. + """ + if self.owner_id: + owner = await self.try_user(self.owner_id) + + if owner: + return [owner] else: - self.owner_id = owner_id = app.owner.id - return user.id == owner_id + return [] + + elif self.owner_ids: + owners = [] + + for owner_id in self.owner_ids: + owner = await self.try_user(owner_id) + if owner: + owners.append(owner) + + return owners + else: + # We didn't have owners cached yet, cache them and retry. + await self.populate_owners() + return await self.try_owners() + + async def populate_owners(self): + """|coro| + + Populate the :attr:`owner_id` and :attr:`owner_ids` through the use of :meth:`~.Bot.application_info`. + + .. versionadded:: 2.0 + """ + app = await self.application_info() # type: ignore + if app.team: + self.owner_ids = {m.id for m in app.team.members} + else: + self.owner_id = app.owner.id def before_invoke(self, coro: CFT) -> CFT: """A decorator that registers a coroutine as a pre-invoke hook. From 092fbca08f7bd2de8dda191c9a7007c9d3649c68 Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 2 Sep 2021 21:28:03 +0200 Subject: [PATCH 20/30] Merge pull request #21 * [BREAKING] Make case_insensitive default to True on groups and commands --- discord/ext/commands/bot.py | 2 +- discord/ext/commands/core.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/discord/ext/commands/bot.py b/discord/ext/commands/bot.py index e169577f..e03562b6 100644 --- a/discord/ext/commands/bot.py +++ b/discord/ext/commands/bot.py @@ -1120,7 +1120,7 @@ class Bot(BotBase, discord.Client): when passing an empty string, it should always be last as no prefix after it will be matched. case_insensitive: :class:`bool` - Whether the commands should be case insensitive. Defaults to ``False``. This + Whether the commands should be case insensitive. Defaults to ``True``. This attribute does not carry over to groups. You must set it to every group if you require group commands to be case insensitive as well. description: :class:`str` diff --git a/discord/ext/commands/core.py b/discord/ext/commands/core.py index 35b7e840..f122e9ad 100644 --- a/discord/ext/commands/core.py +++ b/discord/ext/commands/core.py @@ -1135,10 +1135,10 @@ class GroupMixin(Generic[CogT]): A mapping of command name to :class:`.Command` objects. case_insensitive: :class:`bool` - Whether the commands should be case insensitive. Defaults to ``False``. + Whether the commands should be case insensitive. Defaults to ``True``. """ def __init__(self, *args: Any, **kwargs: Any) -> None: - case_insensitive = kwargs.get('case_insensitive', False) + case_insensitive = kwargs.get('case_insensitive', True) self.all_commands: Dict[str, Command[CogT, Any, Any]] = _CaseInsensitiveDict() if case_insensitive else {} self.case_insensitive: bool = case_insensitive super().__init__(*args, **kwargs) From 42c0a8d8a5840c00185e367933e61e2565bf7305 Mon Sep 17 00:00:00 2001 From: chillymosh <86857777+chillymosh@users.noreply.github.com> Date: Thu, 2 Sep 2021 20:32:46 +0100 Subject: [PATCH 21/30] Merge pull request #12 * Clean up python * Clean up bot python * revert lists * revert commands.bot completely * extract raise_expected_coro further * add new lines * removed erroneous import * remove hashed line --- discord/activity.py | 10 ++++----- discord/ext/commands/bot.py | 16 ++++++-------- discord/ext/commands/context.py | 6 ++---- discord/ext/commands/converter.py | 21 +++++++++--------- discord/ext/commands/view.py | 15 ++++++------- discord/ext/tasks/__init__.py | 25 ++++++--------------- discord/player.py | 13 ++++++----- discord/ui/button.py | 16 +++++++------- discord/utils.py | 36 +++++++++++++++++-------------- 9 files changed, 74 insertions(+), 84 deletions(-) diff --git a/discord/activity.py b/discord/activity.py index 51205377..cba61f38 100644 --- a/discord/activity.py +++ b/discord/activity.py @@ -794,13 +794,13 @@ class CustomActivity(BaseActivity): return hash((self.name, str(self.emoji))) def __str__(self) -> str: - if self.emoji: - if self.name: - return f'{self.emoji} {self.name}' - return str(self.emoji) - else: + if not self.emoji: return str(self.name) + if self.name: + return f'{self.emoji} {self.name}' + return str(self.emoji) + def __repr__(self) -> str: return f'' diff --git a/discord/ext/commands/bot.py b/discord/ext/commands/bot.py index e03562b6..b3a1fb57 100644 --- a/discord/ext/commands/bot.py +++ b/discord/ext/commands/bot.py @@ -43,6 +43,7 @@ from .context import Context from . import errors from .help import HelpCommand, DefaultHelpCommand from .cog import Cog +from discord.utils import raise_expected_coro if TYPE_CHECKING: import importlib.machinery @@ -424,11 +425,9 @@ class BotBase(GroupMixin): TypeError The coroutine passed is not actually a coroutine. """ - if not asyncio.iscoroutinefunction(coro): - raise TypeError('The pre-invoke hook must be a coroutine.') - - self._before_invoke = coro - return coro + return raise_expected_coro( + coro, 'The pre-invoke hook must be a coroutine.' + ) def after_invoke(self, coro: CFT) -> CFT: r"""A decorator that registers a coroutine as a post-invoke hook. @@ -457,11 +456,10 @@ class BotBase(GroupMixin): TypeError The coroutine passed is not actually a coroutine. """ - if not asyncio.iscoroutinefunction(coro): - raise TypeError('The post-invoke hook must be a coroutine.') + return raise_expected_coro( + coro, 'The post-invoke hook must be a coroutine.' + ) - self._after_invoke = coro - return coro # listener registration diff --git a/discord/ext/commands/context.py b/discord/ext/commands/context.py index fa16c74a..158c84ea 100644 --- a/discord/ext/commands/context.py +++ b/discord/ext/commands/context.py @@ -21,6 +21,7 @@ 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 import inspect @@ -61,10 +62,7 @@ T = TypeVar('T') BotT = TypeVar('BotT', bound="Union[Bot, AutoShardedBot]") CogT = TypeVar('CogT', bound="Cog") -if TYPE_CHECKING: - P = ParamSpec('P') -else: - P = TypeVar('P') +P = ParamSpec('P') if TYPE_CHECKING else TypeVar('P') class Context(discord.abc.Messageable, Generic[BotT]): diff --git a/discord/ext/commands/converter.py b/discord/ext/commands/converter.py index 5740a188..ce7037a4 100644 --- a/discord/ext/commands/converter.py +++ b/discord/ext/commands/converter.py @@ -353,15 +353,15 @@ class PartialMessageConverter(Converter[discord.PartialMessage]): @staticmethod def _resolve_channel(ctx, guild_id, channel_id) -> Optional[PartialMessageableChannel]: - if guild_id is not None: - guild = ctx.bot.get_guild(guild_id) - if guild is not None and channel_id is not None: - return guild._resolve_channel(channel_id) # type: ignore - else: - return None - else: + if guild_id is None: return ctx.bot.get_channel(channel_id) if channel_id else ctx.channel + guild = ctx.bot.get_guild(guild_id) + if guild is not None and channel_id is not None: + return guild._resolve_channel(channel_id) # type: ignore + else: + return None + async def convert(self, ctx: Context, argument: str) -> discord.PartialMessage: guild_id, message_id, channel_id = self._get_id_matches(ctx, argument) channel = self._resolve_channel(ctx, guild_id, channel_id) @@ -754,8 +754,8 @@ class GuildConverter(IDConverter[discord.Guild]): if result is None: result = discord.utils.get(ctx.bot.guilds, name=argument) - if result is None: - raise GuildNotFound(argument) + if result is None: + raise GuildNotFound(argument) return result @@ -939,8 +939,7 @@ class clean_content(Converter[str]): def repl(match: re.Match) -> str: type = match[1] id = int(match[2]) - transformed = transforms[type](id) - return transformed + return transforms[type](id) result = re.sub(r'<(@[!&]?|#)([0-9]{15,20})>', repl, argument) if self.escape_markdown: diff --git a/discord/ext/commands/view.py b/discord/ext/commands/view.py index a7dc7236..39cc35f7 100644 --- a/discord/ext/commands/view.py +++ b/discord/ext/commands/view.py @@ -82,9 +82,7 @@ class StringView: def skip_string(self, string): strlen = len(string) if self.buffer[self.index:self.index + strlen] == string: - self.previous = self.index - self.index += strlen - return True + return self._return_index(strlen, True) return False def read_rest(self): @@ -95,9 +93,7 @@ class StringView: def read(self, n): result = self.buffer[self.index:self.index + n] - self.previous = self.index - self.index += n - return result + return self._return_index(n, result) def get(self): try: @@ -105,9 +101,12 @@ class StringView: except IndexError: result = None + return self._return_index(1, result) + + def _return_index(self, arg0, arg1): self.previous = self.index - self.index += 1 - return result + self.index += arg0 + return arg1 def get_word(self): pos = 0 diff --git a/discord/ext/tasks/__init__.py b/discord/ext/tasks/__init__.py index 5b78f10e..9518390e 100644 --- a/discord/ext/tasks/__init__.py +++ b/discord/ext/tasks/__init__.py @@ -46,7 +46,9 @@ import traceback from collections.abc import Sequence from discord.backoff import ExponentialBackoff -from discord.utils import MISSING +from discord.utils import MISSING, raise_expected_coro + + __all__ = ( 'loop', @@ -488,11 +490,7 @@ class Loop(Generic[LF]): The function was not a coroutine. """ - if not inspect.iscoroutinefunction(coro): - raise TypeError(f'Expected coroutine function, received {coro.__class__.__name__!r}.') - - self._before_loop = coro - return coro + return raise_expected_coro(coro, f'Expected coroutine function, received {coro.__class__.__name__!r}.') def after_loop(self, coro: FT) -> FT: """A decorator that register a coroutine to be called after the loop finished running. @@ -516,11 +514,7 @@ class Loop(Generic[LF]): The function was not a coroutine. """ - if not inspect.iscoroutinefunction(coro): - raise TypeError(f'Expected coroutine function, received {coro.__class__.__name__!r}.') - - self._after_loop = coro - return coro + return raise_expected_coro(coro, f'Expected coroutine function, received {coro.__class__.__name__!r}.') def error(self, coro: ET) -> ET: """A decorator that registers a coroutine to be called if the task encounters an unhandled exception. @@ -542,11 +536,7 @@ class Loop(Generic[LF]): TypeError The function was not a coroutine. """ - if not inspect.iscoroutinefunction(coro): - raise TypeError(f'Expected coroutine function, received {coro.__class__.__name__!r}.') - - self._error = coro # type: ignore - return coro + return raise_expected_coro(coro, f'Expected coroutine function, received {coro.__class__.__name__!r}.') def _get_next_sleep_time(self) -> datetime.datetime: if self._sleep is not MISSING: @@ -614,8 +604,7 @@ class Loop(Generic[LF]): ) ret.append(t if t.tzinfo is not None else t.replace(tzinfo=utc)) - ret = sorted(set(ret)) # de-dupe and sort times - return ret + return sorted(set(ret)) def change_interval( self, diff --git a/discord/player.py b/discord/player.py index 8098d3e3..79579c8d 100644 --- a/discord/player.py +++ b/discord/player.py @@ -21,6 +21,7 @@ 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 import threading @@ -63,10 +64,7 @@ __all__ = ( CREATE_NO_WINDOW: int -if sys.platform != 'win32': - CREATE_NO_WINDOW = 0 -else: - CREATE_NO_WINDOW = 0x08000000 +CREATE_NO_WINDOW = 0 if sys.platform != 'win32' else 0x08000000 class AudioSource: """Represents an audio stream. @@ -526,7 +524,12 @@ class FFmpegOpusAudio(FFmpegAudio): @staticmethod def _probe_codec_native(source, executable: str = 'ffmpeg') -> Tuple[Optional[str], Optional[int]]: - exe = executable[:2] + 'probe' if executable in ('ffmpeg', 'avconv') else executable + exe = ( + executable[:2] + 'probe' + if executable in {'ffmpeg', 'avconv'} + else executable + ) + args = [exe, '-v', 'quiet', '-print_format', 'json', '-show_streams', '-select_streams', 'a:0', source] output = subprocess.check_output(args, timeout=20) codec = bitrate = None diff --git a/discord/ui/button.py b/discord/ui/button.py index fedeac68..0b16e87e 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -185,16 +185,16 @@ class Button(Item[V]): @emoji.setter def emoji(self, value: Optional[Union[str, Emoji, PartialEmoji]]): # type: ignore - if value is not None: - if isinstance(value, str): - self._underlying.emoji = PartialEmoji.from_str(value) - elif isinstance(value, _EmojiTag): - self._underlying.emoji = value._to_partial() - else: - raise TypeError(f'expected str, Emoji, or PartialEmoji, received {value.__class__} instead') - else: + if value is None: self._underlying.emoji = None + elif isinstance(value, str): + self._underlying.emoji = PartialEmoji.from_str(value) + elif isinstance(value, _EmojiTag): + self._underlying.emoji = value._to_partial() + else: + raise TypeError(f'expected str, Emoji, or PartialEmoji, received {value.__class__} instead') + @classmethod def from_component(cls: Type[B], button: ButtonComponent) -> B: return cls( diff --git a/discord/utils.py b/discord/utils.py index 4360b77a..cad99da6 100644 --- a/discord/utils.py +++ b/discord/utils.py @@ -499,14 +499,14 @@ else: def _parse_ratelimit_header(request: Any, *, use_clock: bool = False) -> float: reset_after: Optional[str] = request.headers.get('X-Ratelimit-Reset-After') - if use_clock or not reset_after: - utc = datetime.timezone.utc - now = datetime.datetime.now(utc) - reset = datetime.datetime.fromtimestamp(float(request.headers['X-Ratelimit-Reset']), utc) - return (reset - now).total_seconds() - else: + if not use_clock and reset_after: return float(reset_after) + utc = datetime.timezone.utc + now = datetime.datetime.now(utc) + reset = datetime.datetime.fromtimestamp(float(request.headers['X-Ratelimit-Reset']), utc) + return (reset - now).total_seconds() + async def maybe_coroutine(f, *args, **kwargs): value = f(*args, **kwargs) @@ -659,11 +659,10 @@ def resolve_invite(invite: Union[Invite, str]) -> str: if isinstance(invite, Invite): return invite.code - else: - rx = r'(?:https?\:\/\/)?discord(?:\.gg|(?:app)?\.com\/invite)\/(.+)' - m = re.match(rx, invite) - if m: - return m.group(1) + rx = r'(?:https?\:\/\/)?discord(?:\.gg|(?:app)?\.com\/invite)\/(.+)' + m = re.match(rx, invite) + if m: + return m.group(1) return invite @@ -687,11 +686,10 @@ def resolve_template(code: Union[Template, str]) -> str: if isinstance(code, Template): return code.code - else: - rx = r'(?:https?\:\/\/)?discord(?:\.new|(?:app)?\.com\/template)\/(.+)' - m = re.match(rx, code) - if m: - return m.group(1) + rx = r'(?:https?\:\/\/)?discord(?:\.new|(?:app)?\.com\/template)\/(.+)' + m = re.match(rx, code) + if m: + return m.group(1) return code @@ -1017,3 +1015,9 @@ def format_dt(dt: datetime.datetime, /, style: Optional[TimestampStyle] = None) if style is None: return f'' return f'' + + +def raise_expected_coro(coro, error: str)-> TypeError: + if not asyncio.iscoroutinefunction(coro): + raise TypeError(error) + return coro From 0f6db99c597a629e739f057ec838827c6d51ef12 Mon Sep 17 00:00:00 2001 From: NightSlasher35 <49624805+NightSlasher35@users.noreply.github.com> Date: Thu, 2 Sep 2021 20:34:41 +0100 Subject: [PATCH 22/30] Merge pull request #22 * add nitro booster color * Update discord/colour.py --- discord/colour.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/discord/colour.py b/discord/colour.py index 927addc1..43ad6c6f 100644 --- a/discord/colour.py +++ b/discord/colour.py @@ -251,6 +251,13 @@ class Colour: def red(cls: Type[CT]) -> CT: """A factory method that returns a :class:`Colour` with a value of ``0xe74c3c``.""" return cls(0xe74c3c) + + @classmethod + def nitro_booster(cls): + """A factory method that returns a :class:`Colour` with a value of ``0xf47fff``. + + .. versionadded:: 2.0""" + return cls(0xf47fff) @classmethod def dark_red(cls: Type[CT]) -> CT: From f37be7961a05e76d0cda3fc76c6bc79bdcc3c52d Mon Sep 17 00:00:00 2001 From: Ahmad Ansori Palembani <46041660+null2264@users.noreply.github.com> Date: Fri, 3 Sep 2021 02:46:56 +0700 Subject: [PATCH 23/30] Merge pull request #41 * Fixed `TypeError` * Handles `EmptyEmbed` inside setter instead of set_ * Remove return and setter docstring --- discord/embeds.py | 38 +++++++++++++++++--------------------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/discord/embeds.py b/discord/embeds.py index 25f05aef..41f2be40 100644 --- a/discord/embeds.py +++ b/discord/embeds.py @@ -404,10 +404,13 @@ class Embed: return EmbedProxy(getattr(self, '_image', {})) # type: ignore @image.setter - def image(self: E, *, url: Any): - self._image = { - 'url': str(url), - } + def image(self: E, url: Any): + if url is EmptyEmbed: + del self._image + else: + self._image = { + 'url': str(url), + } @image.deleter def image(self: E): @@ -431,10 +434,7 @@ class Embed: The source URL for the image. Only HTTP(S) is supported. """ - if url is EmptyEmbed: - del self.image - else: - self.image = url + self.image = url return self @@ -454,15 +454,13 @@ class Embed: return EmbedProxy(getattr(self, '_thumbnail', {})) # type: ignore @thumbnail.setter - def thumbnail(self: E, *, url: Any): - """Sets the thumbnail for the embed content. - """ - - self._thumbnail = { - 'url': str(url), - } - - return + def thumbnail(self: E, url: Any): + if url is EmptyEmbed: + del self._thumbnail + else: + self._thumbnail = { + 'url': str(url), + } @thumbnail.deleter def thumbnail(self): @@ -485,10 +483,8 @@ class Embed: url: :class:`str` The source URL for the thumbnail. Only HTTP(S) is supported. """ - if url is EmptyEmbed: - del self.thumbnail - else: - self.thumbnail = url + + self.thumbnail = url return self From 152b61aabb33bb13dae54a3f63e4e97aae81aa6e Mon Sep 17 00:00:00 2001 From: IAmTomahawkx Date: Thu, 2 Sep 2021 12:49:38 -0700 Subject: [PATCH 24/30] fix recursionerror caused by a Pull Request --- discord/embeds.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/embeds.py b/discord/embeds.py index 41f2be40..52d71ef4 100644 --- a/discord/embeds.py +++ b/discord/embeds.py @@ -465,7 +465,7 @@ class Embed: @thumbnail.deleter def thumbnail(self): try: - del self.thumbnail + del self._thumbnail except AttributeError: pass From 4055bafaa5b35416f568254b89402d7bb218af01 Mon Sep 17 00:00:00 2001 From: Astrea Date: Thu, 2 Sep 2021 16:34:39 -0400 Subject: [PATCH 25/30] Merge pull request #47 * Added `on_raw_typing` event --- discord/raw_models.py | 38 +++++++++++++++++++++++++++++++++- discord/state.py | 41 ++++++++++++++++++++++--------------- discord/types/raw_models.py | 10 +++++++++ docs/api.rst | 19 +++++++++++++++++ 4 files changed, 91 insertions(+), 17 deletions(-) diff --git a/discord/raw_models.py b/discord/raw_models.py index cda754d1..3c9360ba 100644 --- a/discord/raw_models.py +++ b/discord/raw_models.py @@ -24,6 +24,7 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations +import datetime from typing import TYPE_CHECKING, Optional, Set, List if TYPE_CHECKING: @@ -34,7 +35,8 @@ if TYPE_CHECKING: MessageUpdateEvent, ReactionClearEvent, ReactionClearEmojiEvent, - IntegrationDeleteEvent + IntegrationDeleteEvent, + TypingEvent ) from .message import Message from .partial_emoji import PartialEmoji @@ -49,6 +51,7 @@ __all__ = ( 'RawReactionClearEvent', 'RawReactionClearEmojiEvent', 'RawIntegrationDeleteEvent', + 'RawTypingEvent' ) @@ -276,3 +279,36 @@ class RawIntegrationDeleteEvent(_RawReprMixin): self.application_id: Optional[int] = int(data['application_id']) except KeyError: self.application_id: Optional[int] = None + + +class RawTypingEvent(_RawReprMixin): + """Represents the payload for a :func:`on_raw_typing` event. + + .. versionadded:: 2.0 + + Attributes + ----------- + channel_id: :class:`int` + The channel ID where the typing originated from. + user_id: :class:`int` + The ID of the user that started typing. + when: :class:`datetime.datetime` + When the typing started as an aware datetime in UTC. + guild_id: Optional[:class:`int`] + The guild ID where the typing originated from, if applicable. + member: Optional[:class:`Member`] + The member who started typing. Only available if the member started typing in a guild. + """ + + __slots__ = ("channel_id", "user_id", "when", "guild_id", "member") + + def __init__(self, data: TypingEvent) -> None: + self.channel_id: int = int(data['channel_id']) + self.user_id: int = int(data['user_id']) + self.when: datetime.datetime = datetime.datetime.fromtimestamp(data.get('timestamp'), tz=datetime.timezone.utc) + self.member: Optional[Member] = None + + try: + self.guild_id: Optional[int] = int(data['guild_id']) + except KeyError: + self.guild_id: Optional[int] = None \ No newline at end of file diff --git a/discord/state.py b/discord/state.py index 0a9feac1..09777008 100644 --- a/discord/state.py +++ b/discord/state.py @@ -1327,28 +1327,37 @@ class ConnectionState: asyncio.create_task(logging_coroutine(coro, info='Voice Protocol voice server update handler')) def parse_typing_start(self, data) -> None: + raw = RawTypingEvent(data) + + member_data = data.get('member') + if member_data: + guild = self._get_guild(raw.guild_id) + if guild is not None: + raw.member = Member(data=member_data, guild=guild, state=self) + else: + raw.member = None + else: + raw.member = None + self.dispatch('raw_typing', raw) + channel, guild = self._get_guild_channel(data) if channel is not None: - member = None - user_id = utils._get_as_snowflake(data, 'user_id') - if isinstance(channel, DMChannel): - member = channel.recipient + user = raw.member or self._get_typing_user(channel, raw.user_id) - elif isinstance(channel, (Thread, TextChannel)) and guild is not None: - # user_id won't be None - member = guild.get_member(user_id) # type: ignore + if user is not None: + self.dispatch('typing', channel, user, raw.when) - if member is None: - member_data = data.get('member') - if member_data: - member = Member(data=member_data, state=self, guild=guild) + def _get_typing_user(self, channel: Optional[MessageableChannel], user_id: int) -> Optional[Union[User, Member]]: + if isinstance(channel, DMChannel): + return channel.recipient - elif isinstance(channel, GroupChannel): - member = utils.find(lambda x: x.id == user_id, channel.recipients) + elif isinstance(channel, (Thread, TextChannel)) and channel.guild is not None: + return channel.guild.get_member(user_id) # type: ignore - if member is not None: - timestamp = datetime.datetime.fromtimestamp(data.get('timestamp'), tz=datetime.timezone.utc) - self.dispatch('typing', channel, member, timestamp) + elif isinstance(channel, GroupChannel): + return utils.find(lambda x: x.id == user_id, channel.recipients) + + return self.get_user(user_id) def _get_reaction_user(self, channel: MessageableChannel, user_id: int) -> Optional[Union[User, Member]]: if isinstance(channel, TextChannel): diff --git a/discord/types/raw_models.py b/discord/types/raw_models.py index 3c45b299..2d779e51 100644 --- a/discord/types/raw_models.py +++ b/discord/types/raw_models.py @@ -85,3 +85,13 @@ class _IntegrationDeleteEventOptional(TypedDict, total=False): class IntegrationDeleteEvent(_IntegrationDeleteEventOptional): id: Snowflake guild_id: Snowflake + + +class _TypingEventOptional(TypedDict, total=False): + guild_id: Snowflake + member: Member + +class TypingEvent(_TypingEventOptional): + channel_id: Snowflake + user_id: Snowflake + timestamp: int \ No newline at end of file diff --git a/docs/api.rst b/docs/api.rst index 0a9ba5cc..5fc56af1 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -369,6 +369,17 @@ to handle it, which defaults to print a traceback and ignoring the exception. :param when: When the typing started as an aware datetime in UTC. :type when: :class:`datetime.datetime` +.. function:: on_raw_typing(payload) + + Called when someone begins typing a message. Unlike :func:`on_typing`, this is + called regardless if the user can be found or not. This most often happens + when a user types in DMs. + + This requires :attr:`Intents.typing` to be enabled. + + :param payload: The raw typing payload. + :type payload: :class:`RawTypingEvent` + .. function:: on_message(message) Called when a :class:`Message` is created and sent. @@ -3846,6 +3857,14 @@ GuildSticker .. autoclass:: GuildSticker() :members: +RawTypingEvent +~~~~~~~~~~~~~~~~~~~~~~~ + +.. attributetable:: RawTypingEvent + +.. autoclass:: RawTypingEvent() + :members: + RawMessageDeleteEvent ~~~~~~~~~~~~~~~~~~~~~~~ From 47e42d164839a9bfff76091fe9604399875a8e01 Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 2 Sep 2021 22:40:11 +0200 Subject: [PATCH 26/30] Merge pull request #42 * implement WelcomeScreen * copy over the kwargs issue. * readable variable names * modernise code * modernise pt2 * Update discord/welcome_screen.py * make pylance not cry from my onions * type http.py * remove extraneous import --- discord/__init__.py | 1 + discord/guild.py | 76 ++++++++++++++ discord/http.py | 15 +++ discord/welcome_screen.py | 216 ++++++++++++++++++++++++++++++++++++++ docs/api.rst | 16 +++ 5 files changed, 324 insertions(+) create mode 100644 discord/welcome_screen.py diff --git a/discord/__init__.py b/discord/__init__.py index 1e74cf91..288da41b 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -40,6 +40,7 @@ from .colour import * from .integrations import * from .invite import * from .template import * +from .welcome_screen import * from .widget import * from .object import * from .reaction import * diff --git a/discord/guild.py b/discord/guild.py index c9132f5a..cb53f44c 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -76,6 +76,7 @@ from .stage_instance import StageInstance from .threads import Thread, ThreadMember from .sticker import GuildSticker from .file import File +from .welcome_screen import WelcomeScreen, WelcomeChannel __all__ = ( @@ -2604,6 +2605,81 @@ class Guild(Hashable): return roles + async def welcome_screen(self) -> WelcomeScreen: + """|coro| + + Returns the guild's welcome screen. + + The guild must have ``COMMUNITY`` in :attr:`~Guild.features`. + + You must have the :attr:`~Permissions.manage_guild` permission to use + this as well. + + .. versionadded:: 2.0 + + Raises + ------- + Forbidden + You do not have the proper permissions to get this. + HTTPException + Retrieving the welcome screen failed. + + Returns + -------- + :class:`WelcomeScreen` + The welcome screen. + """ + data = await self._state.http.get_welcome_screen(self.id) + return WelcomeScreen(data=data, guild=self) + + @overload + async def edit_welcome_screen( + self, + *, + description: Optional[str] = ..., + welcome_channels: Optional[List[WelcomeChannel]] = ..., + enabled: Optional[bool] = ..., + ) -> WelcomeScreen: + ... + + @overload + async def edit_welcome_screen(self) -> None: + ... + + async def edit_welcome_screen(self, **kwargs): + """|coro| + + A shorthand method of :attr:`WelcomeScreen.edit` without needing + to fetch the welcome screen beforehand. + + The guild must have ``COMMUNITY`` in :attr:`~Guild.features`. + + You must have the :attr:`~Permissions.manage_guild` permission to use + this as well. + + .. versionadded:: 2.0 + + Returns + -------- + :class:`WelcomeScreen` + The edited welcome screen. + """ + try: + welcome_channels = kwargs['welcome_channels'] + except KeyError: + pass + else: + welcome_channels_serialised = [] + for wc in welcome_channels: + if not isinstance(wc, WelcomeChannel): + raise InvalidArgument('welcome_channels parameter must be a list of WelcomeChannel') + welcome_channels_serialised.append(wc.to_dict()) + kwargs['welcome_channels'] = welcome_channels_serialised + + if kwargs: + data = await self._state.http.edit_welcome_screen(self.id, kwargs) + return WelcomeScreen(data=data, guild=self) + async def kick(self, user: Snowflake, *, reason: Optional[str] = None) -> None: """|coro| diff --git a/discord/http.py b/discord/http.py index 7a4c2adc..4f86fc87 100644 --- a/discord/http.py +++ b/discord/http.py @@ -84,6 +84,7 @@ if TYPE_CHECKING: threads, voice, sticker, + welcome_screen, ) from .types.snowflake import Snowflake, SnowflakeList @@ -1116,6 +1117,20 @@ class HTTPClient: payload['icon'] = icon return self.request(Route('POST', '/guilds/templates/{code}', code=code), json=payload) + def get_welcome_screen(self, guild_id: Snowflake) -> Response[welcome_screen.WelcomeScreen]: + return self.request(Route('GET', '/guilds/{guild_id}/welcome-screen', guild_id=guild_id)) + + def edit_welcome_screen(self, guild_id: Snowflake, payload: Any) -> Response[welcome_screen.WelcomeScreen]: + valid_keys = ( + 'description', + 'welcome_channels', + 'enabled', + ) + payload = { + k: v for k, v in payload.items() if k in valid_keys + } + return self.request(Route('PATCH', '/guilds/{guild_id}/welcome-screen', guild_id=guild_id), json=payload) + def get_bans(self, guild_id: Snowflake) -> Response[List[guild.Ban]]: return self.request(Route('GET', '/guilds/{guild_id}/bans', guild_id=guild_id)) diff --git a/discord/welcome_screen.py b/discord/welcome_screen.py new file mode 100644 index 00000000..de8c3c27 --- /dev/null +++ b/discord/welcome_screen.py @@ -0,0 +1,216 @@ +# -*- 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 Dict, List, Optional, TYPE_CHECKING, Union, overload +from .utils import _get_as_snowflake, get +from .errors import InvalidArgument +from .partial_emoji import _EmojiTag + +__all__ = ( + 'WelcomeChannel', + 'WelcomeScreen', +) + +if TYPE_CHECKING: + from .types.welcome_screen import ( + WelcomeScreen as WelcomeScreenPayload, + WelcomeScreenChannel as WelcomeScreenChannelPayload, + ) + from .abc import Snowflake + from .guild import Guild + from .partial_emoji import PartialEmoji + from .emoji import Emoji + + +class WelcomeChannel: + """Represents a :class:`WelcomeScreen` welcome channel. + + .. versionadded:: 2.0 + + Attributes + ----------- + channel: :class:`abc.Snowflake` + The guild channel that is being referenced. + description: :class:`str` + The description shown of the channel. + emoji: Optional[:class:`PartialEmoji`, :class:`Emoji`, :class:`str`] + The emoji used beside the channel description. + """ + + def __init__(self, *, channel: Snowflake, description: str, emoji: Union[PartialEmoji, Emoji, str] = None): + self.channel = channel + self.description = description + self.emoji = emoji + + def __repr__(self) -> str: + return f'' + + @classmethod + def _from_dict(cls, *, data: WelcomeScreenChannelPayload, guild: Guild) -> WelcomeChannel: + channel_id = _get_as_snowflake(data, 'channel_id') + channel = guild.get_channel(channel_id) + description = data['description'] + _emoji_id = _get_as_snowflake(data, 'emoji_id') + _emoji_name = data['emoji_name'] + + if _emoji_id: + # custom + emoji = get(guild.emojis, id=_emoji_id) + else: + # unicode or None + emoji = _emoji_name + + return cls(channel=channel, description=description, emoji=emoji) # type: ignore + + def to_dict(self) -> WelcomeScreenChannelPayload: + ret: WelcomeScreenChannelPayload = { + 'channel_id': self.channel.id, + 'description': self.description, + 'emoji_id': None, + 'emoji_name': None, + } + + if isinstance(self.emoji, _EmojiTag): + ret['emoji_id'] = self.emoji.id # type: ignore + ret['emoji_name'] = self.emoji.name # type: ignore + else: + # unicode or None + ret['emoji_name'] = self.emoji + + return ret + + +class WelcomeScreen: + """Represents a :class:`Guild` welcome screen. + + .. versionadded:: 2.0 + + Attributes + ----------- + description: :class:`str` + The description shown on the welcome screen. + welcome_channels: List[:class:`WelcomeChannel`] + The channels shown on the welcome screen. + """ + + def __init__(self, *, data: WelcomeScreenPayload, guild: Guild): + self._state = guild._state + self._guild = guild + self._store(data) + + def _store(self, data: WelcomeScreenPayload) -> None: + self.description = data['description'] + welcome_channels = data.get('welcome_channels', []) + self.welcome_channels = [WelcomeChannel._from_dict(data=wc, guild=self._guild) for wc in welcome_channels] + + def __repr__(self) -> str: + return f'' + + @property + def enabled(self) -> bool: + """:class:`bool`: Whether the welcome screen is displayed. + + This is equivalent to checking if ``WELCOME_SCREEN_ENABLED`` + is present in :attr:`Guild.features`. + """ + return 'WELCOME_SCREEN_ENABLED' in self._guild.features + + @overload + async def edit( + self, + *, + description: Optional[str] = ..., + welcome_channels: Optional[List[WelcomeChannel]] = ..., + enabled: Optional[bool] = ..., + ) -> None: + ... + + @overload + async def edit(self) -> None: + ... + + async def edit(self, **kwargs): + """|coro| + + Edit the welcome screen. + + You must have the :attr:`~Permissions.manage_guild` permission in the + guild to do this. + + Usage: :: + + rules_channel = guild.get_channel(12345678) + announcements_channel = guild.get_channel(87654321) + + custom_emoji = utils.get(guild.emojis, name='loudspeaker') + + await welcome_screen.edit( + description='This is a very cool community server!', + welcome_channels=[ + WelcomeChannel(channel=rules_channel, description='Read the rules!', emoji='👨‍🏫'), + WelcomeChannel(channel=announcements_channel, description='Watch out for announcements!', emoji=custom_emoji), + ] + ) + + .. note:: + + Welcome channels can only accept custom emojis if :attr:`~Guild.premium_tier` is level 2 or above. + + Parameters + ------------ + description: Optional[:class:`str`] + The template's description. + welcome_channels: Optional[List[:class:`WelcomeChannel`]] + The welcome channels, in their respective order. + enabled: Optional[:class:`bool`] + Whether the welcome screen should be displayed. + + Raises + ------- + HTTPException + Editing the welcome screen failed failed. + Forbidden + You don't have permissions to edit the welcome screen. + NotFound + This welcome screen does not exist. + """ + try: + welcome_channels = kwargs['welcome_channels'] + except KeyError: + pass + else: + welcome_channels_serialised = [] + for wc in welcome_channels: + if not isinstance(wc, WelcomeChannel): + raise InvalidArgument('welcome_channels parameter must be a list of WelcomeChannel') + welcome_channels_serialised.append(wc.to_dict()) + kwargs['welcome_channels'] = welcome_channels_serialised + + if kwargs: + data = await self._state.http.edit_welcome_screen(self._guild.id, kwargs) + self._store(data) diff --git a/docs/api.rst b/docs/api.rst index 5fc56af1..069dd1d4 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -3792,6 +3792,22 @@ Template .. autoclass:: Template() :members: +WelcomeScreen +~~~~~~~~~~~~~~~ + +.. attributetable:: WelcomeScreen + +.. autoclass:: WelcomeScreen() + :members: + +WelcomeChannel +~~~~~~~~~~~~~~~ + +.. attributetable:: WelcomeChannel + +.. autoclass:: WelcomeChannel() + :members: + WidgetChannel ~~~~~~~~~~~~~~~ From 33470ff1960285b5e5d5e0b277ee76a1e71dc1e2 Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 2 Sep 2021 22:41:26 +0200 Subject: [PATCH 27/30] Merge pull request #31 * Add bots and humans to TextChannel --- discord/channel.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/discord/channel.py b/discord/channel.py index dc3967c4..f467d1cc 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -228,6 +228,16 @@ class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable): """List[:class:`Member`]: Returns all members that can see this channel.""" return [m for m in self.guild.members if self.permissions_for(m).read_messages] + @property + def bots(self) -> List[Member]: + """List[:class:`Member`]: Returns all bots that can see this channel.""" + return [m for m in self.guild.members if m.bot and self.permissions_for(m).read_messages] + + @property + def humans(self) -> List[Member]: + """List[:class:`Member`]: Returns all human members that can see this channel.""" + return [m for m in self.guild.members if not m.bot and self.permissions_for(m).read_messages] + @property def threads(self) -> List[Thread]: """List[:class:`Thread`]: Returns all the threads that you can see. From 1032728311ef71f26d4fc8aa5e3ba942d9ae775a Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 2 Sep 2021 22:43:19 +0200 Subject: [PATCH 28/30] Merge pull request #32 * Add get/fetch_member to ThreadMember objects --- discord/threads.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/discord/threads.py b/discord/threads.py index 6f8ffb00..a06ef219 100644 --- a/discord/threads.py +++ b/discord/threads.py @@ -808,3 +808,39 @@ class ThreadMember(Hashable): def thread(self) -> Thread: """:class:`Thread`: The thread this member belongs to.""" return self.parent + + async def fetch_member(self) -> Member: + """|coro| + + Retrieves a :class:`Member` from the ThreadMember object. + + .. note:: + + This method is an API call. If you have :attr:`Intents.members` and member cache enabled, consider :meth:`get_member` instead. + + Raises + ------- + Forbidden + You do not have access to the guild. + HTTPException + Fetching the member failed. + + Returns + -------- + :class:`Member` + The member. + """ + + return await self.thread.guild.fetch_member(self.id) + + def get_member(self) -> Optional[Member]: + """ + Get the :class:`Member` from cache for the ThreadMember object. + + Returns + -------- + Optional[:class:`Member`] + The member or ``None`` if not found. + """ + + return await self.thread.guild.get_member(self.id) From 3ffe1348956ebc1e2512439b532fdb6516c267c6 Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 2 Sep 2021 22:50:19 +0200 Subject: [PATCH 29/30] Merge pull request #44 * Typehint gateway.py * Add relevant typehints to gateway.py to voice_client.py * Change EventListener to subclass NamedTuple * Add return type for DiscordWebSocket.wait_for * Correct deque typehint * Remove unnecessary typehints for literals * Use type aliases * Merge branch '2.0' into pr7422 --- discord/gateway.py | 257 ++++++++++++++++++++++++---------------- discord/voice_client.py | 3 + 2 files changed, 161 insertions(+), 99 deletions(-) diff --git a/discord/gateway.py b/discord/gateway.py index aa0c6ba0..fbbc3c5e 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -22,8 +22,12 @@ 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 TYPE_CHECKING, TypedDict, Any, Optional, List, TypeVar, Type, Dict, Callable, Coroutine, NamedTuple, Deque + import asyncio -from collections import namedtuple, deque +from collections import deque import concurrent.futures import logging import struct @@ -38,9 +42,25 @@ import aiohttp from . import utils from .activity import BaseActivity from .enums import SpeakingState -from .errors import ConnectionClosed, InvalidArgument +from .errors import ConnectionClosed, InvalidArgument + +if TYPE_CHECKING: + from .client import Client + from .state import ConnectionState + from .voice_client import VoiceClient + + T = TypeVar('T') + DWS = TypeVar('DWS', bound='DiscordWebSocket') + DVWS = TypeVar('DVWS', bound='DiscordVoiceWebSocket') + + Coro = Callable[..., Coroutine[Any, Any, Any]] + Predicate = Callable[[Dict[str, Any]], bool] + DataCallable = Callable[[Dict[str, Any]], T] + Result = Optional[DataCallable[Any]] + + +_log: logging.Logger = logging.getLogger(__name__) -_log = logging.getLogger(__name__) __all__ = ( 'DiscordWebSocket', @@ -50,36 +70,49 @@ __all__ = ( 'ReconnectWebSocket', ) + +class Heartbeat(TypedDict): + op: int + d: int + + class ReconnectWebSocket(Exception): """Signals to safely reconnect the websocket.""" - def __init__(self, shard_id, *, resume=True): - self.shard_id = shard_id - self.resume = resume + def __init__(self, shard_id: Optional[int], *, resume: bool = True) -> None: + self.shard_id: Optional[int] = shard_id + self.resume: bool = resume self.op = 'RESUME' if resume else 'IDENTIFY' + class WebSocketClosure(Exception): """An exception to make up for the fact that aiohttp doesn't signal closure.""" pass -EventListener = namedtuple('EventListener', 'predicate event result future') + +class EventListener(NamedTuple): + predicate: Predicate + event: str + result: Result + future: asyncio.Future + class GatewayRatelimiter: - def __init__(self, count=110, per=60.0): + def __init__(self, count: int = 110, per: float = 60.0) -> None: # The default is 110 to give room for at least 10 heartbeats per minute - self.max = count - self.remaining = count - self.window = 0.0 - self.per = per - self.lock = asyncio.Lock() - self.shard_id = None + self.max: int = count + self.remaining: int = count + self.window: float = 0.0 + self.per: float = per + self.lock: asyncio.Lock = asyncio.Lock() + self.shard_id: Optional[int] = None - def is_ratelimited(self): + def is_ratelimited(self) -> bool: current = time.time() if current > self.window + self.per: return False return self.remaining == 0 - def get_delay(self): + def get_delay(self) -> float: current = time.time() if current > self.window + self.per: @@ -97,7 +130,7 @@ class GatewayRatelimiter: return 0.0 - async def block(self): + async def block(self) -> None: async with self.lock: delta = self.get_delay() if delta: @@ -106,27 +139,27 @@ class GatewayRatelimiter: class KeepAliveHandler(threading.Thread): - def __init__(self, *args, **kwargs): - ws = kwargs.pop('ws', None) + def __init__(self, *args: Any, **kwargs: Any) -> None: + ws = kwargs.pop('ws') interval = kwargs.pop('interval', None) shard_id = kwargs.pop('shard_id', None) threading.Thread.__init__(self, *args, **kwargs) - self.ws = ws - self._main_thread_id = ws.thread_id - self.interval = interval - self.daemon = True - self.shard_id = shard_id - self.msg = 'Keeping shard ID %s websocket alive with sequence %s.' - self.block_msg = 'Shard ID %s heartbeat blocked for more than %s seconds.' - self.behind_msg = 'Can\'t keep up, shard ID %s websocket is %.1fs behind.' - self._stop_ev = threading.Event() - self._last_ack = time.perf_counter() - self._last_send = time.perf_counter() - self._last_recv = time.perf_counter() - self.latency = float('inf') - self.heartbeat_timeout = ws._max_heartbeat_timeout + self.ws: DiscordWebSocket = ws + self._main_thread_id: int = ws.thread_id + self.interval: Optional[float] = interval + self.daemon: bool = True + self.shard_id: Optional[int] = shard_id + self.msg: str = 'Keeping shard ID %s websocket alive with sequence %s.' + self.block_msg: str = 'Shard ID %s heartbeat blocked for more than %s seconds.' + self.behind_msg: str = 'Can\'t keep up, shard ID %s websocket is %.1fs behind.' + self._stop_ev: threading.Event = threading.Event() + self._last_ack: float = time.perf_counter() + self._last_send: float = time.perf_counter() + self._last_recv: float = time.perf_counter() + self.latency: float = float('inf') + self.heartbeat_timeout: float = ws._max_heartbeat_timeout - def run(self): + def run(self) -> None: while not self._stop_ev.wait(self.interval): if self._last_recv + self.heartbeat_timeout < time.perf_counter(): _log.warning("Shard ID %s has stopped responding to the gateway. Closing and restarting.", self.shard_id) @@ -168,19 +201,20 @@ class KeepAliveHandler(threading.Thread): else: self._last_send = time.perf_counter() - def get_payload(self): + def get_payload(self) -> Heartbeat: return { 'op': self.ws.HEARTBEAT, - 'd': self.ws.sequence + # the websocket's sequence won't be None here + 'd': self.ws.sequence # type: ignore } - def stop(self): + def stop(self) -> None: self._stop_ev.set() - def tick(self): + def tick(self) -> None: self._last_recv = time.perf_counter() - def ack(self): + def ack(self) -> None: ack_time = time.perf_counter() self._last_ack = ack_time self.latency = ack_time - self._last_send @@ -188,30 +222,32 @@ class KeepAliveHandler(threading.Thread): _log.warning(self.behind_msg, self.shard_id, self.latency) class VoiceKeepAliveHandler(KeepAliveHandler): - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) - self.recent_ack_latencies = deque(maxlen=20) + self.recent_ack_latencies: Deque[float] = deque(maxlen=20) self.msg = 'Keeping shard ID %s voice websocket alive with timestamp %s.' self.block_msg = 'Shard ID %s voice heartbeat blocked for more than %s seconds' self.behind_msg = 'High socket latency, shard ID %s heartbeat is %.1fs behind' - def get_payload(self): + def get_payload(self) -> Heartbeat: return { 'op': self.ws.HEARTBEAT, 'd': int(time.time() * 1000) } - def ack(self): + def ack(self) -> None: ack_time = time.perf_counter() self._last_ack = ack_time self._last_recv = ack_time self.latency = ack_time - self._last_send self.recent_ack_latencies.append(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. @@ -266,41 +302,53 @@ class DiscordWebSocket: HEARTBEAT_ACK = 11 GUILD_SYNC = 12 - def __init__(self, socket, *, loop): - self.socket = socket - self.loop = loop + def __init__(self, socket: aiohttp.ClientWebSocketResponse, *, loop: asyncio.AbstractEventLoop) -> None: + self.socket: aiohttp.ClientWebSocketResponse = socket + self.loop: asyncio.AbstractEventLoop = loop # an empty dispatcher to prevent crashes self._dispatch = lambda *args: None # generic event listeners - self._dispatch_listeners = [] + self._dispatch_listeners: List[EventListener] = [] # the keep alive - self._keep_alive = None - self.thread_id = threading.get_ident() + self._keep_alive: Optional[KeepAliveHandler] = None + self.thread_id: int = threading.get_ident() # ws related stuff - self.session_id = None - self.sequence = None + self.session_id: Optional[str] = None + self.sequence: Optional[int] = None self._zlib = zlib.decompressobj() - self._buffer = bytearray() - self._close_code = None - self._rate_limiter = GatewayRatelimiter() + self._buffer: bytearray = bytearray() + self._close_code: Optional[int] = None + self._rate_limiter: GatewayRatelimiter = GatewayRatelimiter() + + # attributes that get set in from_client + self.token: str = utils.MISSING + self._connection: ConnectionState = utils.MISSING + self._discord_parsers: Dict[str, DataCallable[None]] = utils.MISSING + self.gateway: str = utils.MISSING + self.call_hooks: Coro = utils.MISSING + self._initial_identify: bool = utils.MISSING + self.shard_id: Optional[int] = utils.MISSING + self.shard_count: Optional[int] = utils.MISSING + self.session_id: Optional[str] = utils.MISSING + self._max_heartbeat_timeout: float = utils.MISSING @property - def open(self): + def open(self) -> bool: return not self.socket.closed - def is_ratelimited(self): + def is_ratelimited(self) -> bool: return self._rate_limiter.is_ratelimited() - def debug_log_receive(self, data, /): + def debug_log_receive(self, data, /) -> None: self._dispatch('socket_raw_receive', data) - def log_receive(self, _, /): + def log_receive(self, _, /) -> None: pass @classmethod - async def from_client(cls, client, *, initial=False, gateway=None, shard_id=None, session=None, sequence=None, resume=False): + async def from_client(cls: Type[DWS], client: Client, *, initial: bool = False, gateway: Optional[str] = None, shard_id: Optional[int] = None, session: Optional[str] = None, sequence: Optional[int] = None, resume: bool = False) -> DWS: """Creates a main websocket for Discord from a :class:`Client`. This is for internal use only. @@ -310,7 +358,9 @@ class DiscordWebSocket: ws = cls(socket, loop=client.loop) # dynamically add attributes needed - ws.token = client.http.token + + # the token won't be None here + ws.token = client.http.token # type: ignore ws._connection = client._connection ws._discord_parsers = client._connection.parsers ws._dispatch = client.dispatch @@ -342,7 +392,7 @@ class DiscordWebSocket: await ws.resume() return ws - def wait_for(self, event, predicate, result=None): + def wait_for(self, event: str, predicate: Predicate, result: Result = None) -> asyncio.Future: """Waits for a DISPATCH'd event that meets the predicate. Parameters @@ -367,7 +417,7 @@ class DiscordWebSocket: self._dispatch_listeners.append(entry) return future - async def identify(self): + async def identify(self) -> None: """Sends the IDENTIFY packet.""" payload = { 'op': self.IDENTIFY, @@ -405,7 +455,7 @@ class DiscordWebSocket: await self.send_as_json(payload) _log.info('Shard ID %s has sent the IDENTIFY payload.', self.shard_id) - async def resume(self): + async def resume(self) -> None: """Sends the RESUME packet.""" payload = { 'op': self.RESUME, @@ -419,7 +469,8 @@ class DiscordWebSocket: await self.send_as_json(payload) _log.info('Shard ID %s has sent the RESUME payload.', self.shard_id) - async def received_message(self, msg, /): + + async def received_message(self, msg, /) -> None: if type(msg) is bytes: self._buffer.extend(msg) @@ -537,16 +588,16 @@ class DiscordWebSocket: del self._dispatch_listeners[index] @property - def latency(self): + def latency(self) -> float: """:class:`float`: Measures latency between a HEARTBEAT and a HEARTBEAT_ACK in seconds.""" heartbeat = self._keep_alive return float('inf') if heartbeat is None else heartbeat.latency - def _can_handle_close(self): + def _can_handle_close(self) -> bool: code = self._close_code or self.socket.close_code return code not in (1000, 4004, 4010, 4011, 4012, 4013, 4014) - async def poll_event(self): + async def poll_event(self) -> None: """Polls for a DISPATCH event and handles the general gateway loop. Raises @@ -584,23 +635,23 @@ class DiscordWebSocket: _log.info('Websocket closed with %s, cannot reconnect.', code) raise ConnectionClosed(self.socket, shard_id=self.shard_id, code=code) from None - async def debug_send(self, data, /): + async def debug_send(self, data, /) -> None: await self._rate_limiter.block() self._dispatch('socket_raw_send', data) await self.socket.send_str(data) - async def send(self, data, /): + async def send(self, data, /) -> None: await self._rate_limiter.block() await self.socket.send_str(data) - async def send_as_json(self, data): + async def send_as_json(self, data) -> None: try: await self.send(utils._to_json(data)) except RuntimeError as exc: if not self._can_handle_close(): raise ConnectionClosed(self.socket, shard_id=self.shard_id) from exc - async def send_heartbeat(self, data): + async def send_heartbeat(self, data: Heartbeat) -> None: # This bypasses the rate limit handling code since it has a higher priority try: await self.socket.send_str(utils._to_json(data)) @@ -608,13 +659,13 @@ class DiscordWebSocket: if not self._can_handle_close(): raise ConnectionClosed(self.socket, shard_id=self.shard_id) from exc - async def change_presence(self, *, activity=None, status=None, since=0.0): + async def change_presence(self, *, activity: Optional[BaseActivity] = None, status: Optional[str] = None, since: float = 0.0) -> None: if activity is not None: if not isinstance(activity, BaseActivity): raise InvalidArgument('activity must derive from BaseActivity.') - activity = [activity.to_dict()] + activities = [activity.to_dict()] else: - activity = [] + activities = [] if status == 'idle': since = int(time.time() * 1000) @@ -622,7 +673,7 @@ class DiscordWebSocket: payload = { 'op': self.PRESENCE, 'd': { - 'activities': activity, + 'activities': activities, 'afk': False, 'since': since, 'status': status @@ -633,7 +684,7 @@ 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_chunks(self, guild_id: int, query: Optional[str] = None, *, limit: int, user_ids: Optional[List[int]] = None, presences: bool = False, nonce: Optional[int] = None) -> None: payload = { 'op': self.REQUEST_MEMBERS, 'd': { @@ -655,7 +706,7 @@ class DiscordWebSocket: 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: int, channel_id: int, self_mute: bool = False, self_deaf: bool = False) -> None: payload = { 'op': self.VOICE_STATE, 'd': { @@ -669,7 +720,7 @@ class DiscordWebSocket: _log.debug('Updating our voice state to %s.', payload) await self.send_as_json(payload) - async def close(self, code=4000): + async def close(self, code: int = 4000) -> None: if self._keep_alive: self._keep_alive.stop() self._keep_alive = None @@ -721,25 +772,31 @@ class DiscordVoiceWebSocket: CLIENT_CONNECT = 12 CLIENT_DISCONNECT = 13 - def __init__(self, socket, loop, *, hook=None): - self.ws = socket - self.loop = loop - self._keep_alive = None - self._close_code = None - self.secret_key = None + def __init__(self, socket: aiohttp.ClientWebSocketResponse, loop: asyncio.AbstractEventLoop, *, hook: Optional[Coro] = None) -> None: + self.ws: aiohttp.ClientWebSocketResponse = socket + self.loop: asyncio.AbstractEventLoop = loop + self._keep_alive: VoiceKeepAliveHandler = utils.MISSING + self._close_code: Optional[int] = None + self.secret_key: Optional[List[int]] = None + self.gateway: str = utils.MISSING + self._connection: VoiceClient = utils.MISSING + self._max_heartbeat_timeout: float = utils.MISSING + self.thread_id: int = utils.MISSING if hook: - self._hook = hook + # we want to redeclare self._hook + self._hook = hook # type: ignore - async def _hook(self, *args): + async def _hook(self, *args: Any) -> Any: pass - async def send_as_json(self, data): + + async def send_as_json(self, data) -> None: _log.debug('Sending voice websocket frame: %s.', data) await self.ws.send_str(utils._to_json(data)) send_heartbeat = send_as_json - async def resume(self): + async def resume(self) -> None: state = self._connection payload = { 'op': self.RESUME, @@ -765,7 +822,7 @@ class DiscordVoiceWebSocket: await self.send_as_json(payload) @classmethod - async def from_client(cls, client, *, resume=False, hook=None): + async def from_client(cls: Type[DVWS], client: VoiceClient, *, resume: bool = False, hook: Optional[Coro] = None) -> DVWS: """Creates a voice websocket for the :class:`VoiceClient`.""" gateway = 'wss://' + client.endpoint + '/?v=4' http = client._state.http @@ -783,7 +840,7 @@ class DiscordVoiceWebSocket: return ws - async def select_protocol(self, ip, port, mode): + async def select_protocol(self, ip, port, mode) -> None: payload = { 'op': self.SELECT_PROTOCOL, 'd': { @@ -798,7 +855,7 @@ class DiscordVoiceWebSocket: await self.send_as_json(payload) - async def client_connect(self): + async def client_connect(self) -> None: payload = { 'op': self.CLIENT_CONNECT, 'd': { @@ -808,7 +865,7 @@ class DiscordVoiceWebSocket: await self.send_as_json(payload) - async def speak(self, state=SpeakingState.voice): + async def speak(self, state=SpeakingState.voice) -> None: payload = { 'op': self.SPEAKING, 'd': { @@ -819,7 +876,8 @@ class DiscordVoiceWebSocket: await self.send_as_json(payload) - async def received_message(self, msg): + + async def received_message(self, msg) -> None: _log.debug('Voice websocket frame received: %s', msg) op = msg['op'] data = msg.get('d') @@ -840,7 +898,7 @@ class DiscordVoiceWebSocket: await self._hook(self, msg) - async def initial_connection(self, data): + async def initial_connection(self, data) -> None: state = self._connection state.ssrc = data['ssrc'] state.voice_port = data['port'] @@ -871,13 +929,13 @@ class DiscordVoiceWebSocket: _log.info('selected the voice protocol for use (%s)', mode) @property - def latency(self): + def latency(self) -> float: """:class:`float`: Latency between a HEARTBEAT and its HEARTBEAT_ACK in seconds.""" heartbeat = self._keep_alive return float('inf') if heartbeat is None else heartbeat.latency @property - def average_latency(self): + def average_latency(self) -> float: """:class:`list`: Average of last 20 HEARTBEAT latencies.""" heartbeat = self._keep_alive if heartbeat is None or not heartbeat.recent_ack_latencies: @@ -885,13 +943,14 @@ class DiscordVoiceWebSocket: return sum(heartbeat.recent_ack_latencies) / len(heartbeat.recent_ack_latencies) - async def load_secret_key(self, data): + + async def load_secret_key(self, data) -> None: _log.info('received secret key for voice connection') self.secret_key = self._connection.secret_key = data.get('secret_key') await self.speak() await self.speak(False) - async def poll_event(self): + async def poll_event(self) -> None: # This exception is handled up the chain msg = await asyncio.wait_for(self.ws.receive(), timeout=30.0) if msg.type is aiohttp.WSMsgType.TEXT: @@ -903,7 +962,7 @@ class DiscordVoiceWebSocket: _log.debug('Received %s', msg) raise ConnectionClosed(self.ws, shard_id=None, code=self._close_code) - async def close(self, code=1000): + async def close(self, code: int = 1000) -> None: if self._keep_alive is not None: self._keep_alive.stop() diff --git a/discord/voice_client.py b/discord/voice_client.py index d382a74d..123dd29b 100644 --- a/discord/voice_client.py +++ b/discord/voice_client.py @@ -255,6 +255,9 @@ class VoiceClient(VoiceProtocol): self.encoder: Encoder = MISSING self._lite_nonce: int = 0 self.ws: DiscordVoiceWebSocket = MISSING + self.ip: str = MISSING + self.port: Tuple[Any, ...] = MISSING + warn_nacl = not has_nacl supported_modes: Tuple[SupportedModes, ...] = ( From 14b3188bb897003666ea641370043e23ea0682dd Mon Sep 17 00:00:00 2001 From: Moksej <58531286+TheMoksej@users.noreply.github.com> Date: Sun, 5 Sep 2021 13:58:10 +0200 Subject: [PATCH 30/30] remove unnecessary await --- discord/threads.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/threads.py b/discord/threads.py index a06ef219..49bba831 100644 --- a/discord/threads.py +++ b/discord/threads.py @@ -843,4 +843,4 @@ class ThreadMember(Hashable): The member or ``None`` if not found. """ - return await self.thread.guild.get_member(self.id) + return self.thread.guild.get_member(self.id)