From 91306d7e89b2c2d172454966b91c9b6aa3d8090d Mon Sep 17 00:00:00 2001 From: Katelyn Gigante Date: Tue, 12 Nov 2024 12:25:15 +1100 Subject: [PATCH 1/5] Feat: Add support for Application Emoji --- interactions/api/http/http_requests/emojis.py | 85 +++++++++++++++++++ interactions/client/smart_cache.py | 4 +- interactions/models/discord/application.py | 36 ++++++++ interactions/models/discord/emoji.py | 4 +- 4 files changed, 126 insertions(+), 3 deletions(-) diff --git a/interactions/api/http/http_requests/emojis.py b/interactions/api/http/http_requests/emojis.py index c8a683cd0..1e94616c7 100644 --- a/interactions/api/http/http_requests/emojis.py +++ b/interactions/api/http/http_requests/emojis.py @@ -110,3 +110,88 @@ async def delete_guild_emoji( Route("DELETE", "/guilds/{guild_id}/emojis/{emoji_id}", guild_id=guild_id, emoji_id=emoji_id), reason=reason, ) + + async def get_application_emojis(self, application_id: "Snowflake_Type") -> list[discord_typings.EmojiData]: + """ + Fetch all emojis for this application + + Args: + application_id: The id of the application + + Returns: + List of emojis + + """ + result = await self.request(Route("GET", f"/applications/{application_id}/emojis")) + result = cast(dict, result) + return cast(list[discord_typings.EmojiData], result["items"]) + + async def get_application_emoji( + self, application_id: "Snowflake_Type", emoji_id: "Snowflake_Type" + ) -> discord_typings.EmojiData: + """ + Fetch an emoji for this application + + Args: + application_id: The id of the application + emoji_id: The id of the emoji + + Returns: + Emoji object + + """ + result = await self.request(Route("GET", f"/applications/{application_id}/emojis/{emoji_id}")) + return cast(discord_typings.EmojiData, result) + + async def create_application_emoji( + self, payload: dict, application_id: "Snowflake_Type", reason: str | None = None + ) -> discord_typings.EmojiData: + """ + Create an emoji for this application + + Args: + application_id: The id of the application + name: The name of the emoji + imagefile: The image file to use for the emoji + + Returns: + Emoji object + + """ + result = await self.request( + Route("POST", f"/applications/{application_id}/emojis"), payload=payload, reason=reason + ) + return cast(discord_typings.EmojiData, result) + + async def edit_application_emoji( + self, application_id: "Snowflake_Type", emoji_id: "Snowflake_Type", name: str + ) -> discord_typings.EmojiData: + """ + Edit an emoji for this application + + Args: + application_id: The id of the application + emoji_id: The id of the emoji + name: The new name for the emoji + + Returns: + Emoji object + + """ + result = await self.request( + Route("PATCH", f"/applications/{application_id}/emojis/{emoji_id}"), payload={"name": name} + ) + return cast(discord_typings.EmojiData, result) + + async def delete_application_emoji( + self, application_id: discord_typings.Snowflake, emoji_id: discord_typings.Snowflake + ) -> None: + """ + Delete an emoji for this application + + Args: + application_id: The id of the application + emoji_id: The id of the emoji + + """ + await self.request(Route("DELETE", f"/applications/{application_id}/emojis/{emoji_id}")) diff --git a/interactions/client/smart_cache.py b/interactions/client/smart_cache.py index bc7ab5b14..8f2d7a44e 100644 --- a/interactions/client/smart_cache.py +++ b/interactions/client/smart_cache.py @@ -642,7 +642,7 @@ def place_guild_data(self, data: discord_typings.GuildData) -> Guild: """ guild_id = to_snowflake(data["id"]) - guild: Guild = self.guild_cache.get(guild_id) + guild: Guild | None = self.guild_cache.get(guild_id) if guild is None: guild = Guild.from_dict(data, self._client) self.guild_cache[guild_id] = guild @@ -929,7 +929,7 @@ def place_emoji_data(self, guild_id: "Snowflake_Type", data: discord_typings.Emo with suppress(KeyError): del data["guild_id"] # discord sometimes packages a guild_id - this will cause an exception - emoji = CustomEmoji.from_dict(data, self._client, to_snowflake(guild_id)) + emoji = CustomEmoji.from_dict(data, self._client, to_optional_snowflake(guild_id)) if self.emoji_cache is not None: self.emoji_cache[emoji.id] = emoji diff --git a/interactions/models/discord/application.py b/interactions/models/discord/application.py index f6fdee89d..73a379559 100644 --- a/interactions/models/discord/application.py +++ b/interactions/models/discord/application.py @@ -4,8 +4,11 @@ from interactions.client.const import MISSING from interactions.client.utils.attr_converters import optional +from interactions.client.utils.serializer import to_image_data from interactions.models.discord.asset import Asset +from interactions.models.discord.emoji import PartialEmoji from interactions.models.discord.enums import ApplicationFlags +from interactions.models.discord.file import UPLOADABLE_TYPE from interactions.models.discord.snowflake import Snowflake_Type, to_snowflake from interactions.models.discord.team import Team from .base import DiscordObject @@ -88,3 +91,36 @@ def _process_dict(cls, data: Dict[str, Any], client: "Client") -> Dict[str, Any] def owner(self) -> "User": """The user object for the owner of this application""" return self._client.cache.get_user(self.owner_id) + + async def fetch_all_emoji(self) -> List[PartialEmoji]: + """Fetch all emojis for this application""" + response = await self._client.http.get_application_emojis(self.id) + return [self._client.cache.place_emoji_data(None, emoji) for emoji in response] + + async def fetch_emoji(self, emoji_id: Snowflake_Type) -> PartialEmoji: + """Fetch an emoji for this application""" + return await self._client.cache.place_emoji_data( + None, self._client.http.get_application_emoji(self.id, emoji_id) + ) + + async def create_emoji(self, name: str, imagefile: UPLOADABLE_TYPE) -> PartialEmoji: + """Create an emoji for this application""" + data_payload = { + "name": name, + "image": to_image_data(imagefile), + "roles": MISSING, + } + + return self._client.cache.place_emoji_data( + None, await self._client.http.create_application_emoji(data_payload, self.id) + ) + + async def edit_emoji(self, emoji_id: Snowflake_Type, name: str) -> PartialEmoji: + """Edit an emoji for this application""" + return await self._client.cache.place_emoji_data( + None, self._client.http.edit_application_emoji(self.id, emoji_id, name) + ) + + async def delete_emoji(self, emoji_id: Snowflake_Type) -> None: + """Delete an emoji for this application""" + return await self._client.http.delete_application_emoji(self.id, emoji_id) diff --git a/interactions/models/discord/emoji.py b/interactions/models/discord/emoji.py index 0364a7e5e..99152aa2d 100644 --- a/interactions/models/discord/emoji.py +++ b/interactions/models/discord/emoji.py @@ -38,6 +38,8 @@ class PartialEmoji(SnowflakeObject, DictSerializationMixin): """The custom emoji name, or standard unicode emoji in string""" animated: bool = attrs.field(repr=True, default=False) """Whether this emoji is animated""" + available: bool = attrs.field(repr=False, default=True) + """whether this emoji can be used, may be false due to loss of Server Boosts""" @classmethod def from_str(cls, emoji_str: str, *, language: str = "alias") -> Optional["PartialEmoji"]: @@ -120,7 +122,7 @@ class CustomEmoji(PartialEmoji, ClientObject): _role_ids: List["Snowflake_Type"] = attrs.field( repr=False, factory=list, converter=optional(list_converter(to_snowflake)) ) - _guild_id: "Snowflake_Type" = attrs.field(repr=False, default=None, converter=to_snowflake) + _guild_id: "Snowflake_Type" = attrs.field(repr=False, default=None, converter=optional(to_snowflake)) @classmethod def _process_dict(cls, data: Dict[str, Any], client: "Client") -> Dict[str, Any]: From fce63bc4264e7a16f76b3a4d3c3bb4945ec79d01 Mon Sep 17 00:00:00 2001 From: Katelyn Gigante Date: Wed, 13 Nov 2024 20:20:48 +1100 Subject: [PATCH 2/5] Fix emoji tests --- tests/test_emoji.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_emoji.py b/tests/test_emoji.py index 5539e750f..58c826eda 100644 --- a/tests/test_emoji.py +++ b/tests/test_emoji.py @@ -34,7 +34,7 @@ def test_emoji_formatting() -> None: def test_emoji_processing() -> None: raw_sample = "<:sparklesnek:910496037708374016>" - dict_sample = {"id": 910496037708374016, "name": "sparklesnek", "animated": False} + dict_sample = {"id": 910496037708374016, "name": "sparklesnek", "animated": False, "available": True} unicode_sample = "👍" target = "sparklesnek:910496037708374016" @@ -48,7 +48,7 @@ def test_emoji_processing() -> None: assert isinstance(raw_emoji, dict) and raw_emoji == dict_sample assert isinstance(dict_emoji, dict) and dict_emoji == dict_sample - assert isinstance(unicode_emoji, dict) and unicode_emoji == {"name": "👍", "animated": False} + assert isinstance(unicode_emoji, dict) and unicode_emoji == {"name": "👍", "animated": False, "available": True} from_str = PartialEmoji.from_str(raw_sample) assert from_str.req_format == target From 174fb264386398c8ac3f2b48fed97bbfbef5b5c0 Mon Sep 17 00:00:00 2001 From: AstreaTSS <25420078+AstreaTSS@users.noreply.github.com> Date: Thu, 2 Jan 2025 14:01:56 -0500 Subject: [PATCH 3/5] fix: make sure to await response Original code from #1746, which was closed for some reason. --- interactions/models/discord/application.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/interactions/models/discord/application.py b/interactions/models/discord/application.py index 73a379559..7d7699e42 100644 --- a/interactions/models/discord/application.py +++ b/interactions/models/discord/application.py @@ -99,9 +99,8 @@ async def fetch_all_emoji(self) -> List[PartialEmoji]: async def fetch_emoji(self, emoji_id: Snowflake_Type) -> PartialEmoji: """Fetch an emoji for this application""" - return await self._client.cache.place_emoji_data( - None, self._client.http.get_application_emoji(self.id, emoji_id) - ) + response = await self._client.http.get_application_emoji(self.id, emoji_id) + return await self._client.cache.place_emoji_data(None, response) async def create_emoji(self, name: str, imagefile: UPLOADABLE_TYPE) -> PartialEmoji: """Create an emoji for this application""" From b798fbe80611234047e4ae4aa8422781c5b093fb Mon Sep 17 00:00:00 2001 From: AstreaTSS <25420078+AstreaTSS@users.noreply.github.com> Date: Thu, 2 Jan 2025 14:17:28 -0500 Subject: [PATCH 4/5] feat: adjust methods in CustomEmoji to respect app emojis --- interactions/models/discord/emoji.py | 38 ++++++++++++++++++---------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/interactions/models/discord/emoji.py b/interactions/models/discord/emoji.py index 99152aa2d..8cef9d733 100644 --- a/interactions/models/discord/emoji.py +++ b/interactions/models/discord/emoji.py @@ -122,7 +122,7 @@ class CustomEmoji(PartialEmoji, ClientObject): _role_ids: List["Snowflake_Type"] = attrs.field( repr=False, factory=list, converter=optional(list_converter(to_snowflake)) ) - _guild_id: "Snowflake_Type" = attrs.field(repr=False, default=None, converter=optional(to_snowflake)) + _guild_id: "Optional[Snowflake_Type]" = attrs.field(repr=False, default=None, converter=optional(to_snowflake)) @classmethod def _process_dict(cls, data: Dict[str, Any], client: "Client") -> Dict[str, Any]: @@ -140,8 +140,8 @@ def from_dict(cls, data: Dict[str, Any], client: "Client", guild_id: int) -> "Cu return cls(client=client, guild_id=guild_id, **cls._filter_kwargs(data, cls._get_init_keys())) @property - def guild(self) -> "Guild": - """The guild this emoji belongs to.""" + def guild(self) -> "Optional[Guild]": + """The guild this emoji belongs to, if applicable.""" return self._client.cache.get_guild(self._guild_id) @property @@ -162,6 +162,9 @@ def is_usable(self) -> bool: if not self.available: return False + if not self._guild_id: # likely an application emoji + return True + guild = self.guild return any(e_role_id in guild.me._role_ids for e_role_id in self._role_ids) @@ -184,14 +187,23 @@ async def edit( The newly modified custom emoji. """ - data_payload = dict_filter_none( - { - "name": name, - "roles": to_snowflake_list(roles) if roles else None, - } - ) + if self._guild_id: + data_payload = dict_filter_none( + { + "name": name, + "roles": to_snowflake_list(roles) if roles else None, + } + ) + + updated_data = await self._client.http.modify_guild_emoji( + data_payload, self._guild_id, self.id, reason=reason + ) + else: + if roles or reason: + raise ValueError("Cannot specify roles or reason for application emoji.") + + updated_data = await self.client.http.edit_application_emoji(self.bot.app.id, self.id, name) - updated_data = await self._client.http.modify_guild_emoji(data_payload, self._guild_id, self.id, reason=reason) self.update_from_dict(updated_data) return self @@ -204,9 +216,9 @@ async def delete(self, reason: Optional[str] = None) -> None: """ if not self._guild_id: - raise ValueError("Cannot delete emoji, no guild id set.") - - await self._client.http.delete_guild_emoji(self._guild_id, self.id, reason=reason) + await self.client.http.delete_application_emoji(self._client.app.id, self.id) + else: + await self._client.http.delete_guild_emoji(self._guild_id, self.id, reason=reason) @property def url(self) -> str: From 54258f67fcc725d9541d81d91b5503265db714cf Mon Sep 17 00:00:00 2001 From: AstreaTSS <25420078+AstreaTSS@users.noreply.github.com> Date: Thu, 2 Jan 2025 14:31:45 -0500 Subject: [PATCH 5/5] fix: adjust app emoji code based off testing --- interactions/client/smart_cache.py | 2 +- interactions/models/discord/application.py | 28 +++++++++++----------- interactions/models/discord/emoji.py | 5 +++- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/interactions/client/smart_cache.py b/interactions/client/smart_cache.py index 8f2d7a44e..c7090a616 100644 --- a/interactions/client/smart_cache.py +++ b/interactions/client/smart_cache.py @@ -914,7 +914,7 @@ def get_emoji(self, emoji_id: Optional["Snowflake_Type"]) -> Optional["CustomEmo """ return self.emoji_cache.get(to_optional_snowflake(emoji_id)) if self.emoji_cache is not None else None - def place_emoji_data(self, guild_id: "Snowflake_Type", data: discord_typings.EmojiData) -> "CustomEmoji": + def place_emoji_data(self, guild_id: "Snowflake_Type | None", data: discord_typings.EmojiData) -> "CustomEmoji": """ Take json data representing an emoji, process it, and cache it. This cache is disabled by default, start your bot with `Client(enable_emoji_cache=True)` to enable it. diff --git a/interactions/models/discord/application.py b/interactions/models/discord/application.py index 7d7699e42..b35949754 100644 --- a/interactions/models/discord/application.py +++ b/interactions/models/discord/application.py @@ -6,7 +6,7 @@ from interactions.client.utils.attr_converters import optional from interactions.client.utils.serializer import to_image_data from interactions.models.discord.asset import Asset -from interactions.models.discord.emoji import PartialEmoji +from interactions.models.discord.emoji import CustomEmoji from interactions.models.discord.enums import ApplicationFlags from interactions.models.discord.file import UPLOADABLE_TYPE from interactions.models.discord.snowflake import Snowflake_Type, to_snowflake @@ -92,17 +92,17 @@ def owner(self) -> "User": """The user object for the owner of this application""" return self._client.cache.get_user(self.owner_id) - async def fetch_all_emoji(self) -> List[PartialEmoji]: + async def fetch_all_emoji(self) -> List[CustomEmoji]: """Fetch all emojis for this application""" - response = await self._client.http.get_application_emojis(self.id) - return [self._client.cache.place_emoji_data(None, emoji) for emoji in response] + response = await self.client.http.get_application_emojis(self.id) + return [self.client.cache.place_emoji_data(None, emoji) for emoji in response] - async def fetch_emoji(self, emoji_id: Snowflake_Type) -> PartialEmoji: + async def fetch_emoji(self, emoji_id: Snowflake_Type) -> CustomEmoji: """Fetch an emoji for this application""" - response = await self._client.http.get_application_emoji(self.id, emoji_id) - return await self._client.cache.place_emoji_data(None, response) + response = await self.client.http.get_application_emoji(self.id, emoji_id) + return self.client.cache.place_emoji_data(None, response) - async def create_emoji(self, name: str, imagefile: UPLOADABLE_TYPE) -> PartialEmoji: + async def create_emoji(self, name: str, imagefile: UPLOADABLE_TYPE) -> CustomEmoji: """Create an emoji for this application""" data_payload = { "name": name, @@ -110,16 +110,16 @@ async def create_emoji(self, name: str, imagefile: UPLOADABLE_TYPE) -> PartialEm "roles": MISSING, } - return self._client.cache.place_emoji_data( - None, await self._client.http.create_application_emoji(data_payload, self.id) + return self.client.cache.place_emoji_data( + None, await self.client.http.create_application_emoji(data_payload, self.id) ) - async def edit_emoji(self, emoji_id: Snowflake_Type, name: str) -> PartialEmoji: + async def edit_emoji(self, emoji_id: Snowflake_Type, name: str) -> CustomEmoji: """Edit an emoji for this application""" - return await self._client.cache.place_emoji_data( - None, self._client.http.edit_application_emoji(self.id, emoji_id, name) + return self.client.cache.place_emoji_data( + None, await self.client.http.edit_application_emoji(self.id, emoji_id, name) ) async def delete_emoji(self, emoji_id: Snowflake_Type) -> None: """Delete an emoji for this application""" - return await self._client.http.delete_application_emoji(self.id, emoji_id) + await self.client.http.delete_application_emoji(self.id, emoji_id) diff --git a/interactions/models/discord/emoji.py b/interactions/models/discord/emoji.py index 8cef9d733..e9a91530c 100644 --- a/interactions/models/discord/emoji.py +++ b/interactions/models/discord/emoji.py @@ -135,7 +135,7 @@ def _process_dict(cls, data: Dict[str, Any], client: "Client") -> Dict[str, Any] return data @classmethod - def from_dict(cls, data: Dict[str, Any], client: "Client", guild_id: int) -> "CustomEmoji": + def from_dict(cls, data: Dict[str, Any], client: "Client", guild_id: "Optional[Snowflake_Type]") -> "CustomEmoji": data = cls._process_dict(data, client) return cls(client=client, guild_id=guild_id, **cls._filter_kwargs(data, cls._get_init_keys())) @@ -216,6 +216,9 @@ async def delete(self, reason: Optional[str] = None) -> None: """ if not self._guild_id: + if reason: + raise ValueError("Cannot specify reason for application emoji.") + await self.client.http.delete_application_emoji(self._client.app.id, self.id) else: await self._client.http.delete_guild_emoji(self._guild_id, self.id, reason=reason)