Skip to content

Commit 439d54b

Browse files
committed
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!
1 parent 27b5a85 commit 439d54b

File tree

3 files changed

+314
-1
lines changed

3 files changed

+314
-1
lines changed

interactions/models/discord/components.py

+272-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@
1212
from interactions.client.mixins.serialization import DictSerializationMixin
1313
from interactions.models.discord.base import DiscordObject
1414
from interactions.models.discord.emoji import PartialEmoji, process_emoji
15-
from interactions.models.discord.enums import ButtonStyle, ChannelType, ComponentType
15+
from interactions.models.discord.enums import (
16+
ButtonStyle,
17+
ChannelType,
18+
ComponentType,
19+
UnfurledMediaItemLoadingState,
20+
SeparatorSpacingSize,
21+
)
1622

1723
if TYPE_CHECKING:
1824
import interactions.models.discord
@@ -38,6 +44,38 @@
3844
)
3945

4046

47+
class UnfurledMediaItem(DictSerializationMixin):
48+
"""A basic object for making media items."""
49+
50+
url: str
51+
proxy_url: Optional[str] = None
52+
height: Optional[int] = None
53+
width: Optional[int] = None
54+
content_type: Optional[str] = None
55+
loading_state: Optional[UnfurledMediaItemLoadingState] = None
56+
57+
def __init__(self, url: str):
58+
self.url = url
59+
60+
@classmethod
61+
def from_dict(cls, data: dict) -> "UnfurledMediaItem":
62+
item = cls(data["url"])
63+
item.proxy_url = data.get("proxy_url")
64+
item.height = data.get("height")
65+
item.width = data.get("width")
66+
item.content_type = data.get("content_type")
67+
item.loading_state = (
68+
UnfurledMediaItemLoadingState(data.get("loading_state")) if data.get("loading_state") else None
69+
)
70+
return item
71+
72+
def __repr__(self) -> str:
73+
return f"<{self.__class__.__name__} url={self.url}>"
74+
75+
def to_dict(self) -> Dict[str, Any]:
76+
return {"url": self.url}
77+
78+
4179
class BaseComponent(DictSerializationMixin):
4280
"""
4381
A base component class.
@@ -48,6 +86,7 @@ class BaseComponent(DictSerializationMixin):
4886
"""
4987

5088
type: ComponentType
89+
id: Optional[int] = None
5190

5291
def __repr__(self) -> str:
5392
return f"<{self.__class__.__name__} type={self.type}>"
@@ -763,6 +802,227 @@ def to_dict(self) -> discord_typings.SelectMenuComponentData:
763802
}
764803

765804

805+
class SectionComponent(BaseComponent):
806+
components: "list[TextDisplayComponent]"
807+
accessory: "Button | ThumbnailComponent"
808+
809+
def __init__(
810+
self, *, components: "list[TextDisplayComponent] | None" = None, accessory: "Button | ThumbnailComponent"
811+
):
812+
self.components = components or []
813+
self.accessory = accessory
814+
self.type = ComponentType.SECTION
815+
816+
@classmethod
817+
def from_dict(cls, data: dict) -> "SectionComponent":
818+
return cls(
819+
components=TextDisplayComponent.from_list(data["components"]), accessory=Button.from_dict(data["accessory"])
820+
)
821+
822+
def __repr__(self) -> str:
823+
return f"<{self.__class__.__name__} type={self.type} components={self.components} accessory={self.accessory}>"
824+
825+
def to_dict(self) -> dict:
826+
return {
827+
"type": self.type.value,
828+
"components": [c.to_dict() for c in self.components],
829+
"accessory": self.accessory.to_dict(),
830+
}
831+
832+
833+
class TextDisplayComponent(BaseComponent):
834+
content: str
835+
836+
def __init__(self, content: str):
837+
self.content = content
838+
self.type = ComponentType.TEXT_DISPLAY
839+
840+
@classmethod
841+
def from_dict(cls, data: dict) -> "TextDisplayComponent":
842+
return cls(data["content"])
843+
844+
def __repr__(self) -> str:
845+
return f"<{self.__class__.__name__} type={self.type} style={self.content}>"
846+
847+
def to_dict(self) -> dict:
848+
return {
849+
"type": self.type.value,
850+
"content": self.content,
851+
}
852+
853+
854+
class ThumbnailComponent(BaseComponent):
855+
media: UnfurledMediaItem
856+
description: Optional[str] = None
857+
spoiler: bool = False
858+
859+
def __init__(self, media: UnfurledMediaItem, *, description: Optional[str] = None, spoiler: bool = False):
860+
self.media = media
861+
self.description = description
862+
self.spoiler = spoiler
863+
self.type = ComponentType.THUMBNAIL
864+
865+
@classmethod
866+
def from_dict(cls, data: dict) -> "ThumbnailComponent":
867+
return cls(
868+
media=UnfurledMediaItem.from_dict(data["media"]),
869+
description=data.get("description"),
870+
spoiler=data.get("spoiler", False),
871+
)
872+
873+
def __repr__(self) -> str:
874+
return f"<{self.__class__.__name__} type={self.type} media={self.media} description={self.description} spoiler={self.spoiler}>"
875+
876+
def to_dict(self) -> dict:
877+
return {
878+
"type": self.type.value,
879+
"media": self.media.to_dict(),
880+
"description": self.description,
881+
"spoiler": self.spoiler,
882+
}
883+
884+
885+
class MediaGalleryItem(DictSerializationMixin):
886+
media: UnfurledMediaItem
887+
description: Optional[str] = None
888+
spoiler: bool = False
889+
890+
def __init__(self, media: UnfurledMediaItem, *, description: Optional[str] = None, spoiler: bool = False):
891+
self.media = media
892+
self.description = description
893+
self.spoiler = spoiler
894+
895+
@classmethod
896+
def from_dict(cls, data: dict) -> "MediaGalleryItem":
897+
return cls(
898+
media=UnfurledMediaItem.from_dict(data["media"]),
899+
description=data.get("description"),
900+
spoiler=data.get("spoiler", False),
901+
)
902+
903+
def __repr__(self) -> str:
904+
return f"<{self.__class__.__name__} media={self.media} description={self.description} spoiler={self.spoiler}>"
905+
906+
def to_dict(self) -> dict:
907+
return {
908+
"media": self.media.to_dict(),
909+
"description": self.description,
910+
"spoiler": self.spoiler,
911+
}
912+
913+
914+
class MediaGalleryComponent(BaseComponent):
915+
items: list[MediaGalleryItem]
916+
917+
def __init__(self, items: list[MediaGalleryItem] | None = None):
918+
self.items = items or []
919+
self.type = ComponentType.MEDIA_GALLERY
920+
921+
@classmethod
922+
def from_dict(cls, data: dict) -> "MediaGalleryComponent":
923+
return cls([MediaGalleryItem.from_dict(item) for item in data["items"]])
924+
925+
def __repr__(self) -> str:
926+
return f"<{self.__class__.__name__} type={self.type} items={self.items}>"
927+
928+
def to_dict(self) -> dict:
929+
return {
930+
"type": self.type.value,
931+
"items": [item.to_dict() for item in self.items],
932+
}
933+
934+
935+
class FileComponent(BaseComponent):
936+
file: UnfurledMediaItem
937+
spoiler: bool = False
938+
939+
def __init__(self, file: UnfurledMediaItem, *, spoiler: bool = False):
940+
self.file = file
941+
self.spoiler = spoiler
942+
self.type = ComponentType.FILE
943+
944+
@classmethod
945+
def from_dict(cls, data: dict) -> "FileComponent":
946+
return cls(file=UnfurledMediaItem.from_dict(data["file"]), spoiler=data.get("spoiler", False))
947+
948+
def __repr__(self) -> str:
949+
return f"<{self.__class__.__name__} type={self.type} file={self.file} spoiler={self.spoiler}>"
950+
951+
def to_dict(self) -> dict:
952+
return {
953+
"type": self.type.value,
954+
"file": self.file.to_dict(),
955+
"spoiler": self.spoiler,
956+
}
957+
958+
959+
class SeparatorComponent(BaseComponent):
960+
divider: bool = False
961+
spacing: SeparatorSpacingSize = SeparatorSpacingSize.SMALL
962+
963+
def __init__(self, *, divider: bool = False, spacing: SeparatorSpacingSize | int = SeparatorSpacingSize.SMALL):
964+
self.divider = divider
965+
self.spacing = SeparatorSpacingSize(spacing)
966+
self.type = ComponentType.SEPARATOR
967+
968+
@classmethod
969+
def from_dict(cls, data: dict) -> "SeparatorComponent":
970+
return cls(divider=data.get("divider", False), spacing=data.get("spacing", SeparatorSpacingSize.SMALL))
971+
972+
def __repr__(self) -> str:
973+
return f"<{self.__class__.__name__} type={self.type} divider={self.divider} spacing={self.spacing}>"
974+
975+
def to_dict(self) -> dict:
976+
return {
977+
"type": self.type.value,
978+
"divider": self.divider,
979+
"spacing": self.spacing,
980+
}
981+
982+
983+
class ContainerComponent(BaseComponent):
984+
components: list[
985+
ActionRow | SectionComponent | TextDisplayComponent | MediaGalleryComponent | FileComponent | SeparatorComponent
986+
]
987+
accent_color: Optional[int] = None
988+
spoiler: bool = False
989+
990+
def __init__(
991+
self,
992+
*components: ActionRow
993+
| SectionComponent
994+
| TextDisplayComponent
995+
| MediaGalleryComponent
996+
| FileComponent
997+
| SeparatorComponent,
998+
accent_color: Optional[int] = None,
999+
spoiler: bool = False,
1000+
):
1001+
self.components = list(components)
1002+
self.accent_color = accent_color
1003+
self.spoiler = spoiler
1004+
self.type = ComponentType.CONTAINER
1005+
1006+
@classmethod
1007+
def from_dict(cls, data: dict) -> "ContainerComponent":
1008+
return cls(
1009+
*[BaseComponent.from_dict_factory(component) for component in data["components"]],
1010+
accent_color=data.get("accent_color"),
1011+
spoiler=data.get("spoiler", False),
1012+
)
1013+
1014+
def __repr__(self) -> str:
1015+
return f"<{self.__class__.__name__} type={self.type} components={self.components} accent_color={self.accent_color} spoiler={self.spoiler}>"
1016+
1017+
def to_dict(self) -> dict:
1018+
return {
1019+
"type": self.type.value,
1020+
"components": [component.to_dict() for component in self.components],
1021+
"accent_color": self.accent_color,
1022+
"spoiler": self.spoiler,
1023+
}
1024+
1025+
7661026
def process_components(
7671027
components: Optional[
7681028
Union[
@@ -806,6 +1066,7 @@ def process_components(
8061066

8071067
if all(isinstance(c, list) for c in components):
8081068
# list of lists... actionRow-less sending
1069+
# note: we're assuming if someone passes a list of lists, they mean to use v1 components
8091070
return [ActionRow(*row).to_dict() for row in components]
8101071

8111072
if all(issubclass(type(c), InteractiveComponent) for c in components):
@@ -816,6 +1077,9 @@ def process_components(
8161077
# we have a list of action rows
8171078
return [action_row.to_dict() for action_row in components]
8181079

1080+
# assume just a list of components
1081+
return [c if isinstance(c, dict) else c.to_dict() for c in components]
1082+
8191083
raise ValueError(f"Invalid components: {components}")
8201084

8211085

@@ -880,4 +1144,11 @@ def get_components_ids(component: Union[str, dict, list, InteractiveComponent])
8801144
ComponentType.CHANNEL_SELECT: ChannelSelectMenu,
8811145
ComponentType.ROLE_SELECT: RoleSelectMenu,
8821146
ComponentType.MENTIONABLE_SELECT: MentionableSelectMenu,
1147+
ComponentType.SECTION: SectionComponent,
1148+
ComponentType.TEXT_DISPLAY: TextDisplayComponent,
1149+
ComponentType.THUMBNAIL: ThumbnailComponent,
1150+
ComponentType.MEDIA_GALLERY: MediaGalleryComponent,
1151+
ComponentType.FILE: FileComponent,
1152+
ComponentType.SEPARATOR: SeparatorComponent,
1153+
ComponentType.CONTAINER: ContainerComponent,
8831154
}

interactions/models/discord/enums.py

+34
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,8 @@ class MessageFlags(DiscordIntFlag): # type: ignore
493493
"""This message should not trigger push or desktop notifications"""
494494
VOICE_MESSAGE = 1 << 13
495495
"""This message is a voice message"""
496+
IS_COMPONENTS_V2 = 1 << 15
497+
"""This message contains uses v2 components"""
496498

497499
SUPPRESS_NOTIFICATIONS = SILENT
498500
"""Alias for :attr:`SILENT`"""
@@ -683,6 +685,38 @@ class ComponentType(CursedIntEnum):
683685
"""Select menu for picking from mentionable objects"""
684686
CHANNEL_SELECT = 8
685687
"""Select menu for picking from channels"""
688+
SECTION = 9
689+
"""Section component for grouping together text and thumbnails/buttons"""
690+
TEXT_DISPLAY = 10
691+
"""Text component for displaying text"""
692+
THUMBNAIL = 11
693+
"""Thumbnail component for displaying a thumbnail for an image"""
694+
MEDIA_GALLERY = 12
695+
"""Media gallery component for displaying multiple images"""
696+
FILE = 13
697+
"""File component for uploading files"""
698+
SEPARATOR = 14
699+
"""Separator component for visual separation"""
700+
CONTAINER = 17
701+
"""Container component for grouping together other components"""
702+
703+
# TODO: this is hacky, is there a better way to do this?
704+
@staticmethod
705+
def v2_component_types() -> set["ComponentType"]:
706+
return {
707+
ComponentType.SECTION,
708+
ComponentType.TEXT_DISPLAY,
709+
ComponentType.THUMBNAIL,
710+
ComponentType.MEDIA_GALLERY,
711+
ComponentType.FILE,
712+
ComponentType.SEPARATOR,
713+
ComponentType.CONTAINER,
714+
}
715+
716+
@property
717+
def v2_component(self) -> bool:
718+
"""Whether this component is a v2 component."""
719+
return self.value in self.v2_component_types()
686720

687721

688722
class IntegrationType(CursedIntEnum):

interactions/models/discord/message.py

+8
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
MessageFlags,
4343
MessageType,
4444
IntegrationType,
45+
ComponentType,
4546
)
4647
from .snowflake import (
4748
Snowflake,
@@ -1066,6 +1067,13 @@ def process_message_payload(
10661067
embeds = embeds if all(e is not None for e in embeds) else None
10671068

10681069
components = models.process_components(components)
1070+
if components:
1071+
# TODO: should we check for content/embeds? should this be moved elsewhere?
1072+
if any(c["type"] in ComponentType.v2_component_types() for c in components):
1073+
if not flags:
1074+
flags = 0
1075+
flags |= MessageFlags.IS_COMPONENTS_V2
1076+
10691077
if stickers:
10701078
stickers = [to_snowflake(sticker) for sticker in stickers]
10711079
allowed_mentions = process_allowed_mentions(allowed_mentions)

0 commit comments

Comments
 (0)