diff --git a/interactions/api/events/processors/_template.py b/interactions/api/events/processors/_template.py index 2d8c57f0d..7174ce6d0 100644 --- a/interactions/api/events/processors/_template.py +++ b/interactions/api/events/processors/_template.py @@ -1,6 +1,7 @@ import asyncio import functools import inspect +import logging from typing import TYPE_CHECKING, Callable, Coroutine from interactions.client.const import Absent, MISSING, AsyncCallable @@ -40,7 +41,9 @@ class EventMixinTemplate: cache: "GlobalCache" dispatch: Callable[["BaseEvent"], None] + fetch_members: bool _init_interactions: Callable[[], Coroutine] + logger: logging.Logger synchronise_interactions: Callable[[], Coroutine] _user: ClientUser _guild_event: asyncio.Event diff --git a/interactions/api/events/processors/_template.pyi b/interactions/api/events/processors/_template.pyi deleted file mode 100644 index 3a4e70c55..000000000 --- a/interactions/api/events/processors/_template.pyi +++ /dev/null @@ -1,14 +0,0 @@ -from typing import Callable - -from interactions import Client -from interactions.client.const import Absent, AsyncCallable - -class Processor: - callback: AsyncCallable - event_name: str - def __init__(self, callback: AsyncCallable, name: str) -> None: ... - @classmethod - def define(cls, event_name: Absent[str] = ...) -> Callable[[AsyncCallable], "Processor"]: ... - -class EventMixinTemplate(Client): - def __init__(self) -> None: ... diff --git a/interactions/api/events/processors/auto_mod.py b/interactions/api/events/processors/auto_mod.py index dfee26c2d..d97851caf 100644 --- a/interactions/api/events/processors/auto_mod.py +++ b/interactions/api/events/processors/auto_mod.py @@ -15,24 +15,24 @@ class AutoModEvents(EventMixinTemplate): @Processor.define() async def _raw_auto_moderation_action_execution(self, event: "RawGatewayEvent") -> None: action = AutoModerationAction.from_dict(event.data.copy(), self) - channel = self.get_channel(event.data.get("channel_id")) - guild = self.get_guild(event.data["guild_id"]) + channel = self.cache.get_channel(event.data.get("channel_id")) + guild = self.cache.get_guild(event.data["guild_id"]) self.dispatch(events.AutoModExec(action, channel, guild)) @Processor.define() async def raw_auto_moderation_rule_create(self, event: "RawGatewayEvent") -> None: rule = AutoModRule.from_dict(event.data, self) - guild = self.get_guild(event.data["guild_id"]) + guild = self.cache.get_guild(event.data["guild_id"]) self.dispatch(events.AutoModCreated(guild, rule)) @Processor.define() async def raw_auto_moderation_rule_update(self, event: "RawGatewayEvent") -> None: rule = AutoModRule.from_dict(event.data, self) - guild = self.get_guild(event.data["guild_id"]) + guild = self.cache.get_guild(event.data["guild_id"]) self.dispatch(events.AutoModUpdated(guild, rule)) @Processor.define() async def raw_auto_moderation_rule_delete(self, event: "RawGatewayEvent") -> None: rule = AutoModRule.from_dict(event.data, self) - guild = self.get_guild(event.data["guild_id"]) + guild = self.cache.get_guild(event.data["guild_id"]) self.dispatch(events.AutoModDeleted(guild, rule)) diff --git a/interactions/api/events/processors/integrations.py b/interactions/api/events/processors/integrations.py index 12615dcdb..9aebf379e 100644 --- a/interactions/api/events/processors/integrations.py +++ b/interactions/api/events/processors/integrations.py @@ -23,7 +23,7 @@ async def _raw_application_command_permissions_update(self, event: "RawGatewayEv command_id = to_snowflake(event.data["id"]) application_id = to_snowflake(event.data["application_id"]) - if guild := self.get_guild(guild_id): + if guild := self.cache.get_guild(guild_id): if guild.permissions: if command_id not in guild.command_permissions: guild.command_permissions[command_id] = CommandPermissions( diff --git a/interactions/api/events/processors/voice_events.py b/interactions/api/events/processors/voice_events.py index ea243a414..77d3dbfc2 100644 --- a/interactions/api/events/processors/voice_events.py +++ b/interactions/api/events/processors/voice_events.py @@ -13,7 +13,7 @@ class VoiceEvents(EventMixinTemplate): @Processor.define() async def _on_raw_voice_state_update(self, event: "RawGatewayEvent") -> None: - if str(event.data["user_id"]) == str(self.user.id): + if str(event.data["user_id"]) == str(self._user.id): # User is the bot itself before = copy.copy(self.cache.get_bot_voice_state(event.data["guild_id"])) or None after = await self.cache.place_voice_state_data(event.data, update_cache=False) 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/api/http/http_requests/webhooks.py b/interactions/api/http/http_requests/webhooks.py index f5b372c1c..1aa098a3b 100644 --- a/interactions/api/http/http_requests/webhooks.py +++ b/interactions/api/http/http_requests/webhooks.py @@ -126,6 +126,7 @@ async def execute_webhook( payload: dict, wait: bool = False, thread_id: "Snowflake_Type" = None, + thread_name: Optional[str] = None, files: list["UPLOADABLE_TYPE"] | None = None, ) -> Optional[discord_typings.MessageData]: """ @@ -136,13 +137,16 @@ async def execute_webhook( webhook_token: The token for the webhook payload: The JSON payload for the message wait: Waits for server confirmation of message send before response - thread_id: Send a message to the specified thread + thread_id: Send a message to the specified thread. Note that this cannot be used with `thread_name` + thread_name: Create a thread with this name. Note that this is only valid for forum channel and cannot be used with `thread_id` files: The files to send with this message Returns: The sent `message`, if `wait` is True else None """ + if thread_name is not None: + payload["thread_name"] = thread_name return await self.request( Route("POST", "/webhooks/{webhook_id}/{webhook_token}", webhook_id=webhook_id, webhook_token=webhook_token), params=dict_filter_none({"wait": "true" if wait else "false", "thread_id": thread_id}), diff --git a/interactions/client/client.py b/interactions/client/client.py index 3c4fed523..0407fc69c 100644 --- a/interactions/client/client.py +++ b/interactions/client/client.py @@ -534,6 +534,16 @@ def guilds(self) -> List["Guild"]: """Returns a list of all guilds the bot is in.""" return self.user.guilds + @property + def guild_count(self) -> int: + """ + Returns the number of guilds the bot is in. + + This function is faster than using `len(client.guilds)` as it does not require using the cache. + As such, this is preferred when you only need the count of guilds. + """ + return self.user.guild_count + @property def status(self) -> Status: """ diff --git a/interactions/client/smart_cache.py b/interactions/client/smart_cache.py index bc7ab5b14..c7090a616 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 @@ -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. @@ -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/ext/debug_extension/__init__.py b/interactions/ext/debug_extension/__init__.py index b018448a8..d05050b90 100644 --- a/interactions/ext/debug_extension/__init__.py +++ b/interactions/ext/debug_extension/__init__.py @@ -76,7 +76,7 @@ async def debug_info(self, ctx: InteractionContext) -> None: e.add_field("Loaded Exts", ", ".join(self.bot.ext)) - e.add_field("Guilds", str(len(self.bot.guilds))) + e.add_field("Guilds", str(self.bot.guild_count)) await ctx.send(embeds=[e]) diff --git a/interactions/models/discord/application.py b/interactions/models/discord/application.py index f6fdee89d..b35949754 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 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 from interactions.models.discord.team import Team from .base import DiscordObject @@ -88,3 +91,35 @@ 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[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] + + 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 self.client.cache.place_emoji_data(None, response) + + async def create_emoji(self, name: str, imagefile: UPLOADABLE_TYPE) -> CustomEmoji: + """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) -> CustomEmoji: + """Edit an emoji for this application""" + 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""" + await self.client.http.delete_application_emoji(self.id, emoji_id) diff --git a/interactions/models/discord/channel.py b/interactions/models/discord/channel.py index 539393ff8..ff2792ef2 100644 --- a/interactions/models/discord/channel.py +++ b/interactions/models/discord/channel.py @@ -2399,7 +2399,7 @@ async def close_stage(self, reason: Absent[Optional[str]] = MISSING) -> None: @attrs.define(eq=False, order=False, hash=False, kw_only=True) -class GuildForum(GuildChannel, InvitableMixin): +class GuildForum(GuildChannel, InvitableMixin, WebhookMixin): available_tags: List[ThreadTag] = attrs.field(repr=False, factory=list) """A list of tags available to assign to threads""" default_reaction_emoji: Optional[DefaultReaction] = attrs.field(repr=False, default=None) diff --git a/interactions/models/discord/emoji.py b/interactions/models/discord/emoji.py index 0364a7e5e..e9a91530c 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: "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]: @@ -133,13 +135,13 @@ 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())) @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 @@ -160,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) @@ -182,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 @@ -202,9 +216,12 @@ async def delete(self, reason: Optional[str] = None) -> None: """ if not self._guild_id: - raise ValueError("Cannot delete emoji, no guild id set.") + if reason: + raise ValueError("Cannot specify reason for application emoji.") - 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: diff --git a/interactions/models/discord/enums.py b/interactions/models/discord/enums.py index 75dab61b1..c437a619e 100644 --- a/interactions/models/discord/enums.py +++ b/interactions/models/discord/enums.py @@ -399,6 +399,7 @@ class MessageType(CursedIntEnum): GUILD_INCIDENT_REPORT_RAID = 38 GUILD_INCIDENT_REPORT_FALSE_ALARM = 39 PURCHASE_NOTIFICATION = 44 + POLL_RESULT = 46 @classmethod def deletable(cls) -> Tuple["MessageType", ...]: @@ -434,6 +435,7 @@ def deletable(cls) -> Tuple["MessageType", ...]: cls.GUILD_INCIDENT_REPORT_RAID, cls.GUILD_INCIDENT_REPORT_FALSE_ALARM, cls.PURCHASE_NOTIFICATION, + cls.POLL_RESULT, ) diff --git a/interactions/models/discord/user.py b/interactions/models/discord/user.py index bfe62efeb..0ac24ba35 100644 --- a/interactions/models/discord/user.py +++ b/interactions/models/discord/user.py @@ -239,6 +239,16 @@ def guilds(self) -> List["Guild"]: """The guilds the user is in.""" return list(filter(None, (self._client.cache.get_guild(guild_id) for guild_id in self._guild_ids))) + @property + def guild_count(self) -> int: + """ + Returns the number of guilds the bot is in. + + This function is faster than using `len(client_user.guilds)` as it does not require using the cache. + As such, this is preferred when you only need the count of guilds. + """ + return len(self._guild_ids or ()) + async def edit( self, *, diff --git a/interactions/models/discord/user.pyi b/interactions/models/discord/user.pyi index f84af990d..c0dfdece9 100644 --- a/interactions/models/discord/user.pyi +++ b/interactions/models/discord/user.pyi @@ -95,6 +95,8 @@ class ClientUser(User): def _add_guilds(self, guild_ids: Set["Snowflake_Type"]) -> None: ... @property def guilds(self) -> List["Guild"]: ... + @property + def guild_count(self) -> int: ... async def edit( self, *, diff --git a/interactions/models/discord/webhooks.py b/interactions/models/discord/webhooks.py index 548eaec9f..96a614b94 100644 --- a/interactions/models/discord/webhooks.py +++ b/interactions/models/discord/webhooks.py @@ -196,6 +196,7 @@ async def send( avatar_url: str | None = None, wait: bool = False, thread: "Snowflake_Type" = None, + thread_name: Optional[str] = None, **kwargs, ) -> Optional["Message"]: """ @@ -218,7 +219,8 @@ async def send( username: The username to use avatar_url: The url of an image to use as the avatar wait: Waits for confirmation of delivery. Set this to True if you intend to edit the message - thread: Send this webhook to a thread channel + thread: Send this webhook to a thread channel. Note that this cannot be used with `thread_name` set + thread_name: Create a thread with `thread_name` with this webhook. Note that this is only valid for forum channel and cannot be used with `thread` set Returns: New message object that was sent if `wait` is set to True @@ -230,6 +232,11 @@ async def send( if not content and not embeds and not embed and not files and not file and not stickers: raise EmptyMessageException("You cannot send a message without any content, embeds, files, or stickers") + if thread is not None and thread_name is not None: + raise ValueError( + "You cannot create a thread and send the message to another thread with a webhook at the same time!" + ) + if suppress_embeds: if isinstance(flags, int): flags = MessageFlags(flags) @@ -256,6 +263,7 @@ async def send( message_payload, wait, to_optional_snowflake(thread), + thread_name, files=files or file, ) if message_data: diff --git a/interactions/models/internal/extension.py b/interactions/models/internal/extension.py index ff434de77..e2b0f6a70 100644 --- a/interactions/models/internal/extension.py +++ b/interactions/models/internal/extension.py @@ -47,7 +47,7 @@ async def some_command(self, context): """ - bot: "Client" + _bot: "Client" name: str extension_name: str description: str @@ -77,8 +77,6 @@ class Metadata: def __new__(cls, bot: "Client", *args, **kwargs) -> "Extension": instance = super().__new__(cls) instance.bot = bot - instance.client = bot - instance.name = cls.__name__ if instance.name in bot.ext: @@ -140,6 +138,22 @@ def __new__(cls, bot: "Client", *args, **kwargs) -> "Extension": def __name__(self) -> str: return self.name + @property + def bot(self) -> "Client": + return self._bot + + @bot.setter + def bot(self, value: "Client") -> None: + self._bot = value + + @property + def client(self) -> "Client": + return self._bot + + @client.setter + def client(self, value: "Client") -> None: + self._bot = value + @property def commands(self) -> List["BaseCommand"]: """Get the commands from this Extension.""" 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