diff --git a/changelog/1223.feature.rst b/changelog/1223.feature.rst new file mode 100644 index 0000000000..188ea62452 --- /dev/null +++ b/changelog/1223.feature.rst @@ -0,0 +1,2 @@ +Edit :class:`.Emoji` to represent application emojis. +Add new methods and properties on :class:`Client` to get, fetch and create application emojis: :meth:`Client.get_application_emoji`, :meth:`Client.fetch_application_emoji`, :meth:`Client.get_or_fetch_application_emoji`, :meth:`Client.getch_application_emoji`, :meth:`Client.fetch_application_emojis`, :attr:`Client.application_emojis` and :meth:`Client.create_application_emoji`. diff --git a/disnake/client.py b/disnake/client.py index 2c4e2c7da4..f3b22c131d 100644 --- a/disnake/client.py +++ b/disnake/client.py @@ -401,6 +401,7 @@ def __init__( intents: Optional[Intents] = None, chunk_guilds_at_startup: Optional[bool] = None, member_cache_flags: Optional[MemberCacheFlags] = None, + cache_app_emojis_at_startup: bool = False, ) -> None: # self.ws is set in the connect method self.ws: DiscordWebSocket = None # type: ignore @@ -444,6 +445,7 @@ def __init__( intents=intents, chunk_guilds_at_startup=chunk_guilds_at_startup, member_cache_flags=member_cache_flags, + cache_app_emojis_at_startup=cache_app_emojis_at_startup, ) self.shard_id: Optional[int] = shard_id self.shard_count: Optional[int] = shard_count @@ -498,6 +500,7 @@ def _get_state( intents: Optional[Intents], chunk_guilds_at_startup: Optional[bool], member_cache_flags: Optional[MemberCacheFlags], + cache_app_emojis_at_startup: bool, ) -> ConnectionState: return ConnectionState( dispatch=self.dispatch, @@ -515,6 +518,7 @@ def _get_state( intents=intents, chunk_guilds_at_startup=chunk_guilds_at_startup, member_cache_flags=member_cache_flags, + cache_app_emojis_at_startup=cache_app_emojis_at_startup, ) def _handle_ready(self) -> None: @@ -563,6 +567,14 @@ def emojis(self) -> List[Emoji]: """List[:class:`.Emoji`]: The emojis that the connected client has.""" return self._connection.emojis + @property + def application_emojis(self) -> List[Emoji]: + """List[:class:`.Emoji`]: The application emojis that the connected client has. + + .. versionadded:: 2.10 + """ + return self._connection.application_emojis + @property def stickers(self) -> List[GuildSticker]: """List[:class:`.GuildSticker`]: The stickers that the connected client has. @@ -711,6 +723,58 @@ async def get_or_fetch_user(self, user_id: int, *, strict: bool = False) -> Opti getch_user = get_or_fetch_user + @overload + async def get_or_fetch_application_emoji( + self, emoji_id: int, *, strict: Literal[False] = ... + ) -> Optional[Emoji]: + ... + + @overload + async def get_or_fetch_application_emoji( + self, emoji_id: int, *, strict: Literal[True] = ... + ) -> Emoji: + ... + + async def get_or_fetch_application_emoji( + self, emoji_id: int, *, strict: bool = False + ) -> Optional[Emoji]: + """|coro| + + Tries to get the application emoji from the cache. If it fails, + fetches the app emoji from the API. + + This only propagates exceptions when the ``strict`` parameter is enabled. + + .. versionadded:: 2.10 + + Parameters + ---------- + emoji_id: :class:`int` + The ID to search for. + strict: :class:`bool` + Whether to propagate exceptions from :func:`fetch_application_emoji` + instead of returning ``None`` in case of failure + (e.g. if the app emoji wasn't found). + Defaults to ``False``. + + Returns + ------- + Optional[:class:`~disnake.Emoji`] + The app emoji with the given ID, or ``None`` if not found and ``strict`` is set to ``False``. + """ + app_emoji = self.get_application_emoji(emoji_id) + if app_emoji is not None: + return app_emoji + try: + app_emoji = await self.fetch_application_emoji(emoji_id) + except Exception: + if strict: + raise + return None + return app_emoji + + getch_application_emoji = get_or_fetch_application_emoji + def is_ready(self) -> bool: """Whether the client's internal cache is ready for use. @@ -1494,6 +1558,28 @@ def get_emoji(self, id: int, /) -> Optional[Emoji]: """ return self._connection.get_emoji(id) + def get_application_emoji(self, emoji_id: int, /) -> Optional[Emoji]: + """Returns an application emoji with the given ID. + + .. versionadded:: 2.10 + + .. note:: + + If this returns ``None`` consider executing :meth:`fetch_application_emoji` + or enable :attr:`disnake.Client.cache_app_emoji`. + + Parameters + ---------- + emoji_id: :class:`int` + The ID to search for. + + Returns + ------- + Optional[:class:`.Emoji`] + The returned application emoji or ``None`` if not found. + """ + return self._connection.get_application_emoji(emoji_id) + def get_sticker(self, id: int, /) -> Optional[GuildSticker]: """Returns a guild sticker with the given ID. @@ -2377,6 +2463,114 @@ async def application_info(self) -> AppInfo: data["rpc_origins"] = None return AppInfo(self._connection, data) + async def fetch_application_emoji(self, emoji_id: int, /, cache: bool = False) -> Emoji: + """|coro| + + Retrieves an application level :class:`~disnake.Emoji` based on its ID. + + .. note:: + + This method is an API call. If you have :attr:`disnake.Client.cache_application_emojis` enabled, consider :meth:`get_application_emoji` instead. + + .. versionadded:: 2.10 + + Parameters + ---------- + emoji_id: :class:`int` + The ID of the emoji to retrieve. + cache: :class:`bool` + Whether to update the cache. + + Raises + ------ + NotFound + The app emoji couldn't be found. + Forbidden + You are not allowed to get the app emoji. + + Returns + ------- + :class:`.Emoji` + The application emoji you requested. + """ + data = await self.http.get_app_emoji(self.application_id, emoji_id) + + if cache: + return self._connection.store_application_emoji(data=data) + return Emoji(guild=None, state=self._connection, data=data) + + async def create_application_emoji(self, *, name: str, image: AssetBytes) -> Emoji: + """|coro| + + Creates an application emoji. + + .. versionadded:: 2.10 + + Parameters + ---------- + name: :class:`str` + The emoji name. Must be at least 2 characters. + image: |resource_type| + The image data of the emoji. + Only JPG, PNG and GIF images are supported. + + Raises + ------ + NotFound + The ``image`` asset couldn't be found. + Forbidden + You are not allowed to create app emojis. + HTTPException + An error occurred creating an app emoji. + TypeError + The ``image`` asset is a lottie sticker (see :func:`Sticker.read `). + ValueError + Wrong image format passed for ``image``. + + Returns + ------- + :class:`.Emoji` + The newly created application emoji. + """ + img = await utils._assetbytes_to_base64_data(image) + data = await self.http.create_app_emoji(self.application_id, name, img) + return self._connection.store_application_emoji(data) + + async def fetch_application_emojis(self, *, cache: bool = False) -> List[Emoji]: + """|coro| + + Retrieves all the :class:`.Emoji` of the application. + + .. versionadded:: 2.10 + + Parameters + ---------- + cache: :class:`bool` + Whether to update the cache. + + Raises + ------ + NotFound + The app emojis for this application ID couldn't be found. + Forbidden + You are not allowed to get app emojis. + + Returns + ------- + List[:class:`.Emoji`] + The list of application emojis you requested. + """ + data = await self.http.get_all_app_emojis(self.application_id) + + if cache: + app_emojis = [] + for emoji_data in data: + app_emojis.append(self._connection.store_application_emoji(emoji_data)) + + return app_emojis + + return [Emoji(guild=None, state=self._connection, data=emoji_data) for emoji_data in data] + async def fetch_user(self, user_id: int, /) -> User: """|coro| diff --git a/disnake/emoji.py b/disnake/emoji.py index badedbce86..e44118160b 100644 --- a/disnake/emoji.py +++ b/disnake/emoji.py @@ -51,6 +51,10 @@ class Emoji(_EmojiTag, AssetMixin): Returns the emoji rendered for Discord. + .. versionchanged:: 2.10 + + This class can now represents app emojis too. Denoted by having :attr:`.Emoji.guild_id` as ``None``. + Attributes ---------- name: :class:`str` @@ -63,8 +67,8 @@ class Emoji(_EmojiTag, AssetMixin): Whether the emoji is animated or not. managed: :class:`bool` Whether the emoji is managed by a Twitch integration. - guild_id: :class:`int` - The guild ID the emoji belongs to. + guild_id: Optional[:class:`int`] + The guild ID the emoji belongs to. ``None`` if this is an app emoji. available: :class:`bool` Whether the emoji is available for use. user: Optional[:class:`User`] @@ -86,9 +90,13 @@ class Emoji(_EmojiTag, AssetMixin): ) def __init__( - self, *, guild: Union[Guild, GuildPreview], state: ConnectionState, data: EmojiPayload + self, + *, + guild: Optional[Union[Guild, GuildPreview]], + state: ConnectionState, + data: EmojiPayload, ) -> None: - self.guild_id: int = guild.id + self.guild_id: Optional[int] = guild.id if guild else None self._state: ConnectionState = state self._from_data(data) @@ -151,16 +159,32 @@ def roles(self) -> List[Role]: and count towards a separate limit of 25 emojis. """ guild = self.guild - if guild is None: # pyright: ignore[reportUnnecessaryComparison] + if guild is None: return [] return [role for role in guild.roles if self._roles.has(role.id)] @property - def guild(self) -> Guild: - """:class:`Guild`: The guild this emoji belongs to.""" + def guild(self) -> Optional[Guild]: + """Optional[:class:`Guild`]: The guild this emoji belongs to. ``None`` if this is an app emoji. + + .. versionchanged:: 2.10 + + This can now return ``None`` if the emoji is an + application owned emoji. + """ # this will most likely never return None but there's a possibility - return self._state._get_guild(self.guild_id) # type: ignore + return self._state._get_guild(self.guild_id) + + @property + def application_id(self) -> Optional[int]: + """Optional[:class:`int`]: The ID of the application which owns this emoji. + + .. versionadded:: 2.10 + """ + if self.guild is None: + return None + return self._state.application_id def is_usable(self) -> bool: """Whether the bot can use this emoji. @@ -173,6 +197,8 @@ def is_usable(self) -> bool: return False if not self._roles: return True + if not self.guild: + return self.available emoji_roles, my_roles = self._roles, self.guild.me._roles return any(my_roles.has(role_id) for role_id in emoji_roles) @@ -196,6 +222,13 @@ async def delete(self, *, reason: Optional[str] = None) -> None: HTTPException An error occurred deleting the emoji. """ + # this is an app emoji + if self.guild is None: + if self.application_id is None: + # should never happen + raise ValueError("Idk message about invalid state?! Pls catch this when reviewing") + + return await self._state.http.delete_app_emoji(self.application_id, self.id) await self._state.http.delete_custom_emoji(self.guild.id, self.id, reason=reason) async def edit( @@ -242,7 +275,14 @@ async def edit( if roles is not MISSING: payload["roles"] = [role.id for role in roles] - data = await self._state.http.edit_custom_emoji( - self.guild.id, self.id, payload=payload, reason=reason - ) + if self.guild is None: + if self.application_id is None: + # should never happen + raise ValueError("Idk message about invalid state?! Pls catch this when reviewing") + + data = await self._state.http.edit_app_emoji(self.application_id, self.id, name) + else: + 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) diff --git a/disnake/http.py b/disnake/http.py index 1569fe5715..84e90180e5 100644 --- a/disnake/http.py +++ b/disnake/http.py @@ -1708,6 +1708,16 @@ def delete_guild_sticker( reason=reason, ) + def get_all_app_emojis(self, app_id: Snowflake) -> Response[List[emoji.Emoji]]: + return self.request(Route("GET", "/applications/{app_id}/emojis", app_id=app_id)) + + def get_app_emoji(self, app_id: Snowflake, emoji_id: Snowflake) -> Response[emoji.Emoji]: + return self.request( + Route( + "GET", "/applications/{app_id}/emojis/{emoji_id}", app_id=app_id, emoji_id=emoji_id + ) + ) + def get_all_custom_emojis(self, guild_id: Snowflake) -> Response[List[emoji.Emoji]]: return self.request(Route("GET", "/guilds/{guild_id}/emojis", guild_id=guild_id)) @@ -1718,6 +1728,45 @@ def get_custom_emoji(self, guild_id: Snowflake, emoji_id: Snowflake) -> Response ) ) + def create_app_emoji( + self, + app_id: Snowflake, + name: str, + image: str, + ) -> Response[emoji.Emoji]: + payload: Dict[str, Any] = { + "name": name, + "image": image, + } + + r = Route("POST", "/applications/{app_id}/emojis", app_id=app_id) + return self.request(r, json=payload) + + def edit_app_emoji( + self, + app_id: Snowflake, + emoji_id: Snowflake, + name: str, + ) -> Response[emoji.Emoji]: + payload: Dict[str, Any] = { + "name": name, + } + + r = Route( + "PATCH", "/applications/{app_id}/emojis/{emoji_id}", app_id=app_id, emoji_id=emoji_id + ) + return self.request(r, json=payload) + + def delete_app_emoji(self, app_id: Snowflake, emoji_id: Snowflake) -> Response[None]: + return self.request( + Route( + "DELETE", + "/applications/{app_id}/emojis/{emoji_id}", + app_id=app_id, + emoji_id=emoji_id, + ) + ) + def create_custom_emoji( self, guild_id: Snowflake, diff --git a/disnake/state.py b/disnake/state.py index bb9b99a874..23b8d0dac6 100644 --- a/disnake/state.py +++ b/disnake/state.py @@ -209,6 +209,7 @@ def __init__( intents: Optional[Intents] = None, chunk_guilds_at_startup: Optional[bool] = None, member_cache_flags: Optional[MemberCacheFlags] = None, + cache_app_emojis_at_startup: bool = False, ) -> None: self.loop: asyncio.AbstractEventLoop = loop self.http: HTTPClient = http @@ -287,6 +288,8 @@ def __init__( if attr.startswith("parse_"): parsers[attr[6:].upper()] = func + self.cache_app_emojis_at_startup = cache_app_emojis_at_startup + self.clear() def clear( @@ -299,6 +302,7 @@ def clear( # - accesses on `_users` are slower, e.g. `__getitem__` takes ~1us with weakrefs and ~0.2us without self._users: weakref.WeakValueDictionary[int, User] = weakref.WeakValueDictionary() self._emojis: Dict[int, Emoji] = {} + self._application_emojis: Dict[int, Emoji] = {} self._stickers: Dict[int, GuildSticker] = {} self._guilds: Dict[int, Guild] = {} @@ -405,6 +409,11 @@ def store_emoji(self, guild: Guild, data: EmojiPayload) -> Emoji: self._emojis[emoji_id] = emoji = Emoji(guild=guild, state=self, data=data) return emoji + def store_application_emoji(self, data: EmojiPayload) -> Emoji: + emoji_id = int(data["id"]) # type: ignore + self._application_emojis[emoji_id] = emoji = Emoji(guild=None, state=self, data=data) + return emoji + def store_sticker(self, guild: Guild, data: GuildStickerPayload) -> GuildSticker: sticker_id = int(data["id"]) self._stickers[sticker_id] = sticker = GuildSticker(state=self, data=data) @@ -516,6 +525,10 @@ def _get_guild_command_named( def emojis(self) -> List[Emoji]: return list(self._emojis.values()) + @property + def application_emojis(self) -> List[Emoji]: + return list(self._application_emojis.values()) + @property def stickers(self) -> List[GuildSticker]: return list(self._stickers.values()) @@ -524,6 +537,10 @@ def get_emoji(self, emoji_id: Optional[int]) -> Optional[Emoji]: # the keys of self._emojis are ints return self._emojis.get(emoji_id) # type: ignore + def get_application_emoji(self, emoji_id: Optional[int]) -> Optional[Emoji]: + # the keys of self._application_emojis are ints + return self._application_emojis.get(emoji_id) # type: ignore + def get_sticker(self, sticker_id: Optional[int]) -> Optional[GuildSticker]: # the keys of self._stickers are ints return self._stickers.get(sticker_id) # type: ignore