From d25be56bf248a3b322b4cc9a52df1d041f7b4600 Mon Sep 17 00:00:00 2001 From: Michael Felix Date: Sun, 2 Mar 2025 12:22:24 -0500 Subject: [PATCH 01/11] feat: add support for nsfw for ThreadChannel --- interactions/models/discord/channel.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/interactions/models/discord/channel.py b/interactions/models/discord/channel.py index ff2792ef2..55b988622 100644 --- a/interactions/models/discord/channel.py +++ b/interactions/models/discord/channel.py @@ -1900,6 +1900,10 @@ def parent_channel(self) -> Union[GuildText, "GuildForum"]: """The channel this thread is a child of.""" return self._client.cache.get_channel(self.parent_id) + @property + def nsfw(self) -> bool: + return self.parent_channel.nsfw + @property def parent_message(self) -> Optional["Message"]: """The message this thread is a child of.""" From 9048b93267a0884646436bfba8cd08b306678190 Mon Sep 17 00:00:00 2001 From: mifuyutsuki Date: Sun, 9 Mar 2025 17:33:16 +0700 Subject: [PATCH 02/11] Merge pull request #1757 from mifuyutsuki/feat-member-banner feat: add guild member banner field --- interactions/models/discord/user.py | 14 ++++++++++++++ interactions/models/discord/user.pyi | 3 +++ 2 files changed, 17 insertions(+) diff --git a/interactions/models/discord/user.py b/interactions/models/discord/user.py index 0ac24ba35..1b2b02933 100644 --- a/interactions/models/discord/user.py +++ b/interactions/models/discord/user.py @@ -328,6 +328,9 @@ class Member(DiscordObject, _SendDMMixin): metadata=docs("Whether the user has **not** passed guild's membership screening requirements"), ) guild_avatar: "Asset" = attrs.field(repr=False, default=None, metadata=docs("The user's guild avatar")) + guild_banner: Optional["Asset"] = attrs.field( + repr=False, default=None, metadata=docs("The user's guild banner, if any") + ) communication_disabled_until: Optional["Timestamp"] = attrs.field( default=None, converter=optional_c(timestamp_converter), @@ -372,6 +375,12 @@ def _process_dict(cls, data: Dict[str, Any], client: "Client") -> Dict[str, Any] f"[DEBUG NEEDED - REPORT THIS] Incomplete dictionary has been passed to member object: {e}" ) raise + if data.get("banner"): + data["guild_banner"] = Asset.from_path_hash( + client, + f"guilds/{data['guild_id']}/users/{data['id']}/banners/{{}}", + data.pop("banner", None), + ) data["role_ids"] = data.pop("roles", []) @@ -440,6 +449,11 @@ def avatar_url(self) -> str: """The users avatar url.""" return self.display_avatar.url + @property + def banner(self) -> Optional["Asset"]: + """The user's banner, if any, will return `guild_banner` if one is set, otherwise will return user banner.""" + return self.guild_banner or self.user.banner + @property def premium(self) -> bool: """Is this member a server booster?""" diff --git a/interactions/models/discord/user.pyi b/interactions/models/discord/user.pyi index c0dfdece9..7fec74e2a 100644 --- a/interactions/models/discord/user.pyi +++ b/interactions/models/discord/user.pyi @@ -116,6 +116,7 @@ class Member(FakeUserMixin): premium_since: Optional["Timestamp"] pending: Optional[bool] guild_avatar: Asset + guild_banner: Optional[Asset] communication_disabled_until: Optional["Timestamp"] _guild_id: Snowflake_Type _role_ids: List["Snowflake_Type"] @@ -143,6 +144,8 @@ class Member(FakeUserMixin): @property def avatar_url(self) -> str: ... @property + def banner(self) -> Optional[Asset]: ... + @property def premium(self) -> bool: ... @property def guild_permissions(self) -> Permissions: ... From d29f76fba68f4b0de07e751d182d559242a838bf Mon Sep 17 00:00:00 2001 From: Michael Felix Date: Wed, 12 Mar 2025 04:19:28 -0400 Subject: [PATCH 03/11] fix: Invite._process_dict incorrectly passed all data (#1756) * fix: Invite._process_dict incorrectly passed all data * ci: correct from checks. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- interactions/models/discord/invite.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/interactions/models/discord/invite.py b/interactions/models/discord/invite.py index b7a45fe94..026e573c9 100644 --- a/interactions/models/discord/invite.py +++ b/interactions/models/discord/invite.py @@ -95,7 +95,8 @@ def _process_dict(cls, data: Dict[str, Any], client: "Client") -> Dict[str, Any] data["stage_instance"] = StageInstance.from_dict(data, client) if "target_application" in data: - data["target_application"] = Application.from_dict(data, client) + app_data = data["target_application"] + data["target_application"] = Application.from_dict(app_data, client) if "target_event_id" in data: data["scheduled_event"] = data["target_event_id"] From 7233db22fcd4477a0a1ecc374dad10e00c4dc3f6 Mon Sep 17 00:00:00 2001 From: Michael Felix Date: Wed, 12 Mar 2025 04:20:02 -0400 Subject: [PATCH 04/11] fix: add permission checks before fetching messages in reaction events (#1754) * fix: add permission checks before fetching messages in reaction events * refactor: extract permission check logic * fix: add test for refactored reactionevents --- .../api/events/processors/reaction_events.py | 51 ++++++++++++++++--- tests/test_bot.py | 33 ++++++++++++ 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/interactions/api/events/processors/reaction_events.py b/interactions/api/events/processors/reaction_events.py index 2f1de264f..7d234341a 100644 --- a/interactions/api/events/processors/reaction_events.py +++ b/interactions/api/events/processors/reaction_events.py @@ -1,7 +1,7 @@ from typing import TYPE_CHECKING import interactions.api.events as events -from interactions.models import PartialEmoji, Reaction +from interactions.models import PartialEmoji, Reaction, Message, Permissions from ._template import EventMixinTemplate, Processor @@ -12,6 +12,29 @@ class ReactionEvents(EventMixinTemplate): + async def _check_message_fetch_permissions(self, channel_id: str, guild_id: str | None) -> bool: + """ + Check if the bot has permissions to fetch a message in the given channel. + + Args: + channel_id: The ID of the channel to check + guild_id: The ID of the guild, if any + + Returns: + bool: True if the bot has permission to fetch messages, False otherwise + + """ + if not guild_id: # DMs always have permission + return True + + channel = await self.cache.fetch_channel(channel_id) + if not channel: + return False + + bot_member = channel.guild.me + ctx_perms = channel.permissions_for(bot_member) + return Permissions.READ_MESSAGE_HISTORY in ctx_perms + async def _handle_message_reaction_change(self, event: "RawGatewayEvent", add: bool) -> None: if member := event.data.get("member"): author = self.cache.place_member_data(event.data.get("guild_id"), member) @@ -53,11 +76,27 @@ async def _handle_message_reaction_change(self, event: "RawGatewayEvent", add: b message.reactions.append(reaction) else: - message = await self.cache.fetch_message(event.data.get("channel_id"), event.data.get("message_id")) - for r in message.reactions: - if r.emoji == emoji: - reaction = r - break + guild_id = event.data.get("guild_id") + channel_id = event.data.get("channel_id") + + if await self._check_message_fetch_permissions(channel_id, guild_id): + message = await self.cache.fetch_message(channel_id, event.data.get("message_id")) + for r in message.reactions: + if r.emoji == emoji: + reaction = r + break + + if not message: # otherwise construct skeleton message with no reactions + message = Message.from_dict( + { + "id": event.data.get("message_id"), + "channel_id": channel_id, + "guild_id": guild_id, + "reactions": [], + }, + self, + ) + if add: self.dispatch(events.MessageReactionAdd(message=message, emoji=emoji, author=author, reaction=reaction)) else: diff --git a/tests/test_bot.py b/tests/test_bot.py index 33267eaf5..7aa66cd8c 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -127,6 +127,39 @@ def ensure_attributes(target_object) -> None: getattr(target_object, attr) +@pytest.mark.asyncio +async def test_reaction_events(bot: Client, guild: Guild) -> None: + """ + Tests reaction event handling on an uncached message. + + Requires manual setup: + 1. Set TARGET_CHANNEL_ID environment variable to a valid channel ID. + 2. A user must add a reaction to the test message within 60 seconds. + """ + # Skip test if target channel not provided + target_channel_id = os.environ.get("BOT_TEST_CHANNEL_ID") + if not target_channel_id: + pytest.skip("Set TARGET_CHANNEL_ID to run this test") + + # Get channel and post test message + channel = await bot.fetch_channel(target_channel_id) + test_msg = await channel.send("Reaction Event Test - React with ✅ within 60 seconds") + + try: + # simulate uncached state + bot.cache.delete_message(message_id=test_msg.id, channel_id=test_msg.channel.id) + + # wait for user to react with checkmark + reaction_event = await bot.wait_for( + "message_reaction_add", timeout=60, checks=lambda e: e.message.id == test_msg.id and str(e.emoji) == "✅" + ) + + assert reaction_event.message.id == test_msg.id + assert reaction_event.emoji.name == "✅" + finally: + await test_msg.delete() + + @pytest.mark.asyncio async def test_channels(bot: Client, guild: Guild) -> None: channels = [ From 06df222a2a0914357a0035543364d7a47a827f5c Mon Sep 17 00:00:00 2001 From: Katelyn Gigante Date: Fri, 4 Apr 2025 13:10:39 +1100 Subject: [PATCH 05/11] fix: Actually place channel ids into cache.dm_channels (#1761) --- interactions/client/smart_cache.py | 1 + 1 file changed, 1 insertion(+) diff --git a/interactions/client/smart_cache.py b/interactions/client/smart_cache.py index c7090a616..4b6b9cbe0 100644 --- a/interactions/client/smart_cache.py +++ b/interactions/client/smart_cache.py @@ -550,6 +550,7 @@ async def fetch_dm_channel_id(self, user_id: "Snowflake_Type", *, force: bool = data = await self._client.http.create_dm(user_id) channel = self.place_channel_data(data) channel_id = channel.id + self.place_dm_channel_id(user_id, channel_id) return channel_id async def fetch_dm_channel(self, user_id: "Snowflake_Type", *, force: bool = False) -> "DM": From 1abdf03dbf76cfab56a87f27650699da0a450aea Mon Sep 17 00:00:00 2001 From: GeomKid <51281740+GeomKid@users.noreply.github.com> Date: Fri, 4 Apr 2025 10:12:05 +0800 Subject: [PATCH 06/11] fix: Deprecation of default_permission in Application Command (#1762) --- interactions/client/utils/deserialise_app_cmds.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/interactions/client/utils/deserialise_app_cmds.py b/interactions/client/utils/deserialise_app_cmds.py index 723b62bee..721e8926f 100644 --- a/interactions/client/utils/deserialise_app_cmds.py +++ b/interactions/client/utils/deserialise_app_cmds.py @@ -41,7 +41,9 @@ def deserialize_app_cmds(data: list[dict]) -> list["InteractionCommand"]: cmd_dict["scopes"] = [cmd_dict.pop("guild_id", const.GLOBAL_SCOPE)] del cmd_dict["version"] - del cmd_dict["default_permission"] + if hasattr(cmd_dict, "default_permission"): + del cmd_dict["default_permission"] + cmd = command_mapping[cmd_type](**cmd_dict) # type: ignore if options: From 351322e317833ac8e5b18f89d2b73c2e6bf25711 Mon Sep 17 00:00:00 2001 From: Katelyn Gigante Date: Sun, 4 May 2025 13:44:23 +1000 Subject: [PATCH 07/11] feat: Components v2 (#1760) * feat: untested prototype impl of v2 components Likely not to work. Very unpolished and pretty much takes directly from the docs. It's here, though! * fix: Add missing enums * fix: Throw error when v2 components are used with incompatible things * test: Test command for v2 components * fix: Don't hardcode the possible subcomponents of a section * Add imports --------- Co-authored-by: Astrea <25420078+AstreaTSS@users.noreply.github.com> --- interactions/__init__.py | 108 +++++--- interactions/models/__init__.py | 72 +++-- interactions/models/discord/__init__.py | 36 ++- interactions/models/discord/components.py | 304 +++++++++++++++++++++- interactions/models/discord/enums.py | 52 ++++ interactions/models/discord/message.py | 11 + main.py | 44 ++++ 7 files changed, 540 insertions(+), 87 deletions(-) diff --git a/interactions/__init__.py b/interactions/__init__.py index e4b9cce38..292e71f38 100644 --- a/interactions/__init__.py +++ b/interactions/__init__.py @@ -54,8 +54,8 @@ ActivityTimestamps, ActivityType, AllowedMentions, - Application, application_commands_to_dict, + Application, ApplicationCommandPermission, ApplicationFlags, Asset, @@ -99,8 +99,8 @@ ChannelType, check, ClientUser, - Color, COLOR_TYPES, + Color, Colour, CommandType, component_callback, @@ -108,10 +108,11 @@ ComponentContext, ComponentType, ConsumeRest, - contexts, + ContainerComponent, context_menu, ContextMenu, ContextMenuContext, + contexts, ContextType, Converter, cooldown, @@ -123,8 +124,8 @@ DateTrigger, DefaultNotificationLevel, DefaultReaction, - DM, dm_only, + DM, DMChannel, DMChannelConverter, DMConverter, @@ -138,17 +139,20 @@ EmbedProvider, Entitlement, ExplicitContentFilterLevel, + ExponentialBackoffSystem, Extension, File, + FileComponent, FlatUIColors, FlatUIColours, ForumLayoutType, + ForumSortOrder, get_components_ids, global_autocomplete, GlobalAutoComplete, Greedy, - Guild, guild_only, + Guild, GuildBan, GuildCategory, GuildCategoryConverter, @@ -184,9 +188,9 @@ has_role, IDConverter, InputText, + integration_types, IntegrationExpireBehaviour, IntegrationType, - integration_types, Intents, InteractionCommand, InteractionContext, @@ -198,6 +202,7 @@ Invite, InviteTargetType, is_owner, + LeakyBucketSystem, listen, Listener, LocalisedDesc, @@ -208,13 +213,15 @@ MaterialColours, max_concurrency, MaxConcurrency, + MediaGalleryComponent, + MediaGalleryItem, Member, MemberConverter, MemberFlags, MentionableSelectMenu, MentionType, - Message, message_context_menu, + Message, MessageableChannelConverter, MessageableMixin, MessageActivity, @@ -226,19 +233,19 @@ MessageReference, MessageType, MFALevel, - Modal, modal_callback, + Modal, ModalCommand, ModalContext, MODEL_TO_CONVERTER, NoArgumentConverter, NSFWLevel, - open_file, Onboarding, OnboardingMode, OnboardingPrompt, OnboardingPromptOption, OnboardingPromptType, + open_file, OptionType, OrTrigger, OverwriteType, @@ -261,8 +268,8 @@ process_components, process_default_reaction, process_embeds, - process_emoji, process_emoji_req_format, + process_emoji, process_message_payload, process_message_reference, process_permission_overwrites, @@ -279,6 +286,9 @@ ScheduledEventPrivacyLevel, ScheduledEventStatus, ScheduledEventType, + SectionComponent, + SeparatorComponent, + SeparatorSpacingSize, ShortText, slash_attachment_option, slash_bool_option, @@ -297,8 +307,9 @@ SlashCommandOption, SlashCommandParameter, SlashContext, - Snowflake, + SlidingWindowSystem, Snowflake_Type, + Snowflake, SnowflakeConverter, SnowflakeObject, spread_to_rows, @@ -319,6 +330,7 @@ Team, TeamMember, TeamMembershipState, + TextDisplayComponent, TextStyles, ThreadableMixin, ThreadChannel, @@ -326,12 +338,14 @@ ThreadList, ThreadMember, ThreadTag, + ThumbnailComponent, Timestamp, TimestampStyles, TimeTrigger, to_optional_snowflake, - to_snowflake, to_snowflake_list, + to_snowflake, + TokenBucketSystem, TYPE_ALL_ACTION, TYPE_ALL_CHANNEL, TYPE_ALL_TRIGGER, @@ -343,9 +357,11 @@ TYPE_THREAD_CHANNEL, TYPE_VOICE_CHANNEL, Typing, + UnfurledMediaItem, + UnfurledMediaItemLoadingState, UPLOADABLE_TYPE, - User, user_context_menu, + User, UserConverter, UserFlags, UserSelectMenu, @@ -359,11 +375,6 @@ WebhookMixin, WebhookTypes, WebSocketOPCode, - SlidingWindowSystem, - ExponentialBackoffSystem, - LeakyBucketSystem, - TokenBucketSystem, - ForumSortOrder, ) from .api import events from . import ext @@ -385,8 +396,8 @@ "ActivityTimestamps", "ActivityType", "AllowedMentions", - "Application", "application_commands_to_dict", + "Application", "ApplicationCommandPermission", "ApplicationFlags", "Asset", @@ -433,50 +444,47 @@ "Client", "ClientT", "ClientUser", - "Color", "COLOR_TYPES", + "Color", "Colour", "CommandType", "component_callback", "ComponentCommand", "ComponentContext", "ComponentType", - "ConsumeRest", "const", - "contexts", - "context_menu", + "ConsumeRest", + "ContainerComponent", "CONTEXT_MENU_NAME_LENGTH", + "context_menu", "ContextMenu", "ContextMenuContext", + "contexts", "ContextType", "Converter", "cooldown", "Cooldown", "CooldownSystem", "CronTrigger", - "SlidingWindowSystem", - "ExponentialBackoffSystem", - "LeakyBucketSystem", - "TokenBucketSystem", "CustomEmoji", "CustomEmojiConverter", "DateTrigger", "DefaultNotificationLevel", "DefaultReaction", "DISCORD_EPOCH", - "DM", "dm_only", + "DM", "DMChannel", "DMChannelConverter", "DMConverter", "DMGroup", "DMGroupConverter", - "Embed", "EMBED_FIELD_VALUE_LENGTH", "EMBED_MAX_DESC_LENGTH", "EMBED_MAX_FIELDS", "EMBED_MAX_NAME_LENGTH", "EMBED_TOTAL_MAX", + "Embed", "EmbedAttachment", "EmbedAuthor", "EmbedField", @@ -486,13 +494,15 @@ "errors", "events", "ExplicitContentFilterLevel", + "ExponentialBackoffSystem", "ext", "Extension", "File", + "FileComponent", "FlatUIColors", "FlatUIColours", - "ForumSortOrder", "ForumLayoutType", + "ForumSortOrder", "get_components_ids", "get_logger", "global_autocomplete", @@ -500,8 +510,8 @@ "GlobalAutoComplete", "GlobalScope", "Greedy", - "Guild", "guild_only", + "Guild", "GuildBan", "GuildCategory", "GuildCategoryConverter", @@ -537,9 +547,9 @@ "has_role", "IDConverter", "InputText", + "integration_types", "IntegrationExpireBehaviour", "IntegrationType", - "integration_types", "Intents", "InteractionCommand", "InteractionContext", @@ -552,6 +562,7 @@ "InviteTargetType", "is_owner", "kwarg_spam", + "LeakyBucketSystem", "listen", "Listener", "LocalisedDesc", @@ -563,6 +574,8 @@ "MaterialColours", "max_concurrency", "MaxConcurrency", + "MediaGalleryComponent", + "MediaGalleryItem", "Member", "MemberConverter", "MemberFlags", @@ -570,8 +583,8 @@ "MentionableSelectMenu", "MentionPrefix", "MentionType", - "Message", "message_context_menu", + "Message", "MessageableChannelConverter", "MessageableMixin", "MessageActivity", @@ -585,19 +598,19 @@ "MFALevel", "Missing", "MISSING", - "Modal", "modal_callback", + "Modal", "ModalCommand", "ModalContext", "MODEL_TO_CONVERTER", "NoArgumentConverter", "NSFWLevel", - "open_file", "Onboarding", "OnboardingMode", "OnboardingPrompt", "OnboardingPromptOption", "OnboardingPromptType", + "open_file", "OptionType", "OrTrigger", "OverwriteType", @@ -606,12 +619,12 @@ "PartialEmojiConverter", "PermissionOverwrite", "Permissions", + "POLL_MAX_ANSWERS", + "POLL_MAX_DURATION_HOURS", "Poll", "PollAnswer", "PollAnswerCount", "PollLayoutType", - "POLL_MAX_ANSWERS", - "POLL_MAX_DURATION_HOURS", "PollMedia", "PollResults", "PREMIUM_GUILD_LIMITS", @@ -623,8 +636,8 @@ "process_components", "process_default_reaction", "process_embeds", - "process_emoji", "process_emoji_req_format", + "process_emoji", "process_message_payload", "process_message_reference", "process_permission_overwrites", @@ -641,9 +654,12 @@ "ScheduledEventPrivacyLevel", "ScheduledEventStatus", "ScheduledEventType", + "SectionComponent", "SELECT_MAX_NAME_LENGTH", "SELECTS_MAX_OPTIONS", "Sentinel", + "SeparatorComponent", + "SeparatorSpacingSize", "ShortText", "Singleton", "slash_attachment_option", @@ -657,8 +673,8 @@ "slash_float_option", "slash_int_option", "slash_mentionable_option", - "slash_option", "SLASH_OPTION_NAME_LENGTH", + "slash_option", "slash_role_option", "slash_str_option", "slash_user_option", @@ -667,9 +683,10 @@ "SlashCommandOption", "SlashCommandParameter", "SlashContext", + "SlidingWindowSystem", "smart_cache", - "Snowflake", "Snowflake_Type", + "Snowflake", "SnowflakeConverter", "SnowflakeObject", "spread_to_rows", @@ -686,12 +703,13 @@ "subcommand", "sync_needed", "SystemChannelFlags", - "T", "T_co", + "T", "Task", "Team", "TeamMember", "TeamMembershipState", + "TextDisplayComponent", "TextStyles", "ThreadableMixin", "ThreadChannel", @@ -699,12 +717,14 @@ "ThreadList", "ThreadMember", "ThreadTag", + "ThumbnailComponent", "Timestamp", "TimestampStyles", "TimeTrigger", "to_optional_snowflake", - "to_snowflake", "to_snowflake_list", + "to_snowflake", + "TokenBucketSystem", "TYPE_ALL_ACTION", "TYPE_ALL_CHANNEL", "TYPE_ALL_TRIGGER", @@ -716,9 +736,11 @@ "TYPE_THREAD_CHANNEL", "TYPE_VOICE_CHANNEL", "Typing", + "UnfurledMediaItem", + "UnfurledMediaItemLoadingState", "UPLOADABLE_TYPE", - "User", "user_context_menu", + "User", "UserConverter", "UserFlags", "UserSelectMenu", diff --git a/interactions/models/__init__.py b/interactions/models/__init__.py index c258996e4..fb8c6d74c 100644 --- a/interactions/models/__init__.py +++ b/interactions/models/__init__.py @@ -38,11 +38,12 @@ ChannelSelectMenu, ChannelType, ClientUser, - Color, COLOR_TYPES, + Color, Colour, CommandType, ComponentType, + ContainerComponent, ContextType, CustomEmoji, DefaultNotificationLevel, @@ -59,9 +60,11 @@ Entitlement, ExplicitContentFilterLevel, File, + FileComponent, FlatUIColors, FlatUIColours, ForumLayoutType, + ForumSortOrder, get_components_ids, Guild, GuildBan, @@ -96,6 +99,8 @@ InviteTargetType, MaterialColors, MaterialColours, + MediaGalleryComponent, + MediaGalleryItem, Member, MemberFlags, MentionableSelectMenu, @@ -112,13 +117,13 @@ MFALevel, Modal, NSFWLevel, - open_file, - OverwriteType, Onboarding, OnboardingMode, OnboardingPrompt, OnboardingPromptOption, OnboardingPromptType, + open_file, + OverwriteType, ParagraphText, PartialEmoji, PermissionOverwrite, @@ -137,8 +142,8 @@ process_components, process_default_reaction, process_embeds, - process_emoji, process_emoji_req_format, + process_emoji, process_message_payload, process_message_reference, process_permission_overwrites, @@ -153,9 +158,12 @@ ScheduledEventPrivacyLevel, ScheduledEventStatus, ScheduledEventType, + SectionComponent, + SeparatorComponent, + SeparatorSpacingSize, ShortText, - Snowflake, Snowflake_Type, + Snowflake, SnowflakeObject, spread_to_rows, StageInstance, @@ -172,17 +180,19 @@ Team, TeamMember, TeamMembershipState, + TextDisplayComponent, TextStyles, ThreadableMixin, ThreadChannel, ThreadList, ThreadMember, ThreadTag, + ThumbnailComponent, Timestamp, TimestampStyles, to_optional_snowflake, - to_snowflake, to_snowflake_list, + to_snowflake, TYPE_ALL_ACTION, TYPE_ALL_CHANNEL, TYPE_ALL_TRIGGER, @@ -193,6 +203,8 @@ TYPE_MESSAGEABLE_CHANNEL, TYPE_THREAD_CHANNEL, TYPE_VOICE_CHANNEL, + UnfurledMediaItem, + UnfurledMediaItemLoadingState, UPLOADABLE_TYPE, User, UserFlags, @@ -205,7 +217,6 @@ WebhookMixin, WebhookTypes, WebSocketOPCode, - ForumSortOrder, ) from .internal import ( ActiveVoiceState, @@ -333,8 +344,8 @@ "ActivityTimestamps", "ActivityType", "AllowedMentions", - "Application", "application_commands_to_dict", + "Application", "ApplicationCommandPermission", "ApplicationFlags", "Asset", @@ -378,8 +389,8 @@ "ChannelType", "check", "ClientUser", - "Color", "COLOR_TYPES", + "Color", "Colour", "CommandType", "component_callback", @@ -387,27 +398,24 @@ "ComponentContext", "ComponentType", "ConsumeRest", - "contexts", + "ContainerComponent", "context_menu", "ContextMenu", "ContextMenuContext", + "contexts", "ContextType", "Converter", "cooldown", "Cooldown", "CooldownSystem", "CronTrigger", - "SlidingWindowSystem", - "ExponentialBackoffSystem", - "LeakyBucketSystem", - "TokenBucketSystem", "CustomEmoji", "CustomEmojiConverter", "DateTrigger", "DefaultNotificationLevel", "DefaultReaction", - "DM", "dm_only", + "DM", "DMChannel", "DMChannelConverter", "DMConverter", @@ -421,18 +429,20 @@ "EmbedProvider", "Entitlement", "ExplicitContentFilterLevel", + "ExponentialBackoffSystem", "Extension", "File", + "FileComponent", "FlatUIColors", "FlatUIColours", - "ForumSortOrder", "ForumLayoutType", + "ForumSortOrder", "get_components_ids", "global_autocomplete", "GlobalAutoComplete", "Greedy", - "Guild", "guild_only", + "Guild", "GuildBan", "GuildCategory", "GuildCategoryConverter", @@ -468,9 +478,9 @@ "has_role", "IDConverter", "InputText", + "integration_types", "IntegrationExpireBehaviour", "IntegrationType", - "integration_types", "Intents", "InteractionCommand", "InteractionContext", @@ -482,6 +492,7 @@ "Invite", "InviteTargetType", "is_owner", + "LeakyBucketSystem", "listen", "Listener", "LocalisedDesc", @@ -492,13 +503,15 @@ "MaterialColours", "max_concurrency", "MaxConcurrency", + "MediaGalleryComponent", + "MediaGalleryItem", "Member", "MemberConverter", "MemberFlags", "MentionableSelectMenu", "MentionType", - "Message", "message_context_menu", + "Message", "MessageableChannelConverter", "MessageableMixin", "MessageActivity", @@ -510,19 +523,19 @@ "MessageReference", "MessageType", "MFALevel", - "Modal", "modal_callback", + "Modal", "ModalCommand", "ModalContext", "MODEL_TO_CONVERTER", "NoArgumentConverter", "NSFWLevel", - "open_file", "Onboarding", "OnboardingMode", "OnboardingPrompt", "OnboardingPromptOption", "OnboardingPromptType", + "open_file", "OptionType", "OrTrigger", "OverwriteType", @@ -545,8 +558,8 @@ "process_components", "process_default_reaction", "process_embeds", - "process_emoji", "process_emoji_req_format", + "process_emoji", "process_message_payload", "process_message_reference", "process_permission_overwrites", @@ -563,6 +576,9 @@ "ScheduledEventPrivacyLevel", "ScheduledEventStatus", "ScheduledEventType", + "SectionComponent", + "SeparatorComponent", + "SeparatorSpacingSize", "ShortText", "slash_attachment_option", "slash_bool_option", @@ -581,8 +597,9 @@ "SlashCommandOption", "SlashCommandParameter", "SlashContext", - "Snowflake", + "SlidingWindowSystem", "Snowflake_Type", + "Snowflake", "SnowflakeConverter", "SnowflakeObject", "spread_to_rows", @@ -603,6 +620,7 @@ "Team", "TeamMember", "TeamMembershipState", + "TextDisplayComponent", "TextStyles", "ThreadableMixin", "ThreadChannel", @@ -610,12 +628,14 @@ "ThreadList", "ThreadMember", "ThreadTag", + "ThumbnailComponent", "Timestamp", "TimestampStyles", "TimeTrigger", "to_optional_snowflake", - "to_snowflake", "to_snowflake_list", + "to_snowflake", + "TokenBucketSystem", "TYPE_ALL_ACTION", "TYPE_ALL_CHANNEL", "TYPE_ALL_TRIGGER", @@ -627,9 +647,11 @@ "TYPE_THREAD_CHANNEL", "TYPE_VOICE_CHANNEL", "Typing", + "UnfurledMediaItem", + "UnfurledMediaItemLoadingState", "UPLOADABLE_TYPE", - "User", "user_context_menu", + "User", "UserConverter", "UserFlags", "UserSelectMenu", diff --git a/interactions/models/discord/__init__.py b/interactions/models/discord/__init__.py index 21baa4d68..667024caf 100644 --- a/interactions/models/discord/__init__.py +++ b/interactions/models/discord/__init__.py @@ -57,15 +57,24 @@ BaseSelectMenu, Button, ChannelSelectMenu, + ContainerComponent, + FileComponent, get_components_ids, InteractiveComponent, + MediaGalleryComponent, + MediaGalleryItem, MentionableSelectMenu, process_components, RoleSelectMenu, + SectionComponent, + SeparatorComponent, spread_to_rows, StringSelectMenu, StringSelectOption, + TextDisplayComponent, + ThumbnailComponent, TYPE_COMPONENT_MAPPING, + UnfurledMediaItem, UserSelectMenu, ) @@ -87,6 +96,7 @@ DefaultNotificationLevel, ExplicitContentFilterLevel, ForumLayoutType, + ForumSortOrder, IntegrationExpireBehaviour, IntegrationType, Intents, @@ -110,17 +120,18 @@ ScheduledEventPrivacyLevel, ScheduledEventStatus, ScheduledEventType, + SeparatorSpacingSize, StagePrivacyLevel, Status, StickerFormatType, StickerTypes, SystemChannelFlags, TeamMembershipState, + UnfurledMediaItemLoadingState, UserFlags, VerificationLevel, VideoQualityMode, WebSocketOPCode, - ForumSortOrder, ) from .file import File, open_file, UPLOADABLE_TYPE from .guild import ( @@ -217,11 +228,12 @@ "ChannelSelectMenu", "ChannelType", "ClientUser", - "Color", "COLOR_TYPES", + "Color", "Colour", "CommandType", "ComponentType", + "ContainerComponent", "ContextType", "CustomEmoji", "DefaultNotificationLevel", @@ -238,10 +250,11 @@ "Entitlement", "ExplicitContentFilterLevel", "File", + "FileComponent", "FlatUIColors", "FlatUIColours", - "ForumSortOrder", "ForumLayoutType", + "ForumSortOrder", "get_components_ids", "Guild", "GuildBan", @@ -276,6 +289,8 @@ "InviteTargetType", "MaterialColors", "MaterialColours", + "MediaGalleryComponent", + "MediaGalleryItem", "Member", "MemberFlags", "MentionableSelectMenu", @@ -292,12 +307,12 @@ "MFALevel", "Modal", "NSFWLevel", - "open_file", "Onboarding", "OnboardingMode", "OnboardingPrompt", "OnboardingPromptOption", "OnboardingPromptType", + "open_file", "OverwriteType", "ParagraphText", "PartialEmoji", @@ -317,8 +332,8 @@ "process_components", "process_default_reaction", "process_embeds", - "process_emoji", "process_emoji_req_format", + "process_emoji", "process_message_payload", "process_message_reference", "process_permission_overwrites", @@ -333,9 +348,12 @@ "ScheduledEventPrivacyLevel", "ScheduledEventStatus", "ScheduledEventType", + "SectionComponent", + "SeparatorComponent", + "SeparatorSpacingSize", "ShortText", - "Snowflake", "Snowflake_Type", + "Snowflake", "SnowflakeObject", "spread_to_rows", "StageInstance", @@ -352,17 +370,19 @@ "Team", "TeamMember", "TeamMembershipState", + "TextDisplayComponent", "TextStyles", "ThreadableMixin", "ThreadChannel", "ThreadList", "ThreadMember", "ThreadTag", + "ThumbnailComponent", "Timestamp", "TimestampStyles", "to_optional_snowflake", - "to_snowflake", "to_snowflake_list", + "to_snowflake", "TYPE_ALL_ACTION", "TYPE_ALL_CHANNEL", "TYPE_ALL_TRIGGER", @@ -373,6 +393,8 @@ "TYPE_MESSAGEABLE_CHANNEL", "TYPE_THREAD_CHANNEL", "TYPE_VOICE_CHANNEL", + "UnfurledMediaItem", + "UnfurledMediaItemLoadingState", "UPLOADABLE_TYPE", "User", "UserFlags", diff --git a/interactions/models/discord/components.py b/interactions/models/discord/components.py index 495874832..d045fb7ea 100644 --- a/interactions/models/discord/components.py +++ b/interactions/models/discord/components.py @@ -12,32 +12,79 @@ from interactions.client.mixins.serialization import DictSerializationMixin from interactions.models.discord.base import DiscordObject from interactions.models.discord.emoji import PartialEmoji, process_emoji -from interactions.models.discord.enums import ButtonStyle, ChannelType, ComponentType +from interactions.models.discord.enums import ( + ButtonStyle, + ChannelType, + ComponentType, + SeparatorSpacingSize, + UnfurledMediaItemLoadingState, +) if TYPE_CHECKING: import interactions.models.discord __all__ = ( - "BaseComponent", - "InteractiveComponent", "ActionRow", - "Button", + "BaseComponent", "BaseSelectMenu", - "StringSelectMenu", - "StringSelectOption", - "UserSelectMenu", - "RoleSelectMenu", - "MentionableSelectMenu", + "Button", "ChannelSelectMenu", + "ContainerComponent", + "DefaultableSelectMenu", + "FileComponent", + "get_components_ids", + "InteractiveComponent", + "MediaGalleryComponent", + "MediaGalleryItem", + "MentionableSelectMenu", "process_components", + "RoleSelectMenu", + "SectionComponent", + "SelectDefaultValues", + "SeparatorComponent", "spread_to_rows", - "get_components_ids", + "StringSelectMenu", + "StringSelectOption", + "TextDisplayComponent", + "ThumbnailComponent", "TYPE_COMPONENT_MAPPING", - "SelectDefaultValues", - "DefaultableSelectMenu", + "UnfurledMediaItem", + "UserSelectMenu", ) +class UnfurledMediaItem(DictSerializationMixin): + """A basic object for making media items.""" + + url: str + proxy_url: Optional[str] = None + height: Optional[int] = None + width: Optional[int] = None + content_type: Optional[str] = None + loading_state: Optional[UnfurledMediaItemLoadingState] = None + + def __init__(self, url: str): + self.url = url + + @classmethod + def from_dict(cls, data: dict) -> "UnfurledMediaItem": + item = cls(data["url"]) + item.proxy_url = data.get("proxy_url") + item.height = data.get("height") + item.width = data.get("width") + item.content_type = data.get("content_type") + item.loading_state = ( + UnfurledMediaItemLoadingState(data.get("loading_state")) if data.get("loading_state") else None + ) + return item + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} url={self.url}>" + + def to_dict(self) -> Dict[str, Any]: + return {"url": self.url} + + class BaseComponent(DictSerializationMixin): """ A base component class. @@ -48,6 +95,7 @@ class BaseComponent(DictSerializationMixin): """ type: ComponentType + id: Optional[int] = None def __repr__(self) -> str: return f"<{self.__class__.__name__} type={self.type}>" @@ -763,6 +811,227 @@ def to_dict(self) -> discord_typings.SelectMenuComponentData: } +class SectionComponent(BaseComponent): + components: "list[TextDisplayComponent]" + accessory: "Button | ThumbnailComponent" + + def __init__( + self, *, components: "list[TextDisplayComponent] | None" = None, accessory: "Button | ThumbnailComponent" + ): + self.components = components or [] + self.accessory = accessory + self.type = ComponentType.SECTION + + @classmethod + def from_dict(cls, data: dict) -> "SectionComponent": + return cls( + components=[BaseComponent.from_dict_factory(component) for component in data["components"]], accessory=BaseComponent.from_dict_factory(data["accessory"]) # type: ignore + ) + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} type={self.type} components={self.components} accessory={self.accessory}>" + + def to_dict(self) -> dict: + return { + "type": self.type.value, + "components": [c.to_dict() for c in self.components], + "accessory": self.accessory.to_dict(), + } + + +class TextDisplayComponent(BaseComponent): + content: str + + def __init__(self, content: str): + self.content = content + self.type = ComponentType.TEXT_DISPLAY + + @classmethod + def from_dict(cls, data: dict) -> "TextDisplayComponent": + return cls(data["content"]) + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} type={self.type} style={self.content}>" + + def to_dict(self) -> dict: + return { + "type": self.type.value, + "content": self.content, + } + + +class ThumbnailComponent(BaseComponent): + media: UnfurledMediaItem + description: Optional[str] = None + spoiler: bool = False + + def __init__(self, media: UnfurledMediaItem, *, description: Optional[str] = None, spoiler: bool = False): + self.media = media + self.description = description + self.spoiler = spoiler + self.type = ComponentType.THUMBNAIL + + @classmethod + def from_dict(cls, data: dict) -> "ThumbnailComponent": + return cls( + media=UnfurledMediaItem.from_dict(data["media"]), + description=data.get("description"), + spoiler=data.get("spoiler", False), + ) + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} type={self.type} media={self.media} description={self.description} spoiler={self.spoiler}>" + + def to_dict(self) -> dict: + return { + "type": self.type.value, + "media": self.media.to_dict(), + "description": self.description, + "spoiler": self.spoiler, + } + + +class MediaGalleryItem(DictSerializationMixin): + media: UnfurledMediaItem + description: Optional[str] = None + spoiler: bool = False + + def __init__(self, media: UnfurledMediaItem, *, description: Optional[str] = None, spoiler: bool = False): + self.media = media + self.description = description + self.spoiler = spoiler + + @classmethod + def from_dict(cls, data: dict) -> "MediaGalleryItem": + return cls( + media=UnfurledMediaItem.from_dict(data["media"]), + description=data.get("description"), + spoiler=data.get("spoiler", False), + ) + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} media={self.media} description={self.description} spoiler={self.spoiler}>" + + def to_dict(self) -> dict: + return { + "media": self.media.to_dict(), + "description": self.description, + "spoiler": self.spoiler, + } + + +class MediaGalleryComponent(BaseComponent): + items: list[MediaGalleryItem] + + def __init__(self, items: list[MediaGalleryItem] | None = None): + self.items = items or [] + self.type = ComponentType.MEDIA_GALLERY + + @classmethod + def from_dict(cls, data: dict) -> "MediaGalleryComponent": + return cls([MediaGalleryItem.from_dict(item) for item in data["items"]]) + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} type={self.type} items={self.items}>" + + def to_dict(self) -> dict: + return { + "type": self.type.value, + "items": [item.to_dict() for item in self.items], + } + + +class FileComponent(BaseComponent): + file: UnfurledMediaItem + spoiler: bool = False + + def __init__(self, file: UnfurledMediaItem, *, spoiler: bool = False): + self.file = file + self.spoiler = spoiler + self.type = ComponentType.FILE + + @classmethod + def from_dict(cls, data: dict) -> "FileComponent": + return cls(file=UnfurledMediaItem.from_dict(data["file"]), spoiler=data.get("spoiler", False)) + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} type={self.type} file={self.file} spoiler={self.spoiler}>" + + def to_dict(self) -> dict: + return { + "type": self.type.value, + "file": self.file.to_dict(), + "spoiler": self.spoiler, + } + + +class SeparatorComponent(BaseComponent): + divider: bool = False + spacing: SeparatorSpacingSize = SeparatorSpacingSize.SMALL + + def __init__(self, *, divider: bool = False, spacing: SeparatorSpacingSize | int = SeparatorSpacingSize.SMALL): + self.divider = divider + self.spacing = SeparatorSpacingSize(spacing) + self.type = ComponentType.SEPARATOR + + @classmethod + def from_dict(cls, data: dict) -> "SeparatorComponent": + return cls(divider=data.get("divider", False), spacing=data.get("spacing", SeparatorSpacingSize.SMALL)) + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} type={self.type} divider={self.divider} spacing={self.spacing}>" + + def to_dict(self) -> dict: + return { + "type": self.type.value, + "divider": self.divider, + "spacing": self.spacing, + } + + +class ContainerComponent(BaseComponent): + components: list[ + ActionRow | SectionComponent | TextDisplayComponent | MediaGalleryComponent | FileComponent | SeparatorComponent + ] + accent_color: Optional[int] = None + spoiler: bool = False + + def __init__( + self, + *components: ActionRow + | SectionComponent + | TextDisplayComponent + | MediaGalleryComponent + | FileComponent + | SeparatorComponent, + accent_color: Optional[int] = None, + spoiler: bool = False, + ): + self.components = list(components) + self.accent_color = accent_color + self.spoiler = spoiler + self.type = ComponentType.CONTAINER + + @classmethod + def from_dict(cls, data: dict) -> "ContainerComponent": + return cls( + *[BaseComponent.from_dict_factory(component) for component in data["components"]], + accent_color=data.get("accent_color"), + spoiler=data.get("spoiler", False), + ) + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} type={self.type} components={self.components} accent_color={self.accent_color} spoiler={self.spoiler}>" + + def to_dict(self) -> dict: + return { + "type": self.type.value, + "components": [component.to_dict() for component in self.components], + "accent_color": self.accent_color, + "spoiler": self.spoiler, + } + + def process_components( components: Optional[ Union[ @@ -806,6 +1075,7 @@ def process_components( if all(isinstance(c, list) for c in components): # list of lists... actionRow-less sending + # note: we're assuming if someone passes a list of lists, they mean to use v1 components return [ActionRow(*row).to_dict() for row in components] if all(issubclass(type(c), InteractiveComponent) for c in components): @@ -816,6 +1086,9 @@ def process_components( # we have a list of action rows return [action_row.to_dict() for action_row in components] + # assume just a list of components + return [c if isinstance(c, dict) else c.to_dict() for c in components] + raise ValueError(f"Invalid components: {components}") @@ -880,4 +1153,11 @@ def get_components_ids(component: Union[str, dict, list, InteractiveComponent]) ComponentType.CHANNEL_SELECT: ChannelSelectMenu, ComponentType.ROLE_SELECT: RoleSelectMenu, ComponentType.MENTIONABLE_SELECT: MentionableSelectMenu, + ComponentType.SECTION: SectionComponent, + ComponentType.TEXT_DISPLAY: TextDisplayComponent, + ComponentType.THUMBNAIL: ThumbnailComponent, + ComponentType.MEDIA_GALLERY: MediaGalleryComponent, + ComponentType.FILE: FileComponent, + ComponentType.SEPARATOR: SeparatorComponent, + ComponentType.CONTAINER: ContainerComponent, } diff --git a/interactions/models/discord/enums.py b/interactions/models/discord/enums.py index c437a619e..41f081986 100644 --- a/interactions/models/discord/enums.py +++ b/interactions/models/discord/enums.py @@ -44,12 +44,14 @@ "ScheduledEventPrivacyLevel", "ScheduledEventStatus", "ScheduledEventType", + "SeparatorSpacingSize", "StagePrivacyLevel", "Status", "StickerFormatType", "StickerTypes", "SystemChannelFlags", "TeamMembershipState", + "UnfurledMediaItemLoadingState", "UserFlags", "VerificationLevel", "VideoQualityMode", @@ -493,6 +495,8 @@ class MessageFlags(DiscordIntFlag): # type: ignore """This message should not trigger push or desktop notifications""" VOICE_MESSAGE = 1 << 13 """This message is a voice message""" + IS_COMPONENTS_V2 = 1 << 15 + """This message contains uses v2 components""" SUPPRESS_NOTIFICATIONS = SILENT """Alias for :attr:`SILENT`""" @@ -683,6 +687,54 @@ class ComponentType(CursedIntEnum): """Select menu for picking from mentionable objects""" CHANNEL_SELECT = 8 """Select menu for picking from channels""" + SECTION = 9 + """Section component for grouping together text and thumbnails/buttons""" + TEXT_DISPLAY = 10 + """Text component for displaying text""" + THUMBNAIL = 11 + """Thumbnail component for displaying a thumbnail for an image""" + MEDIA_GALLERY = 12 + """Media gallery component for displaying multiple images""" + FILE = 13 + """File component for uploading files""" + SEPARATOR = 14 + """Separator component for visual separation""" + CONTAINER = 17 + """Container component for grouping together other components""" + + # TODO: this is hacky, is there a better way to do this? + @staticmethod + def v2_component_types() -> set["ComponentType"]: + return { + ComponentType.SECTION, + ComponentType.TEXT_DISPLAY, + ComponentType.THUMBNAIL, + ComponentType.MEDIA_GALLERY, + ComponentType.FILE, + ComponentType.SEPARATOR, + ComponentType.CONTAINER, + } + + @property + def v2_component(self) -> bool: + """Whether this component is a v2 component.""" + return self.value in self.v2_component_types() + + +class UnfurledMediaItemLoadingState(CursedIntEnum): + """The loading state of an unfurled media item.""" + + UNKNOWN = 0 + LOADING = 1 + SUCCESS = 2 + FAILED = 3 + + +class SeparatorSpacingSize(CursedIntEnum): + """The size of the spacing in a separator component.""" + + SMALL = 1 + LARGE = 2 class IntegrationType(CursedIntEnum): diff --git a/interactions/models/discord/message.py b/interactions/models/discord/message.py index abe982653..9dbd34ff4 100644 --- a/interactions/models/discord/message.py +++ b/interactions/models/discord/message.py @@ -42,6 +42,7 @@ MessageFlags, MessageType, IntegrationType, + ComponentType, ) from .snowflake import ( Snowflake, @@ -1066,6 +1067,16 @@ def process_message_payload( embeds = embeds if all(e is not None for e in embeds) else None components = models.process_components(components) + if components: + # TODO: should we check for content/embeds? should this be moved elsewhere? + if any(c["type"] in ComponentType.v2_component_types() for c in components): + if not flags: + flags = 0 + flags |= MessageFlags.IS_COMPONENTS_V2 + + if content or embeds: + raise ValueError("Cannot send content or embeds with v2 components") + if stickers: stickers = [to_snowflake(sticker) for sticker in stickers] allowed_mentions = process_allowed_mentions(allowed_mentions) diff --git a/main.py b/main.py index 3946b1b0e..550425023 100644 --- a/main.py +++ b/main.py @@ -3,6 +3,8 @@ import os import uuid +from interactions.models.internal.context import SlashContext + from thefuzz import process import interactions @@ -69,6 +71,48 @@ async def components(ctx): ) +@slash_command("v2") +async def v2(ctx: SlashContext): + from interactions.models.discord.components import ( + SectionComponent, + Button, + TextDisplayComponent, + ContainerComponent, + ButtonStyle, + ActionRow, + SeparatorComponent, + ThumbnailComponent, + UnfurledMediaItem, + ) + + components = [ + SectionComponent( + components=[ + TextDisplayComponent("This is some"), + TextDisplayComponent("Text"), + ], + accessory=Button(style=ButtonStyle.PRIMARY, label="Click me"), + ), + TextDisplayComponent("Hello World"), + ContainerComponent( + ActionRow( + Button(style=ButtonStyle.RED, label="Red Button"), Button(style=ButtonStyle.GREEN, label="Green Button") + ), + SeparatorComponent(divider=True), + TextDisplayComponent("👀"), + ), + SectionComponent( + components=[ + TextDisplayComponent("This one has a thumbnail"), + ], + accessory=ThumbnailComponent( + UnfurledMediaItem("https://avatars.githubusercontent.com/u/98242689?s=200&v=4") + ), + ), + ] + await ctx.send(components=components) + + @slash_command("record", description="Record audio in your voice channel") @slash_option("duration", "The duration of the recording", opt_type=interactions.OptionType.NUMBER, required=True) async def record(ctx: interactions.SlashContext, duration: int) -> None: From a4599dc4703ac97a7ad73688e647f22e5d526e08 Mon Sep 17 00:00:00 2001 From: Katelyn Gigante Date: Mon, 5 May 2025 14:02:19 +1000 Subject: [PATCH 08/11] chore: Bump version to 5.15.0rc1 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 626a533f6..c4fc7c153 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "interactions.py" -version = "5.14.0" +version = "5.15.0rc1" description = "Easy, simple, scalable and modular: a Python API wrapper for interactions." authors = ["LordOfPolls "] From 13640e336b2d253e69fa0b932fda2e5303948631 Mon Sep 17 00:00:00 2001 From: Katelyn Gigante Date: Mon, 5 May 2025 14:02:47 +1000 Subject: [PATCH 09/11] fix: Use Sequence instead of List in wait_for_component hints (#1764) --- interactions/client/client.py | 4 ++-- interactions/models/discord/components.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/interactions/client/client.py b/interactions/client/client.py index 8e83f5cac..0f2ede684 100644 --- a/interactions/client/client.py +++ b/interactions/client/client.py @@ -1216,8 +1216,8 @@ async def wait_for_component( messages: Optional[Union[Message, int, list]] = None, components: Optional[ Union[ - List[List[Union["BaseComponent", dict]]], - List[Union["BaseComponent", dict]], + Sequence[Sequence[Union["BaseComponent", dict]]], + Sequence[Union["BaseComponent", dict]], "BaseComponent", dict, ] diff --git a/interactions/models/discord/components.py b/interactions/models/discord/components.py index d045fb7ea..4b6c6ba2c 100644 --- a/interactions/models/discord/components.py +++ b/interactions/models/discord/components.py @@ -1,7 +1,7 @@ import contextlib import uuid from abc import abstractmethod -from typing import Any, Dict, Iterator, List, Optional, Union, TYPE_CHECKING +from typing import Any, Dict, Iterator, List, Optional, Sequence, Union, TYPE_CHECKING import attrs import discord_typings @@ -1113,7 +1113,7 @@ def spread_to_rows(*components: Union[ActionRow, Button, StringSelectMenu], max_ return ActionRow.split_components(*components, count_per_row=max_in_row) -def get_components_ids(component: Union[str, dict, list, InteractiveComponent]) -> Iterator[str]: +def get_components_ids(component: Union[str, dict, list, InteractiveComponent, Sequence]) -> Iterator[str]: """ Creates a generator with the `custom_id` of a component or list of components. From 4dc176471845531fa0275fa9dd74f8ba099818c6 Mon Sep 17 00:00:00 2001 From: mifuyutsuki Date: Mon, 5 May 2025 11:05:17 +0700 Subject: [PATCH 10/11] fix: update last page button emoji (#1772) --- interactions/ext/paginators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/interactions/ext/paginators.py b/interactions/ext/paginators.py index 9e36cdcbb..563cb4746 100644 --- a/interactions/ext/paginators.py +++ b/interactions/ext/paginators.py @@ -122,7 +122,7 @@ class Paginator: ) """The emoji to use for the next button""" last_button_emoji: Optional[Union["PartialEmoji", dict, str]] = attrs.field( - repr=False, default="⏩", metadata=export_converter(process_emoji) + repr=False, default="⏭️", metadata=export_converter(process_emoji) ) """The emoji to use for the last button""" callback_button_emoji: Optional[Union["PartialEmoji", dict, str]] = attrs.field( From 311c1e192d5cdc3d782e3f6b9765a51cb6ae15e0 Mon Sep 17 00:00:00 2001 From: Katelyn Gigante Date: Thu, 19 Jun 2025 16:48:11 +1000 Subject: [PATCH 11/11] chore: Bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c4fc7c153..cd710a38e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "interactions.py" -version = "5.15.0rc1" +version = "5.15.0" description = "Easy, simple, scalable and modular: a Python API wrapper for interactions." authors = ["LordOfPolls "]