diff --git a/docs/source/client.rst b/docs/source/client.rst index 24700b0..2e43ff0 100644 --- a/docs/source/client.rst +++ b/docs/source/client.rst @@ -43,7 +43,7 @@ to handle it, which defaults to print a traceback and ignoring the exception. .. note:: It will not be received by :meth:`Client.wait_for`. - :param exception: Produced error. + :param exception: Exception. :type exception: :class:`Exception` .. function:: on_channel_create(channel) @@ -112,7 +112,7 @@ to handle it, which defaults to print a traceback and ignoring the exception. See the docs of :attr:`Intents.MESSAGE_CONTENT` for more information. :param message: The current message. - :type message: :class:`models.message.message.Message + :type message: :class:`models.message.message.Message` .. function:: on_shard_ready(shard_id) diff --git a/melisa/exceptions.py b/melisa/exceptions.py index c41debc..eb201d0 100644 --- a/melisa/exceptions.py +++ b/melisa/exceptions.py @@ -55,6 +55,28 @@ class PrivilegedIntentsRequired(ClientException): super().__init__(message.format(self.shard_id)) +class EmbedFieldError(MelisaException, ValueError): + """Occurs when an embed field is too large.""" + + @classmethod + def characters_from_desc(cls, field_type: str, current_size: int, max_size: int): + """Create an instance by description. + + Parameters + ---------- + field_type :class:`str` + The type/name of the field. + current_size :class:`int` + The current size of the field. + max_si :class:`int` + The maximum size of the field. + """ + return cls( + f"{field_type} can have maximum {max_size} characters." + f" (Current size: {current_size})" + ) + + class HTTPException(MelisaException): """Occurs when an HTTP request operation fails.""" diff --git a/melisa/models/guild/channel.py b/melisa/models/guild/channel.py index 5b06310..59034d6 100644 --- a/melisa/models/guild/channel.py +++ b/melisa/models/guild/channel.py @@ -18,6 +18,8 @@ from typing import ( ) from ..message.message import Message +from ...exceptions import EmbedFieldError +from ...models.message.embed import Embed from ...utils import Snowflake, Timestamp from ...utils import APIModelBase from ...utils.types import APINullable @@ -502,7 +504,9 @@ class MessageableChannel(Channel): headers={"X-Audit-Log-Reason": reason}, ) - async def send(self, content: str = None) -> Message: + async def send( + self, content: str = None, *, embed: Embed = None, embeds: List[Embed] = None + ) -> Message: """|coro| Sends a message to the destination with the content given. @@ -513,6 +517,10 @@ class MessageableChannel(Channel): ---------- content: Optional[:class:`str`] The content of the message to send. + embed: Optional[:class:`~melisa.models.message.embed.Embed`] + Embed + embeds: Optional[List[:class:`~melisa.models.message.embed.Embed`]] + List of embeds Raises ------- @@ -526,11 +534,22 @@ class MessageableChannel(Channel): # ToDo: Add other parameters + if embeds is None: + embeds = [] + content = str(content) if content is not None else None + embeds.append(embed.to_dict()) if embed is not None else None + + for _embed in embeds: + if embed.total_length() > 6000: + raise EmbedFieldError.characters_from_desc( + "Embed", embed.total_length(), 6000 + ) return Message.from_dict( await self._http.post( - f"/channels/{self.id}/messages", data={"content": content} + f"/channels/{self.id}/messages", + data={"content": content, "embeds": embeds}, ) ) diff --git a/melisa/models/guild/guild.py b/melisa/models/guild/guild.py index f31e0bf..99b77b8 100644 --- a/melisa/models/guild/guild.py +++ b/melisa/models/guild/guild.py @@ -169,85 +169,6 @@ class SystemChannelFlags(IntEnum): return self.value -class GuildFeatures(Enum): - """Guild Features - - Attributes - ---------- - ANIMATED_ICON: - Guild has access to set an animated guild icon - BANNER: - Guild has access to set a guild banner image - COMMERCE: - Guild has access to use commerce features (i.e. create store channels) - COMMUNITY: - Guild can enable welcome screen, Membership Screening, - stage channels and discovery, and receives community updates - DISCOVERABLE: - Guild is able to be discovered in the directory - FEATURABLE: - Guild is able to be featured in the directory - INVITE_SPLASH: - Guild has access to set an invite splash background - MEMBER_VERIFICATION_GATE_ENABLED: - Guild has enabled Membership Screening - MONETIZATION_ENABLED: - Guild has enabled monetization - MORE_STICKERS: - Guild has increased custom sticker slots - NEWS: - Guild has access to create news channels - PARTNERED: - Guild is partnered - PREVIEW_ENABLED: - Guild can be previewed before joining via Membership Screening or the directory - PRIVATE_THREADS: - Guild has access to create private threads - ROLE_ICONS: - Guild is able to set role icons - SEVEN_DAY_THREAD_ARCHIVE: - Guild has access to the seven day archive time for threads - THREE_DAY_THREAD_ARCHIVE: - Guild has access to the three day archive time for threads - TICKETED_EVENTS_ENABLED: - Guild has enabled ticketed events - VANITY_URL: - Guild has access to set a vanity URL - VERIFIED: - Guild is verified - VIP_REGIONS: - Guild has access to set 384kbps bitrate in voice (previously VIP voice servers) - WELCOME_SCREEN_ENABLED: - Guild has enabled the welcome screen - EXPOSED_TO_ACTIVITIES_WTP_EXPERIMENT: - Unkown. Found during testing. Not listed in Discord API docs. - """ - - ANIMATED_ICON = "ANIMATED_ICON" - BANNER = "BANNER" - COMMERCE = "COMMERCE" - COMMUNITY = "COMMUNITY" - DISCOVERABLE = "DISCOVERABLE" - FEATURABLE = "FEATURABLE" - INVITE_SPLASH = "INVITE_SPLASH" - MEMBER_VERIFICATION_GATE_ENABLED = "MEMBER_VERIFICATION_GATE_ENABLED" - MONETIZATION_ENABLED = "MONETIZATION_ENABLED" - MORE_STICKERS = "MORE_STICKERS" - NEWS = "NEWS" - PARTNERED = "PARTNERED" - PREVIEW_ENABLED = "PREVIEW_ENABLED" - PRIVATE_THREADS = "PRIVATE_THREADS" - ROLE_ICONS = "ROLE_ICONS" - SEVEN_DAY_THREAD_ARCHIVE = "SEVEN_DAY_THREAD_ARCHIVE" - THREE_DAY_THREAD_ARCHIVE = "THREE_DAY_THREAD_ARCHIVE" - TICKETED_EVENTS_ENABLED = "TICKETED_EVENTS_ENABLED" - VANITY_URL = "VANITY_URL" - VERIFIED = "VERIFIED" - VIP_REGIONS = "VIP_REGIONS" - WELCOME_SCREEN_ENABLED = "WELCOME_SCREEN_ENABLED" - EXPOSED_TO_ACTIVITIES_WTP_EXPERIMENT = "EXPOSED_TO_ACTIVITIES_WTP_EXPERIMENT" - - @dataclass(repr=False) class Guild(APIModelBase): """Guilds in Discord represent an isolated collection of users and channels, @@ -289,7 +210,7 @@ class Guild(APIModelBase): Default message notifications level explicit_content_filter: :class:`int` Explicit content filter level - features: APINullable[:class:`typing.Any`] + features: APINullable[List[:class:`str`]] Enabled guild features roles: APINullable[:class:`typing.Any`] Roles in the guild @@ -388,7 +309,7 @@ class Guild(APIModelBase): verification_level: APINullable[int] = None default_message_notifications: APINullable[int] = None explicit_content_filter: APINullable[int] = None - features: APINullable[List[GuildFeatures]] = None + features: APINullable[List[str]] = None roles: APINullable[List] = None emojis: APINullable[List] = None # TODO: Make a structures of emoji and role diff --git a/melisa/models/message/__init__.py b/melisa/models/message/__init__.py index 77d95ac..3a11a3f 100644 --- a/melisa/models/message/__init__.py +++ b/melisa/models/message/__init__.py @@ -1,6 +1,5 @@ # Copyright MelisaDev 2022 - Present # Full MIT License can be found in `LICENSE.txt` at the project root. -from .message import MessageActivityType, MessageFlags, MessageType, Message - -__all__ = ("MessageActivityType", "MessageFlags", "MessageType", "Message") +from .message import * +from .embed import * diff --git a/melisa/models/message/embed.py b/melisa/models/message/embed.py new file mode 100644 index 0000000..11e81fd --- /dev/null +++ b/melisa/models/message/embed.py @@ -0,0 +1,396 @@ +# Copyright MelisaDev 2022 - Present +# Full MIT License can be found in `LICENSE.txt` at the project root. + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from enum import Enum + +from typing import List, Union, Optional + +from melisa.exceptions import EmbedFieldError +from melisa.utils.api_model import APIModelBase, APINullable +from melisa.utils.timestamp import Timestamp + + +class EmbedType(Enum): + """ + Embed types are "loosely defined" and, for the most part, + are not used by our clients for rendering. + Embed attributes power what is rendered. + Embed types should be considered deprecated and might be removed in a future API version. + + Attributes + __________ + RICH: + Generic embed rendered from embed attributes + IMAGE + Image embed + VIDEO + Video embed + GIFV + Animated gif image embed rendered as a video embed + ARTICLE + Article embed + LINK + Link embed + """ + + RICH = "rich" + IMAGE = "image" + VIDEO = "video" + GIFV = "gifv" + ARTICLE = "article" + LINK = "link" + + +@dataclass(repr=False) +class EmbedThumbnail(APIModelBase): + """Representation of the Embed Thumbnail + + Attributes + ---------- + url: :class:`str` + Source url of the thumbnail + proxy_url: Optional[:class:`str`] + A proxied url of the thumbnail + height: Optional[:class:`int`] + Height of the thumbnail + width: Optional[:class:`int`] + Width of the thumbnail + """ + + url: str + proxy_url: APINullable[str] = None + height: APINullable[int] = None + width: APINullable[int] = None + + +@dataclass(repr=False) +class EmbedVideo(APIModelBase): + """Representation of the Embed Video + + Attributes + ---------- + url: Optional[:class:`str`] + Source url of the video + proxy_url: Optional[:class:`str`] + A proxied url of the video + height: Optional[:class:`int`] + Height of the video + width: Optional[:class:`int`] + Width of the video + """ + + url: str + proxy_url: APINullable[str] = None + height: APINullable[int] = None + width: APINullable[int] = None + + +@dataclass(repr=False) +class EmbedImage(APIModelBase): + """Representation of the Embed Image + + Attributes + ---------- + url: :class:`str` + Source url of image (only supports http(s) and attachments) + proxy_url: Optional[:class:`str`] + A proxied url of the image + height: Optional[:class:`int`] + Height of the image + width: Optional[:class:`int`] + Width of the image + """ + + url: str + proxy_url: APINullable[str] = None + height: APINullable[int] = None + width: APINullable[int] = None + + +@dataclass(repr=False) +class EmbedProvider(APIModelBase): + """Representation of the Embed Provider + + Attributes + ---------- + name: Optional[:class:`str`] + Name of provider + url: Optional[:class:`str`] + Url of provider + """ + + name: APINullable[str] = None + url: APINullable[str] = None + + +@dataclass(repr=False) +class EmbedAuthor(APIModelBase): + """Representation of the Embed Author + + Attributes + ---------- + name: :class:`str` + Name of author + url: Optional[:class:`str`] + Url of author + icon_url: Optional[:class:`str`] + Url of author icon (only supports http(s) and attachments) + proxy_icon_url: Optional[:class:`str`] + A proxied url of author icon + """ + + name: str + url: APINullable[str] = None + icon_url: APINullable[str] = None + proxy_icon_url: APINullable[str] = None + + +@dataclass(repr=False) +class EmbedFooter(APIModelBase): + """Representation of the Embed Footer + + Attributes + ---------- + text: :class:`str` + Footer text + icon_url: Optional[:class:`str`] + Url of footer icon (only supports http(s) and attachments) + proxy_icon_url: Optional[:class:`str`] + A proxied url of footer icon + """ + + text: str + icon_url: APINullable[str] = None + proxy_icon_url: APINullable[str] = None + + +@dataclass(repr=False) +class EmbedField(APIModelBase): + """Representation of the Embed Field + + Attributes + ---------- + name: :class:`str` + Name of the field + value: :class:`str` + Value of the field + inline: Optional[:class:`bool`] + Whether or not this field should display inline + """ + + name: str + value: str + inline: APINullable[str] = None + + +@dataclass(repr=False) +class Embed(APIModelBase): + # ToDo: Add fields set method + + """Represents an embed sent in with message within Discord. + + Attributes + ---------- + + title: Optional[:class:`str`] + Title of embed + type: Optional[:class:`~melisa.models.message.embed.EmbedType`] + Type of embed (always "rich" for webhook embeds) + description: Optional[:class:`str`] + Description of embed + color: Optional[:class:`int`] + Color code of the embed + fields: Optional[List[:class:`~melisa.models.message.embed.EmbedField`]] + Fields information. + footer: Optional[:class:`~melisa.models.message.embed.EmbedFooter`] + Footer information. + image: Optional[:class:`~melisa.models.message.embed.EmbedImage`] + Image information. + provider: Optional[:class:`~melisa.models.message.embed.EmbedProvider`] + Provider information. + thumbnail: Optional[:class:`~melisa.models.message.embed.EmbedThumbnail`] + Thumbnail information. + timestamp: Optional[:class:`~melisa.utils.timestamp.Timestamp`] + Timestamp of embed content + url: Optional[:class:`str`] + Url of embed + video: Optional[:class:`~melisa.models.message.embed.EmbedVideo`] + Video information. + """ + + title: APINullable[str] = None + type: APINullable[EmbedType] = None + description: APINullable[str] = None + url: APINullable[str] = None + timestamp: APINullable[Timestamp] = None + color: APINullable[int] = None + footer: APINullable[EmbedFooter] = None + image: APINullable[EmbedImage] = None + thumbnail: APINullable[EmbedThumbnail] = None + video: APINullable[EmbedVideo] = None + provider: APINullable[EmbedProvider] = None + author: APINullable[EmbedAuthor] = None + fields: APINullable[List[EmbedField]] = None + + def __post_init__(self): + if self.title and len(self.title) > 256: + raise EmbedFieldError.characters_from_desc( + "Embed Title", + len(self.title), + 256, + ) + + if self.description and len(self.description) > 4096: + raise EmbedFieldError.characters_from_desc( + "Embed Description", len(self.description), 4096 + ) + + if self.fields and len(self.fields) > 25: + raise EmbedFieldError("""You can't set more than 25 embed fields!""") + + def set_timestamp(self, time: Union[Timestamp, datetime]) -> Embed: + """Sets timestamp in the supported by discord format. + + Parameters + ---------- + time: :class:`~melisa.utils.timestamp.Timestamp` + The datetime to set the timestamp to. + + Returns + ------- + :class:`~,e;osa.models.message.embed.Embed` + The new embed object. + """ + self.timestamp = time.isoformat() + + return self + + def set_author( + self, + name: str, + *, + url: Optional[str] = None, + icon_url: Optional[str] = None, + proxy_icon_url: Optional[str] = None, + ) -> Embed: + """Set the author for the embed. + + Parameters + ---------- + name: :class:`str` + Name of author + url: Optional[:class:`str`] + Url of author icon (only supports http(s) and attachments) + icon_url: Optional[:class:`str`] + Url of author + proxy_icon_url: Optional[:class:`str`] + A proxied url of author icon + Returns + ------- + :class:`~melisa.models.message.embed.Embed` + Updated embed. + """ + + self.author = EmbedAuthor( + name=name, + url=url, + icon_url=icon_url, + proxy_icon_url=proxy_icon_url, + ) + + return self + + def set_image(self, url: str, *, proxy_url: Optional[str] = None) -> Embed: + """Set the image for the embed. + + Parameters + ---------- + url: :class:`str` + Source url of image (only supports http(s) and attachments) + proxy_url: Optional[:class:`str`] + A proxied url of the image + Returns + ------- + :class:`~melisa.models.message.embed.Embed` + Updated embed. + """ + + self.image = EmbedImage(url=url, proxy_url=proxy_url) + + return self + + def set_thumbnail(self, url: str, *, proxy_url: Optional[str] = None) -> Embed: + """Set the thumbnail for the embed. + + Parameters + ---------- + url: :class:`str` + Source url of thumbnail (only supports http(s) and attachments) + proxy_url: Optional[:class:`str`] + A proxied url of the thumbnail + Returns + ------- + :class:`~melisa.models.message.embed.Embed` + Updated embed. + """ + + self.thumbnail = EmbedThumbnail(url=url, proxy_url=proxy_url) + + return self + + def set_footer( + self, + text: str, + *, + icon_url: Optional[str] = None, + proxy_icon_url: Optional[str] = None, + ) -> Embed: + """ + Sets the embed footer. + + Parameters + ---------- + text: :class:`str` + Footer text + icon_url: Optional[:class:`str`] + Url of footer icon (only supports http(s) and attachments) + proxy_icon_url: Optional[:class:`str`] + A proxied url of footer icon + + Returns + ------- + :class:`~melisa.models.message.embed.Embed` + Updated embed. + """ + self.footer = EmbedFooter( + text=text, icon_url=icon_url, proxy_icon_url=proxy_icon_url + ) + + return self + + def total_length(self) -> int: + """Get the total character count of the embed. + + Returns + ------- + :class:`int` + The total character count of this embed, including title, description, + fields, footer, and author combined. + """ + total = len(self.title or "") + len(self.description or "") + + if self.fields: + for field in self.fields: + total += len(field.name) + len(field.value) + + if self.footer and self.footer.text: + total += len(self.footer.text) + + if self.author and self.author.name: + total += len(self.author.name) + + return total diff --git a/tests/test_embeds.py b/tests/test_embeds.py new file mode 100644 index 0000000..7c7baf8 --- /dev/null +++ b/tests/test_embeds.py @@ -0,0 +1,58 @@ +import datetime + +from melisa import Embed, Timestamp + +dict_embed = { + 'title': 'my title', + 'description': 'simple description', + 'timestamp': datetime.datetime.utcfromtimestamp(1649748784).isoformat(), + 'footer': { + 'text': 'cool footer text' + }, + 'author': { + 'name': 'best author' + }, +} + + +class TestEmbed: + def test_total_length_when_embed_is_empty(self): + embed = Embed() + assert embed.total_length() == 0 + + def test_total_length_when_title_is_none(self): + embed = Embed(title=None) + assert embed.total_length() == 0 + + def test_total_length_title(self): + embed = Embed(title="my title") + assert embed.total_length() == 8 + + def test_total_length_when_description_is_none(self): + embed = Embed(description=None) + assert embed.total_length() == 0 + + def test_total_length_description(self): + embed = Embed(description="simple description") + assert embed.total_length() == 18 + + def test_total_length_author_name(self): + embed = Embed().set_author(name="best author") + assert embed.total_length() == 11 + + def test_total_length_footer_text(self): + embed = Embed().set_footer(text="cool footer text") + assert embed.total_length() == 16 + + def test_total_length_all(self): + embed = Embed(title="my title", description="simple description") + embed.set_author(name="best author") + embed.set_footer(text="cool footer text") + assert embed.total_length() == 53 + + def test_comparing_embeds(self): + embed = Embed(title="my title", description="simple description") + embed.set_author(name="best author") + embed.set_footer(text="cool footer text") + embed.set_timestamp(Timestamp.parse(1649748784)) + assert embed.to_dict() == dict_embed