diff --git a/discord/__init__.py b/discord/__init__.py index 765719b68..2cf64c934 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -84,4 +84,10 @@ version_info: VersionInfo = VersionInfo(major=2, minor=5, micro=0, releaselevel= logging.getLogger(__name__).addHandler(logging.NullHandler()) +# This is a backwards compatibility hack and should be removed in v3 +# Essentially forcing the exception to have different base classes +# In the future, this should only inherit from ClientException +if len(MissingApplicationID.__bases__) == 1: + MissingApplicationID.__bases__ = (app_commands.AppCommandError, ClientException) + del logging, NamedTuple, Literal, VersionInfo diff --git a/discord/app_commands/errors.py b/discord/app_commands/errors.py index dc63f10e8..2efb4e5b0 100644 --- a/discord/app_commands/errors.py +++ b/discord/app_commands/errors.py @@ -27,7 +27,7 @@ from __future__ import annotations from typing import Any, TYPE_CHECKING, List, Optional, Sequence, Union from ..enums import AppCommandOptionType, AppCommandType, Locale -from ..errors import DiscordException, HTTPException, _flatten_error_dict +from ..errors import DiscordException, HTTPException, _flatten_error_dict, MissingApplicationID as MissingApplicationID from ..utils import _human_join __all__ = ( @@ -59,11 +59,6 @@ if TYPE_CHECKING: CommandTypes = Union[Command[Any, ..., Any], Group, ContextMenu] -APP_ID_NOT_FOUND = ( - 'Client does not have an application_id set. Either the function was called before on_ready ' - 'was called or application_id was not passed to the Client constructor.' -) - class AppCommandError(DiscordException): """The base exception type for all application command related errors. @@ -422,19 +417,6 @@ class CommandSignatureMismatch(AppCommandError): super().__init__(msg) -class MissingApplicationID(AppCommandError): - """An exception raised when the client does not have an application ID set. - An application ID is required for syncing application commands. - - This inherits from :exc:`~discord.app_commands.AppCommandError`. - - .. versionadded:: 2.0 - """ - - def __init__(self, message: Optional[str] = None): - super().__init__(message or APP_ID_NOT_FOUND) - - def _get_command_error( index: str, inner: Any, @@ -485,6 +467,10 @@ def _get_command_error( if key == 'options': for index, d in remaining.items(): _get_command_error(index, d, children, messages, indent=indent + 2) + elif key == '_errors': + errors = [x.get('message', '') for x in remaining] + + messages.extend(f'{indentation} {message}' for message in errors) else: if isinstance(remaining, dict): try: @@ -493,10 +479,9 @@ def _get_command_error( errors = _flatten_error_dict(remaining, key=key) else: errors = {key: ' '.join(x.get('message', '') for x in inner_errors)} - else: - errors = _flatten_error_dict(remaining, key=key) - messages.extend(f'{indentation} {k}: {v}' for k, v in errors.items()) + if isinstance(errors, dict): + messages.extend(f'{indentation} {k}: {v}' for k, v in errors.items()) class CommandSyncFailure(AppCommandError, HTTPException): diff --git a/discord/appinfo.py b/discord/appinfo.py index 074892d05..932f852c2 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -147,6 +147,10 @@ class AppInfo: The approximate count of the guilds the bot was added to. .. versionadded:: 2.4 + approximate_user_install_count: Optional[:class:`int`] + The approximate count of the user-level installations the bot has. + + .. versionadded:: 2.5 """ __slots__ = ( @@ -175,6 +179,7 @@ class AppInfo: 'interactions_endpoint_url', 'redirect_uris', 'approximate_guild_count', + 'approximate_user_install_count', ) def __init__(self, state: ConnectionState, data: AppInfoPayload): @@ -212,6 +217,7 @@ class AppInfo: self.interactions_endpoint_url: Optional[str] = data.get('interactions_endpoint_url') self.redirect_uris: List[str] = data.get('redirect_uris', []) self.approximate_guild_count: int = data.get('approximate_guild_count', 0) + self.approximate_user_install_count: Optional[int] = data.get('approximate_user_install_count') def __repr__(self) -> str: return ( diff --git a/discord/client.py b/discord/client.py index a91be7160..2ca8c2ae0 100644 --- a/discord/client.py +++ b/discord/client.py @@ -84,7 +84,7 @@ if TYPE_CHECKING: from typing_extensions import Self from .abc import Messageable, PrivateChannel, Snowflake, SnowflakeTime - from .app_commands import Command, ContextMenu, MissingApplicationID + from .app_commands import Command, ContextMenu from .automod import AutoModAction, AutoModRule from .channel import DMChannel, GroupChannel from .ext.commands import AutoShardedBot, Bot, Context, CommandError @@ -249,6 +249,11 @@ class Client: set to is ``30.0`` seconds. .. versionadded:: 2.0 + connector: Optional[:class:`aiohttp.BaseConnector`] + The aiohhtp connector to use for this client. This can be used to control underlying aiohttp + behavior, such as setting a dns resolver or sslcontext. + + .. versionadded:: 2.5 Attributes ----------- @@ -264,6 +269,7 @@ class Client: self.shard_id: Optional[int] = options.get('shard_id') self.shard_count: Optional[int] = options.get('shard_count') + connector: Optional[aiohttp.BaseConnector] = options.get('connector', None) proxy: Optional[str] = options.pop('proxy', None) proxy_auth: Optional[aiohttp.BasicAuth] = options.pop('proxy_auth', None) unsync_clock: bool = options.pop('assume_unsync_clock', True) @@ -271,6 +277,7 @@ class Client: max_ratelimit_timeout: Optional[float] = options.pop('max_ratelimit_timeout', None) self.http: HTTPClient = HTTPClient( self.loop, + connector, proxy=proxy, proxy_auth=proxy_auth, unsync_clock=unsync_clock, @@ -359,7 +366,13 @@ class Client: @property def emojis(self) -> Sequence[Emoji]: - """Sequence[:class:`.Emoji`]: The emojis that the connected client has.""" + """Sequence[:class:`.Emoji`]: The emojis that the connected client has. + + .. note:: + + This not include the emojis that are owned by the application. + Use :meth:`.fetch_application_emoji` to get those. + """ return self._connection.emojis @property @@ -623,6 +636,11 @@ class Client: if self._connection.application_id is None: self._connection.application_id = self._application.id + if self._application.interactions_endpoint_url is not None: + _log.warning( + 'Application has an interaction endpoint URL set, this means registered components and app commands will not be received by the library.' + ) + if not self._connection.application_flags: self._connection.application_flags = self._application.flags @@ -2919,6 +2937,33 @@ 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_premium_sticker_pack(self, sticker_pack_id: int, /) -> StickerPack: + """|coro| + + Retrieves a premium sticker pack with the specified ID. + + .. versionadded:: 2.5 + + Parameters + ---------- + sticker_pack_id: :class:`int` + The sticker pack's ID to fetch from. + + Raises + ------- + NotFound + A sticker pack with this ID does not exist. + HTTPException + Retrieving the sticker pack failed. + + Returns + ------- + :class:`.StickerPack` + The retrieved premium sticker pack. + """ + data = await self.http.get_sticker_pack(sticker_pack_id) + return StickerPack(state=self._connection, data=data) + async def create_dm(self, user: Snowflake) -> DMChannel: """|coro| @@ -3039,3 +3084,97 @@ class Client: .. versionadded:: 2.0 """ return self._connection.persistent_views + + async def create_application_emoji( + self, + *, + name: str, + image: bytes, + ) -> Emoji: + """|coro| + + Create an emoji for the current application. + + .. versionadded:: 2.5 + + Parameters + ---------- + name: :class:`str` + The emoji name. Must be at least 2 characters. + image: :class:`bytes` + The :term:`py:bytes-like object` representing the image data to use. + Only JPG, PNG and GIF images are supported. + + Raises + ------ + MissingApplicationID + The application ID could not be found. + HTTPException + Creating the emoji failed. + + Returns + ------- + :class:`.Emoji` + The emoji that was created. + """ + if self.application_id is None: + raise MissingApplicationID + + img = utils._bytes_to_base64_data(image) + data = await self.http.create_application_emoji(self.application_id, name, img) + return Emoji(guild=Object(0), state=self._connection, data=data) + + async def fetch_application_emoji(self, emoji_id: int, /) -> Emoji: + """|coro| + + Retrieves an emoji for the current application. + + .. versionadded:: 2.5 + + Parameters + ---------- + emoji_id: :class:`int` + The emoji ID to retrieve. + + Raises + ------ + MissingApplicationID + The application ID could not be found. + HTTPException + Retrieving the emoji failed. + + Returns + ------- + :class:`.Emoji` + The emoji requested. + """ + if self.application_id is None: + raise MissingApplicationID + + data = await self.http.get_application_emoji(self.application_id, emoji_id) + return Emoji(guild=Object(0), state=self._connection, data=data) + + async def fetch_application_emojis(self) -> List[Emoji]: + """|coro| + + Retrieves all emojis for the current application. + + .. versionadded:: 2.5 + + Raises + ------- + MissingApplicationID + The application ID could not be found. + HTTPException + Retrieving the emojis failed. + + Returns + ------- + List[:class:`.Emoji`] + The list of emojis for the current application. + """ + if self.application_id is None: + raise MissingApplicationID + + data = await self.http.get_application_emojis(self.application_id) + return [Emoji(guild=Object(0), state=self._connection, data=emoji) for emoji in data['items']] diff --git a/discord/embeds.py b/discord/embeds.py index 6a79fef71..258ef0dfd 100644 --- a/discord/embeds.py +++ b/discord/embeds.py @@ -199,7 +199,7 @@ class Embed: """Converts a :class:`dict` to a :class:`Embed` provided it is in the format that Discord expects it to be in. - You can find out about this format in the :ddocs:`official Discord documentation `. + You can find out about this format in the :ddocs:`official Discord documentation `. Parameters ----------- @@ -413,8 +413,9 @@ class Embed: Parameters ----------- - url: :class:`str` + url: Optional[:class:`str`] The source URL for the image. Only HTTP(S) is supported. + If ``None`` is passed, any existing image is removed. Inline attachment URLs are also supported, see :ref:`local_image`. """ @@ -452,13 +453,11 @@ class Embed: This function returns the class instance to allow for fluent-style chaining. - .. versionchanged:: 1.4 - Passing ``None`` removes the thumbnail. - Parameters ----------- - url: :class:`str` + url: Optional[:class:`str`] The source URL for the thumbnail. Only HTTP(S) is supported. + If ``None`` is passed, any existing thumbnail is removed. Inline attachment URLs are also supported, see :ref:`local_image`. """ diff --git a/discord/emoji.py b/discord/emoji.py index 045486d5a..74f344acc 100644 --- a/discord/emoji.py +++ b/discord/emoji.py @@ -29,6 +29,8 @@ from .asset import Asset, AssetMixin from .utils import SnowflakeList, snowflake_time, MISSING from .partial_emoji import _EmojiTag, PartialEmoji from .user import User +from .errors import MissingApplicationID +from .object import Object # fmt: off __all__ = ( @@ -93,6 +95,10 @@ class Emoji(_EmojiTag, AssetMixin): user: Optional[:class:`User`] The user that created the emoji. This can only be retrieved using :meth:`Guild.fetch_emoji` and having :attr:`~Permissions.manage_emojis`. + + Or if :meth:`.is_application_owned` is ``True``, this is the team member that uploaded + the emoji, or the bot user if it was uploaded using the API and this can + only be retrieved using :meth:`~discord.Client.fetch_application_emoji` or :meth:`~discord.Client.fetch_application_emojis`. """ __slots__: Tuple[str, ...] = ( @@ -108,7 +114,7 @@ class Emoji(_EmojiTag, AssetMixin): 'available', ) - def __init__(self, *, guild: Guild, state: ConnectionState, data: EmojiPayload) -> None: + def __init__(self, *, guild: Snowflake, state: ConnectionState, data: EmojiPayload) -> None: self.guild_id: int = guild.id self._state: ConnectionState = state self._from_data(data) @@ -196,20 +202,32 @@ class Emoji(_EmojiTag, AssetMixin): Deletes the custom emoji. - You must have :attr:`~Permissions.manage_emojis` to do this. + You must have :attr:`~Permissions.manage_emojis` to do this if + :meth:`.is_application_owned` is ``False``. Parameters ----------- reason: Optional[:class:`str`] The reason for deleting this emoji. Shows up on the audit log. + This does not apply if :meth:`.is_application_owned` is ``True``. + Raises ------- Forbidden You are not allowed to delete emojis. HTTPException An error occurred deleting the emoji. + MissingApplicationID + The emoji is owned by an application but the application ID is missing. """ + if self.is_application_owned(): + application_id = self._state.application_id + if application_id is None: + raise MissingApplicationID + + await self._state.http.delete_application_emoji(application_id, self.id) + return await self._state.http.delete_custom_emoji(self.guild_id, self.id, reason=reason) @@ -231,15 +249,22 @@ class Emoji(_EmojiTag, AssetMixin): The new emoji name. roles: List[:class:`~discord.abc.Snowflake`] A list of roles that can use this emoji. An empty list can be passed to make it available to everyone. + + This does not apply if :meth:`.is_application_owned` is ``True``. + reason: Optional[:class:`str`] The reason for editing this emoji. Shows up on the audit log. + This does not apply if :meth:`.is_application_owned` is ``True``. + Raises ------- Forbidden You are not allowed to edit emojis. HTTPException An error occurred editing the emoji. + MissingApplicationID + The emoji is owned by an application but the application ID is missing Returns -------- @@ -253,5 +278,25 @@ class Emoji(_EmojiTag, AssetMixin): if roles is not MISSING: payload['roles'] = [role.id for role in roles] + if self.is_application_owned(): + application_id = self._state.application_id + if application_id is None: + raise MissingApplicationID + + payload.pop('roles', None) + data = await self._state.http.edit_application_emoji( + application_id, + self.id, + payload=payload, + ) + return Emoji(guild=Object(0), data=data, state=self._state) + data = await self._state.http.edit_custom_emoji(self.guild_id, self.id, payload=payload, reason=reason) return Emoji(guild=self.guild, data=data, state=self._state) # type: ignore # if guild is None, the http request would have failed + + def is_application_owned(self) -> bool: + """:class:`bool`: Whether the emoji is owned by an application. + + .. versionadded:: 2.5 + """ + return self.guild_id == 0 diff --git a/discord/errors.py b/discord/errors.py index 6035ace7c..a40842578 100644 --- a/discord/errors.py +++ b/discord/errors.py @@ -47,6 +47,12 @@ __all__ = ( 'ConnectionClosed', 'PrivilegedIntentsRequired', 'InteractionResponded', + 'MissingApplicationID', +) + +APP_ID_NOT_FOUND = ( + 'Client does not have an application_id set. Either the function was called before on_ready ' + 'was called or application_id was not passed to the Client constructor.' ) @@ -278,3 +284,22 @@ class InteractionResponded(ClientException): def __init__(self, interaction: Interaction): self.interaction: Interaction = interaction super().__init__('This interaction has already been responded to before') + + +class MissingApplicationID(ClientException): + """An exception raised when the client does not have an application ID set. + + An application ID is required for syncing application commands and various + other application tasks such as SKUs or application emojis. + + This inherits from :exc:`~discord.app_commands.AppCommandError` + and :class:`~discord.ClientException`. + + .. versionadded:: 2.0 + + .. versionchanged:: 2.5 + This is now exported to the ``discord`` namespace and now inherits from :class:`~discord.ClientException`. + """ + + def __init__(self, message: Optional[str] = None): + super().__init__(message or APP_ID_NOT_FOUND) diff --git a/discord/flags.py b/discord/flags.py index 3d31e3a58..583f98c34 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -2032,6 +2032,48 @@ class MemberFlags(BaseFlags): """:class:`bool`: Returns ``True`` if the member has started onboarding.""" return 1 << 3 + @flag_value + def guest(self): + """:class:`bool`: Returns ``True`` if the member is a guest and can only access + the voice channel they were invited to. + + .. versionadded:: 2.5 + """ + return 1 << 4 + + @flag_value + def started_home_actions(self): + """:class:`bool`: Returns ``True`` if the member has started Server Guide new member actions. + + .. versionadded:: 2.5 + """ + return 1 << 5 + + @flag_value + def completed_home_actions(self): + """:class:`bool`: Returns ``True`` if the member has completed Server Guide new member actions. + + .. versionadded:: 2.5 + """ + return 1 << 6 + + @flag_value + def automod_quarantined_username(self): + """:class:`bool`: Returns ``True`` if the member's username, nickname, or global name has been + blocked by AutoMod. + + .. versionadded:: 2.5 + """ + return 1 << 7 + + @flag_value + def dm_settings_upsell_acknowledged(self): + """:class:`bool`: Returns ``True`` if the member has dismissed the DM settings upsell. + + .. versionadded:: 2.5 + """ + return 1 << 9 + @fill_with_flags() class AttachmentFlags(BaseFlags): diff --git a/discord/guild.py b/discord/guild.py index f34818b63..9bdcda129 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -3408,6 +3408,37 @@ class Guild(Hashable): data = await self._state.http.get_roles(self.id) return [Role(guild=self, state=self._state, data=d) for d in data] + async def fetch_role(self, role_id: int, /) -> Role: + """|coro| + + Retrieves a :class:`Role` with the specified ID. + + .. versionadded:: 2.5 + + .. note:: + + This method is an API call. For general usage, consider :attr:`get_role` instead. + + Parameters + ---------- + role_id: :class:`int` + The role's ID. + + Raises + ------- + NotFound + The role requested could not be found. + HTTPException + An error occurred fetching the role. + + Returns + ------- + :class:`Role` + The retrieved role. + """ + data = await self._state.http.get_role(self.id, role_id) + return Role(guild=self, state=self._state, data=data) + @overload async def create_role( self, @@ -4404,13 +4435,35 @@ class Guild(Hashable): return utils.parse_time(self._incidents_data.get('dms_disabled_until')) + @property + def dm_spam_detected_at(self) -> Optional[datetime.datetime]: + """:class:`datetime.datetime`: Returns the time when DM spam was detected in the guild. + + .. versionadded:: 2.5 + """ + if not self._incidents_data: + return None + + return utils.parse_time(self._incidents_data.get('dm_spam_detected_at')) + + @property + def raid_detected_at(self) -> Optional[datetime.datetime]: + """Optional[:class:`datetime.datetime`]: Returns the time when a raid was detected in the guild. + + .. versionadded:: 2.5 + """ + if not self._incidents_data: + return None + + return utils.parse_time(self._incidents_data.get('raid_detected_at')) + def invites_paused(self) -> bool: """:class:`bool`: Whether invites are paused in the guild. .. versionadded:: 2.4 """ if not self.invites_paused_until: - return False + return 'INVITES_DISABLED' in self.features return self.invites_paused_until > utils.utcnow() @@ -4423,3 +4476,23 @@ class Guild(Hashable): return False return self.dms_paused_until > utils.utcnow() + + def is_dm_spam_detected(self) -> bool: + """:class:`bool`: Whether DM spam was detected in the guild. + + .. versionadded:: 2.5 + """ + if not self.dm_spam_detected_at: + return False + + return self.dm_spam_detected_at > utils.utcnow() + + def is_raid_detected(self) -> bool: + """:class:`bool`: Whether a raid was detected in the guild. + + .. versionadded:: 2.5 + """ + if not self.raid_detected_at: + return False + + return self.raid_detected_at > utils.utcnow() diff --git a/discord/http.py b/discord/http.py index 608595fe3..6230f9b1d 100644 --- a/discord/http.py +++ b/discord/http.py @@ -48,7 +48,6 @@ from typing import ( from urllib.parse import quote as _uriquote from collections import deque import datetime -import socket import aiohttp @@ -93,6 +92,7 @@ if TYPE_CHECKING: welcome_screen, sku, poll, + voice, ) from .types.snowflake import Snowflake, SnowflakeList @@ -798,13 +798,13 @@ class HTTPClient: async def static_login(self, token: str) -> user.User: # Necessary to get aiohttp to stop complaining about session creation if self.connector is MISSING: - # discord does not support ipv6 - self.connector = aiohttp.TCPConnector(limit=0, family=socket.AF_INET) + self.connector = aiohttp.TCPConnector(limit=0) self.__session = aiohttp.ClientSession( connector=self.connector, ws_response_class=DiscordClientWebSocketResponse, trace_configs=None if self.http_trace is None else [self.http_trace], + cookie_jar=aiohttp.DummyCookieJar(), ) self._global_over = asyncio.Event() self._global_over.set() @@ -1148,6 +1148,12 @@ class HTTPClient: r = Route('PATCH', '/guilds/{guild_id}/members/{user_id}', guild_id=guild_id, user_id=user_id) return self.request(r, json=fields, reason=reason) + def get_my_voice_state(self, guild_id: Snowflake) -> Response[voice.GuildVoiceState]: + return self.request(Route('GET', '/guilds/{guild_id}/voice-states/@me', guild_id=guild_id)) + + def get_voice_state(self, guild_id: Snowflake, user_id: Snowflake) -> Response[voice.GuildVoiceState]: + return self.request(Route('GET', '/guilds/{guild_id}/voice-states/{user_id}', guild_id=guild_id, user_id=user_id)) + # Channel management def edit_channel( @@ -1611,6 +1617,9 @@ class HTTPClient: def get_sticker(self, sticker_id: Snowflake) -> Response[sticker.Sticker]: return self.request(Route('GET', '/stickers/{sticker_id}', sticker_id=sticker_id)) + def get_sticker_pack(self, sticker_pack_id: Snowflake) -> Response[sticker.StickerPack]: + return self.request(Route('GET', '/sticker-packs/{sticker_pack_id}', sticker_pack_id=sticker_pack_id)) + def list_premium_sticker_packs(self) -> Response[sticker.ListPremiumStickerPacks]: return self.request(Route('GET', '/sticker-packs')) @@ -1858,6 +1867,9 @@ class HTTPClient: def get_roles(self, guild_id: Snowflake) -> Response[List[role.Role]]: return self.request(Route('GET', '/guilds/{guild_id}/roles', guild_id=guild_id)) + def get_role(self, guild_id: Snowflake, role_id: Snowflake) -> Response[role.Role]: + return self.request(Route('GET', '/guilds/{guild_id}/roles/{role_id}', guild_id=guild_id, role_id=role_id)) + def edit_role( self, guild_id: Snowflake, role_id: Snowflake, *, reason: Optional[str] = None, **fields: Any ) -> Response[role.Role]: @@ -2503,7 +2515,7 @@ class HTTPClient: ), ) - # Misc + # Application def application_info(self) -> Response[appinfo.AppInfo]: return self.request(Route('GET', '/oauth2/applications/@me')) @@ -2524,6 +2536,59 @@ class HTTPClient: payload = {k: v for k, v in payload.items() if k in valid_keys} return self.request(Route('PATCH', '/applications/@me'), json=payload, reason=reason) + def get_application_emojis(self, application_id: Snowflake) -> Response[appinfo.ListAppEmojis]: + return self.request(Route('GET', '/applications/{application_id}/emojis', application_id=application_id)) + + def get_application_emoji(self, application_id: Snowflake, emoji_id: Snowflake) -> Response[emoji.Emoji]: + return self.request( + Route( + 'GET', '/applications/{application_id}/emojis/{emoji_id}', application_id=application_id, emoji_id=emoji_id + ) + ) + + def create_application_emoji( + self, + application_id: Snowflake, + name: str, + image: str, + ) -> Response[emoji.Emoji]: + payload = { + 'name': name, + 'image': image, + } + + return self.request( + Route('POST', '/applications/{application_id}/emojis', application_id=application_id), json=payload + ) + + def edit_application_emoji( + self, + application_id: Snowflake, + emoji_id: Snowflake, + *, + payload: Dict[str, Any], + ) -> Response[emoji.Emoji]: + r = Route( + 'PATCH', '/applications/{application_id}/emojis/{emoji_id}', application_id=application_id, emoji_id=emoji_id + ) + return self.request(r, json=payload) + + def delete_application_emoji( + self, + application_id: Snowflake, + emoji_id: Snowflake, + ) -> Response[None]: + return self.request( + Route( + 'DELETE', + '/applications/{application_id}/emojis/{emoji_id}', + application_id=application_id, + emoji_id=emoji_id, + ) + ) + + # Poll + def get_poll_answer_voters( self, channel_id: Snowflake, @@ -2561,6 +2626,8 @@ class HTTPClient: ) ) + # Misc + async def get_gateway(self, *, encoding: str = 'json', zlib: bool = True) -> str: try: data = await self.request(Route('GET', '/gateway')) diff --git a/discord/member.py b/discord/member.py index 2eadacd27..66c771572 100644 --- a/discord/member.py +++ b/discord/member.py @@ -1153,6 +1153,40 @@ class Member(discord.abc.Messageable, _UserTag): for role in roles: await req(guild_id, user_id, role.id, reason=reason) + async def fetch_voice(self) -> VoiceState: + """|coro| + + Retrieves the current voice state from this member. + + .. versionadded:: 2.5 + + Raises + ------- + NotFound + The member is not in a voice channel. + Forbidden + You do not have permissions to get a voice state. + HTTPException + Retrieving the voice state failed. + + Returns + ------- + :class:`VoiceState` + The current voice state of the member. + """ + guild_id = self.guild.id + if self._state.self_id == self.id: + data = await self._state.http.get_my_voice_state(guild_id) + else: + data = await self._state.http.get_voice_state(guild_id, self.id) + + channel_id = data.get('channel_id') + channel: Optional[VocalGuildChannel] = None + if channel_id is not None: + channel = self.guild.get_channel(int(channel_id)) # type: ignore # must be voice channel here + + return VoiceState(data=data, channel=channel) + def get_role(self, role_id: int, /) -> Optional[Role]: """Returns a role with the given ID from roles which the member has. diff --git a/discord/message.py b/discord/message.py index 1d1a3c96c..76127f869 100644 --- a/discord/message.py +++ b/discord/message.py @@ -194,6 +194,10 @@ class Attachment(Hashable): The waveform (amplitudes) of the audio in bytes. Returns ``None`` if it's not a voice message. .. versionadded:: 2.3 + title: Optional[:class:`str`] + The normalised version of the attachment's filename. + + .. versionadded:: 2.5 """ __slots__ = ( @@ -211,6 +215,7 @@ class Attachment(Hashable): 'duration', 'waveform', '_flags', + 'title', ) def __init__(self, *, data: AttachmentPayload, state: ConnectionState): @@ -226,6 +231,7 @@ class Attachment(Hashable): self.description: Optional[str] = data.get('description') self.ephemeral: bool = data.get('ephemeral', False) self.duration: Optional[float] = data.get('duration_secs') + self.title: Optional[str] = data.get('title') waveform = data.get('waveform') self.waveform: Optional[bytes] = utils._base64_to_bytes(waveform) if waveform is not None else None @@ -1837,13 +1843,11 @@ class Message(PartialMessage, Hashable): self.application_id: Optional[int] = utils._get_as_snowflake(data, 'application_id') self.stickers: List[StickerItem] = [StickerItem(data=d, state=state) for d in data.get('sticker_items', [])] - # This updates the poll so it has the counts, if the message - # was previously cached. self.poll: Optional[Poll] = None try: self.poll = Poll._from_data(data=data['poll'], message=self, state=state) except KeyError: - self.poll = state._get_poll(self.id) + pass try: # if the channel doesn't have a guild attribute, we handle that diff --git a/discord/permissions.py b/discord/permissions.py index 17c7b38c9..b553e2578 100644 --- a/discord/permissions.py +++ b/discord/permissions.py @@ -187,7 +187,7 @@ class Permissions(BaseFlags): permissions set to ``True``. """ # Some of these are 0 because we don't want to set unnecessary bits - return cls(0b0000_0000_0000_0010_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111) + return cls(0b0000_0000_0000_0110_0111_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111) @classmethod def _timeout_mask(cls) -> int: diff --git a/discord/poll.py b/discord/poll.py index 7dc3897ac..88ed5b534 100644 --- a/discord/poll.py +++ b/discord/poll.py @@ -384,9 +384,9 @@ class Poll: question_data = data.get('question') question = question_data.get('text') expiry = utils.parse_time(data['expiry']) # If obtained via API, then expiry is set. - duration = expiry - message.created_at + # expiry - message.created_at may be a few nanos away from the actual duration + duration = datetime.timedelta(hours=round((expiry - message.created_at).total_seconds() / 3600)) # self.created_at = message.created_at - # duration = self.created_at - expiry self = cls( duration=duration, diff --git a/discord/sku.py b/discord/sku.py index 2af171c1d..e8780399c 100644 --- a/discord/sku.py +++ b/discord/sku.py @@ -28,7 +28,7 @@ from __future__ import annotations from typing import Optional, TYPE_CHECKING from . import utils -from .app_commands import MissingApplicationID +from .errors import MissingApplicationID from .enums import try_enum, SKUType, EntitlementType from .flags import SKUFlags diff --git a/discord/state.py b/discord/state.py index db0395266..6279f14bf 100644 --- a/discord/state.py +++ b/discord/state.py @@ -510,12 +510,6 @@ class ConnectionState(Generic[ClientT]): def _get_message(self, msg_id: Optional[int]) -> Optional[Message]: return utils.find(lambda m: m.id == msg_id, reversed(self._messages)) if self._messages else None - def _get_poll(self, msg_id: Optional[int]) -> Optional[Poll]: - message = self._get_message(msg_id) - if not message: - return - return message.poll - def _add_guild_from_data(self, data: GuildPayload) -> Guild: guild = Guild(data=data, state=self) self._add_guild(guild) diff --git a/discord/sticker.py b/discord/sticker.py index 30eb62c70..bf90f8866 100644 --- a/discord/sticker.py +++ b/discord/sticker.py @@ -28,8 +28,7 @@ import unicodedata from .mixins import Hashable from .asset import Asset, AssetMixin -from .utils import cached_slot_property, find, snowflake_time, get, MISSING, _get_as_snowflake -from .errors import InvalidData +from .utils import cached_slot_property, snowflake_time, get, MISSING, _get_as_snowflake from .enums import StickerType, StickerFormatType, try_enum __all__ = ( @@ -51,7 +50,6 @@ if TYPE_CHECKING: Sticker as StickerPayload, StandardSticker as StandardStickerPayload, GuildSticker as GuildStickerPayload, - ListPremiumStickerPacks as ListPremiumStickerPacksPayload, ) @@ -203,7 +201,10 @@ class StickerItem(_StickerTag): self.name: str = data['name'] self.id: int = int(data['id']) self.format: StickerFormatType = try_enum(StickerFormatType, data['format_type']) - self.url: str = f'{Asset.BASE}/stickers/{self.id}.{self.format.file_extension}' + if self.format is StickerFormatType.gif: + self.url: str = f'https://media.discordapp.net/stickers/{self.id}.gif' + else: + self.url: str = f'{Asset.BASE}/stickers/{self.id}.{self.format.file_extension}' def __repr__(self) -> str: return f'' @@ -258,8 +259,6 @@ class Sticker(_StickerTag): The id of the sticker. description: :class:`str` The description of the sticker. - pack_id: :class:`int` - The id of the sticker's pack. format: :class:`StickerFormatType` The format for the sticker's image. url: :class:`str` @@ -352,9 +351,12 @@ class StandardSticker(Sticker): Retrieves the sticker pack that this sticker belongs to. + .. versionchanged:: 2.5 + Now raises ``NotFound`` instead of ``InvalidData``. + Raises -------- - InvalidData + NotFound The corresponding sticker pack was not found. HTTPException Retrieving the sticker pack failed. @@ -364,13 +366,8 @@ class StandardSticker(Sticker): :class:`StickerPack` The retrieved sticker pack. """ - data: ListPremiumStickerPacksPayload = await self._state.http.list_premium_sticker_packs() - packs = data['sticker_packs'] - pack = find(lambda d: int(d['id']) == self.pack_id, packs) - - if pack: - return StickerPack(state=self._state, data=pack) - raise InvalidData(f'Could not find corresponding sticker pack for {self!r}') + data = await self._state.http.get_sticker_pack(self.pack_id) + return StickerPack(state=self._state, data=data) class GuildSticker(Sticker): diff --git a/discord/types/appinfo.py b/discord/types/appinfo.py index e291babfa..7cca955b7 100644 --- a/discord/types/appinfo.py +++ b/discord/types/appinfo.py @@ -30,6 +30,7 @@ from typing_extensions import NotRequired from .user import User from .team import Team from .snowflake import Snowflake +from .emoji import Emoji class InstallParams(TypedDict): @@ -45,6 +46,7 @@ class BaseAppInfo(TypedDict): summary: str description: str flags: int + approximate_user_install_count: NotRequired[int] cover_image: NotRequired[str] terms_of_service_url: NotRequired[str] privacy_policy_url: NotRequired[str] @@ -78,3 +80,7 @@ class PartialAppInfo(BaseAppInfo, total=False): class GatewayAppInfo(TypedDict): id: Snowflake flags: int + + +class ListAppEmojis(TypedDict): + items: List[Emoji] diff --git a/discord/user.py b/discord/user.py index 5151957dc..c5391372a 100644 --- a/discord/user.py +++ b/discord/user.py @@ -171,7 +171,7 @@ class BaseUser(_UserTag): @property def default_avatar(self) -> Asset: """:class:`Asset`: Returns the default avatar for a given user.""" - if self.discriminator == '0': + if self.discriminator in ('0', '0000'): avatar_id = (self.id >> 22) % len(DefaultAvatar) else: avatar_id = int(self.discriminator) % 5 diff --git a/discord/utils.py b/discord/utils.py index 99c7cfc94..89cc8bdeb 100644 --- a/discord/utils.py +++ b/discord/utils.py @@ -302,7 +302,7 @@ def deprecated(instead: Optional[str] = None) -> Callable[[Callable[P, T]], Call else: fmt = '{0.__name__} is deprecated.' - warnings.warn(fmt.format(func, instead), stacklevel=3, category=DeprecationWarning) + warnings.warn(fmt.format(func, instead), stacklevel=2, category=DeprecationWarning) warnings.simplefilter('default', DeprecationWarning) # reset filter return func(*args, **kwargs) diff --git a/discord/webhook/async_.py b/discord/webhook/async_.py index 26510b585..93184415a 100644 --- a/discord/webhook/async_.py +++ b/discord/webhook/async_.py @@ -38,7 +38,7 @@ import aiohttp from .. import utils from ..errors import HTTPException, Forbidden, NotFound, DiscordServerError from ..message import Message -from ..enums import try_enum, WebhookType, ChannelType +from ..enums import try_enum, WebhookType, ChannelType, DefaultAvatar from ..user import BaseUser, User from ..flags import MessageFlags from ..asset import Asset @@ -363,7 +363,7 @@ class AsyncWebhookAdapter: multipart: Optional[List[Dict[str, Any]]] = None, files: Optional[Sequence[File]] = None, thread_id: Optional[int] = None, - ) -> Response[Message]: + ) -> Response[MessagePayload]: route = Route( 'PATCH', '/webhooks/{webhook_id}/{webhook_token}/messages/{message_id}', @@ -736,11 +736,6 @@ class _WebhookState: return self._parent._get_guild(guild_id) return None - def _get_poll(self, msg_id: Optional[int]) -> Optional[Poll]: - if self._parent is not None: - return self._parent._get_poll(msg_id) - return None - def store_user(self, data: Union[UserPayload, PartialUserPayload], *, cache: bool = True) -> BaseUser: if self._parent is not None: return self._parent.store_user(data, cache=cache) @@ -1064,12 +1059,11 @@ class BaseWebhook(Hashable): @property def default_avatar(self) -> Asset: """ - :class:`Asset`: Returns the default avatar. This is always the blurple avatar. + :class:`Asset`: Returns the default avatar. .. versionadded:: 2.0 """ - # Default is always blurple apparently - return Asset._from_default_avatar(self._state, 0) + return Asset._from_default_avatar(self._state, (self.id >> 22) % len(DefaultAvatar)) @property def display_avatar(self) -> Asset: diff --git a/docs/api.rst b/docs/api.rst index 41cf6549d..e415ea8ce 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -5440,6 +5440,8 @@ The following exceptions are thrown by the library. .. autoexception:: InteractionResponded +.. autoexception:: MissingApplicationID + .. autoexception:: discord.opus.OpusError .. autoexception:: discord.opus.OpusNotLoaded @@ -5457,6 +5459,7 @@ Exception Hierarchy - :exc:`ConnectionClosed` - :exc:`PrivilegedIntentsRequired` - :exc:`InteractionResponded` + - :exc:`MissingApplicationID` - :exc:`GatewayNotFound` - :exc:`HTTPException` - :exc:`Forbidden` diff --git a/docs/interactions/api.rst b/docs/interactions/api.rst index 211cd790f..aeb6a25c6 100644 --- a/docs/interactions/api.rst +++ b/docs/interactions/api.rst @@ -872,9 +872,6 @@ Exceptions .. autoexception:: discord.app_commands.CommandNotFound :members: -.. autoexception:: discord.app_commands.MissingApplicationID - :members: - .. autoexception:: discord.app_commands.CommandSyncFailure :members: @@ -899,7 +896,7 @@ Exception Hierarchy - :exc:`~discord.app_commands.CommandAlreadyRegistered` - :exc:`~discord.app_commands.CommandSignatureMismatch` - :exc:`~discord.app_commands.CommandNotFound` - - :exc:`~discord.app_commands.MissingApplicationID` + - :exc:`~discord.MissingApplicationID` - :exc:`~discord.app_commands.CommandSyncFailure` - :exc:`~discord.HTTPException` - :exc:`~discord.app_commands.CommandSyncFailure` diff --git a/pyproject.toml b/pyproject.toml index d36c85c82..596e6ef08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ docs = [ ] speed = [ "orjson>=3.5.4", - "aiodns>=1.1", + "aiodns>=1.1; sys_platform != 'win32'", "Brotli", "cchardet==2.1.7; python_version < '3.10'", ] diff --git a/requirements.txt b/requirements.txt index 046084ebb..ef2b6c534 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ aiohttp>=3.7.4,<4 +audioop-lts; python_version>='3.13' diff --git a/tests/test_permissions_all.py b/tests/test_permissions_all.py new file mode 100644 index 000000000..883dc1b63 --- /dev/null +++ b/tests/test_permissions_all.py @@ -0,0 +1,7 @@ +import discord + +from functools import reduce +from operator import or_ + +def test_permissions_all(): + assert discord.Permissions.all().value == reduce(or_, discord.Permissions.VALID_FLAGS.values())