diff --git a/changelog/1223.breaking.rst b/changelog/1223.breaking.rst new file mode 100644 index 0000000000..ae7890a041 --- /dev/null +++ b/changelog/1223.breaking.rst @@ -0,0 +1 @@ +:attr:`Emoji.guild_id` can now be ``None`` if the emoji is owned by an application. You can use :attr:`Emoji.is_guild_emoji` and :attr:`Emoji.is_app_emoji` to check if this is a Guild or App Emoji. diff --git a/changelog/1223.feature.rst b/changelog/1223.feature.rst new file mode 100644 index 0000000000..799f3adb44 --- /dev/null +++ b/changelog/1223.feature.rst @@ -0,0 +1,3 @@ +Add support to :class:`.Emoji` to represent application emojis. +New methods on :class:`Client`: :meth:`Client.fetch_application_emoji`, :meth:`Client.fetch_application_emojis` and :meth:`Client.create_application_emoji`. +New attributes on :class:`.Emoji`: :attr:`Emoji.application_id`, :attr:`Emoji.is_guild_emoji` and :attr:`Emoji.is_app_emoji`. diff --git a/disnake/client.py b/disnake/client.py index f8f7ffad50..5a24514cf4 100644 --- a/disnake/client.py +++ b/disnake/client.py @@ -2418,6 +2418,95 @@ async def application_info(self) -> AppInfo: data = await self.http.application_info() return AppInfo(self._connection, data) + async def fetch_application_emoji(self, emoji_id: int) -> Emoji: + """|coro| + + Retrieves an application level :class:`~disnake.Emoji` based on its ID. + + .. versionadded:: |vnext| + + Parameters + ---------- + emoji_id: :class:`int` + The ID of the emoji to retrieve. + + 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) + 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:: |vnext| + + 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 Emoji(guild=None, state=self._connection, data=data) + + async def fetch_application_emojis(self) -> List[Emoji]: + """|coro| + + Retrieves all the :class:`.Emoji` of the application. + + .. versionadded:: |vnext| + + 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) + return [ + Emoji(guild=None, state=self._connection, data=emoji_data) + for emoji_data in data["items"] + ] + async def fetch_user(self, user_id: int, /) -> User: """|coro| diff --git a/disnake/emoji.py b/disnake/emoji.py index badedbce86..e474bbbed3 100644 --- a/disnake/emoji.py +++ b/disnake/emoji.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Any, Iterator, List, Optional, Tuple, Union from .asset import Asset, AssetMixin +from .errors import InvalidData from .partial_emoji import PartialEmoji, _EmojiTag from .user import User from .utils import MISSING, SnowflakeList, snowflake_time @@ -51,6 +52,11 @@ class Emoji(_EmojiTag, AssetMixin): Returns the emoji rendered for Discord. + .. versionchanged:: |vnext| + + This class can now represents app emojis. Use :attr:`Emoji.is_app_emoji` to check for this. + To check if this is a guild emoji, use :attr:`Emoji.is_guild_emoji`. + Attributes ---------- name: :class:`str` @@ -63,8 +69,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 +92,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) @@ -119,7 +129,12 @@ def __str__(self) -> str: return f"<:{self.name}:{self.id}>" def __repr__(self) -> str: - return f"" + return ( + f"" + ) def __eq__(self, other: Any) -> bool: return isinstance(other, _EmojiTag) and self.id == other.id @@ -151,16 +166,47 @@ 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.""" - # this will most likely never return None but there's a possibility - return self._state._get_guild(self.guild_id) # type: ignore + def guild(self) -> Optional[Guild]: + """Optional[:class:`Guild`]: The guild this emoji belongs to. ``None`` if this is an app emoji. + + .. versionchanged:: |vnext| + + This can now return ``None`` if the emoji is an + application owned emoji. + """ + 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:: |vnext| + """ + if self.guild_id: + return None + return self._state.application_id + + @property + def is_guild_emoji(self) -> bool: + """:class:`bool`: Whether this is a guild emoji. + + .. versionadded:: |vnext| + """ + return self.guild_id is not None + + @property + def is_app_emoji(self) -> bool: + """:class:`bool`: Whether this is an application emoji. + + .. versionadded:: |vnext| + """ + return self.guild_id is None def is_usable(self) -> bool: """Whether the bot can use this emoji. @@ -171,6 +217,9 @@ def is_usable(self) -> bool: """ if not self.available: return False + if not self.guild: + # if we don't have a guild, this is an app emoji + return self.available if not self._roles: return True emoji_roles, my_roles = self._roles, self.guild.me._roles @@ -195,7 +244,17 @@ async def delete(self, *, reason: Optional[str] = None) -> None: You are not allowed to delete this emoji. HTTPException An error occurred deleting the emoji. + InvalidData + The emoji data is invalid and cannot be processed. """ + # this is an app emoji + if self.guild is None: + if self.application_id is None: + # should never happen + msg = f"guild and application_id are both None when attempting to delete emoji with ID {self.id} This may be a library bug! Open an issue on GitHub." + raise InvalidData(msg) + + 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( @@ -230,6 +289,8 @@ async def edit( You are not allowed to edit this emoji. HTTPException An error occurred editing the emoji. + InvalidData + The emoji data is invalid and cannot be processed. Returns ------- @@ -242,7 +303,15 @@ 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 + msg = f"guild and application_id are both None when attempting to edit emoji with ID {self.id} This may be a library bug! Open an issue on GitHub." + raise InvalidData(msg) + + 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 880ea0155f..b7c2c963df 100644 --- a/disnake/http.py +++ b/disnake/http.py @@ -1750,6 +1750,16 @@ def delete_guild_sticker( reason=reason, ) + def get_all_app_emojis(self, app_id: Snowflake) -> Response[emoji.ListAppEmoji]: + 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)) @@ -1760,6 +1770,37 @@ 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/types/emoji.py b/disnake/types/emoji.py index 5b8bdcf756..76ac0f423b 100644 --- a/disnake/types/emoji.py +++ b/disnake/types/emoji.py @@ -1,6 +1,6 @@ # SPDX-License-Identifier: MIT -from typing import Optional, TypedDict +from typing import List, Optional, TypedDict from .snowflake import Snowflake, SnowflakeList from .user import User @@ -23,3 +23,7 @@ class Emoji(PartialEmoji, total=False): class EditEmoji(TypedDict): name: str roles: Optional[SnowflakeList] + + +class ListAppEmoji(TypedDict): + items: List[Emoji]