diff --git a/changelog/1223.breaking.rst b/changelog/1223.breaking.rst new file mode 100644 index 0000000000..790cbfd63a --- /dev/null +++ b/changelog/1223.breaking.rst @@ -0,0 +1 @@ +:attr:`Emoji.guild_id` can now be ``None`` is the emoji is owned by an application. You can use :meth:`Emoji.is_app_emoji` to check for that. diff --git a/changelog/1223.feature.rst b/changelog/1223.feature.rst new file mode 100644 index 0000000000..ebbd5b21ec --- /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 fetch and create application emojis: :meth:`Client.fetch_application_emoji`, :meth:`Client.fetch_application_emojis` and :meth:`Client.create_application_emoji`. diff --git a/disnake/client.py b/disnake/client.py index 9e0ee512d3..8394ec7fbf 100644 --- a/disnake/client.py +++ b/disnake/client.py @@ -2420,6 +2420,92 @@ async def application_info(self) -> AppInfo: data["rpc_origins"] = None 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:: 2.11 + + 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:: 2.11 + + 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:: 2.11 + + 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] + async def fetch_user(self, user_id: int, /) -> User: """|coro| diff --git a/disnake/emoji.py b/disnake/emoji.py index badedbce86..d57fafc20e 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.11 + + This class can now represents app emojis too. You can use :meth:`Emoji.is_app_emoji` to check for this. + 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,42 @@ 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.11 + + 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.11 + """ + if self.guild_id is None: + return None + return self._state.application_id + + @property + def is_app_emoji(self) -> bool: + """:class:`bool`: Whether this is an application emoji. + + .. versionadded:: 2.11 + """ + if self.guild_id is None: + return True + return False def is_usable(self) -> bool: """Whether the bot can use this emoji. @@ -173,6 +207,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 +232,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("This may be a library bug! Open an issue on GitHub.") + + 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 +285,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("This may be a library bug! Open an issue on GitHub.") + + 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 b258985016..cd8ebdebc9 100644 --- a/disnake/http.py +++ b/disnake/http.py @@ -1725,6 +1725,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)) @@ -1735,6 +1745,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,