diff --git a/discord/attachment.py b/discord/attachment.py new file mode 100644 index 000000000..2be4eac1a --- /dev/null +++ b/discord/attachment.py @@ -0,0 +1,417 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the 'Software'), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" +from __future__ import annotations + +import io +from os import PathLike +from typing import TYPE_CHECKING, Any, Optional, Union + +from .mixins import Hashable +from .file import File +from .state import ConnectionState +from .flags import AttachmentFlags +from . import utils + +if TYPE_CHECKING: + from .types.attachment import Attachment as AttachmentPayload + +MISSING = utils.MISSING + +__all__ = ( + 'Attachment', + 'UnfurledAttachment', +) + + +class AttachmentBase: + url: str + + async def save( + self, + fp: Union[io.BufferedIOBase, PathLike[Any]], + *, + seek_begin: bool = True, + use_cached: bool = False, + ) -> int: + """|coro| + + Saves this attachment into a file-like object. + + Parameters + ---------- + fp: Union[:class:`io.BufferedIOBase`, :class:`os.PathLike`] + The file-like object to save this attachment to or the filename + to use. If a filename is passed then a file is created with that + filename and used instead. + seek_begin: :class:`bool` + Whether to seek to the beginning of the file after saving is + successfully done. + use_cached: :class:`bool` + Whether to use :attr:`proxy_url` rather than :attr:`url` when downloading + the attachment. This will allow attachments to be saved after deletion + more often, compared to the regular URL which is generally deleted right + after the message is deleted. Note that this can still fail to download + deleted attachments if too much time has passed and it does not work + on some types of attachments. + + Raises + -------- + HTTPException + Saving the attachment failed. + NotFound + The attachment was deleted. + + Returns + -------- + :class:`int` + The number of bytes written. + """ + data = await self.read(use_cached=use_cached) + if isinstance(fp, io.BufferedIOBase): + written = fp.write(data) + if seek_begin: + fp.seek(0) + return written + else: + with open(fp, 'wb') as f: + return f.write(data) + + async def read(self, *, use_cached: bool = False) -> bytes: + """|coro| + + Retrieves the content of this attachment as a :class:`bytes` object. + + .. versionadded:: 1.1 + + Parameters + ----------- + use_cached: :class:`bool` + Whether to use :attr:`proxy_url` rather than :attr:`url` when downloading + the attachment. This will allow attachments to be saved after deletion + more often, compared to the regular URL which is generally deleted right + after the message is deleted. Note that this can still fail to download + deleted attachments if too much time has passed and it does not work + on some types of attachments. + + Raises + ------ + HTTPException + Downloading the attachment failed. + Forbidden + You do not have permissions to access this attachment + NotFound + The attachment was deleted. + + Returns + ------- + :class:`bytes` + The contents of the attachment. + """ + url = self.proxy_url if use_cached else self.url + data = await self._http.get_from_cdn(url) + return data + + async def to_file( + self, + *, + filename: Optional[str] = MISSING, + description: Optional[str] = MISSING, + use_cached: bool = False, + spoiler: bool = False, + ) -> File: + """|coro| + + Converts the attachment into a :class:`File` suitable for sending via + :meth:`abc.Messageable.send`. + + .. versionadded:: 1.3 + + Parameters + ----------- + filename: Optional[:class:`str`] + The filename to use for the file. If not specified then the filename + of the attachment is used instead. + + .. versionadded:: 2.0 + description: Optional[:class:`str`] + The description to use for the file. If not specified then the + description of the attachment is used instead. + + .. versionadded:: 2.0 + use_cached: :class:`bool` + Whether to use :attr:`proxy_url` rather than :attr:`url` when downloading + the attachment. This will allow attachments to be saved after deletion + more often, compared to the regular URL which is generally deleted right + after the message is deleted. Note that this can still fail to download + deleted attachments if too much time has passed and it does not work + on some types of attachments. + + .. versionadded:: 1.4 + spoiler: :class:`bool` + Whether the file is a spoiler. + + .. versionadded:: 1.4 + + Raises + ------ + HTTPException + Downloading the attachment failed. + Forbidden + You do not have permissions to access this attachment + NotFound + The attachment was deleted. + + Returns + ------- + :class:`File` + The attachment as a file suitable for sending. + """ + + data = await self.read(use_cached=use_cached) + file_filename = filename if filename is not MISSING else self.filename + file_description = ( + description if description is not MISSING else self.description + ) + return File( + io.BytesIO(data), + filename=file_filename, + description=file_description, + spoiler=spoiler, + ) + + +class Attachment(Hashable, AttachmentBase): + """Represents an attachment from Discord. + + .. container:: operations + + .. describe:: str(x) + + Returns the URL of the attachment. + + .. describe:: x == y + + Checks if the attachment is equal to another attachment. + + .. describe:: x != y + + Checks if the attachment is not equal to another attachment. + + .. describe:: hash(x) + + Returns the hash of the attachment. + + .. versionchanged:: 1.7 + Attachment can now be casted to :class:`str` and is hashable. + + Attributes + ------------ + id: :class:`int` + The attachment ID. + size: :class:`int` + The attachment size in bytes. + height: Optional[:class:`int`] + The attachment's height, in pixels. Only applicable to images and videos. + width: Optional[:class:`int`] + The attachment's width, in pixels. Only applicable to images and videos. + filename: :class:`str` + The attachment's filename. + url: :class:`str` + The attachment URL. If the message this attachment was attached + to is deleted, then this will 404. + proxy_url: :class:`str` + The proxy URL. This is a cached version of the :attr:`~Attachment.url` in the + case of images. When the message is deleted, this URL might be valid for a few + minutes or not valid at all. + content_type: Optional[:class:`str`] + The attachment's `media type `_ + + .. versionadded:: 1.7 + description: Optional[:class:`str`] + The attachment's description. Only applicable to images. + + .. versionadded:: 2.0 + ephemeral: :class:`bool` + Whether the attachment is ephemeral. + + .. versionadded:: 2.0 + duration: Optional[:class:`float`] + The duration of the audio file in seconds. Returns ``None`` if it's not a voice message. + + .. versionadded:: 2.3 + waveform: Optional[:class:`bytes`] + The waveform (amplitudes) of the audio in bytes. Returns ``None`` if it's not a voice message. + + .. versionadded:: 2.3 + title: Optional[:class:`str`] + The normalised version of the attachment's filename. + + .. versionadded:: 2.5 + """ + + __slots__ = ( + 'id', + 'size', + 'height', + 'width', + 'filename', + 'url', + 'proxy_url', + '_http', + 'content_type', + 'description', + 'ephemeral', + 'duration', + 'waveform', + '_flags', + 'title', + ) + + def __init__(self, *, data: AttachmentPayload, state: ConnectionState): + self.id: int = int(data['id']) + self.size: int = data['size'] + self.height: Optional[int] = data.get('height') + self.width: Optional[int] = data.get('width') + self.filename: str = data['filename'] + self.url: str = data['url'] + self.proxy_url: str = data['proxy_url'] + self._http = state.http + self.content_type: Optional[str] = data.get('content_type') + self.description: Optional[str] = data.get('description') + self.ephemeral: bool = data.get('ephemeral', False) + self.duration: Optional[float] = data.get('duration_secs') + self.title: Optional[str] = data.get('title') + + waveform = data.get('waveform') + self.waveform: Optional[bytes] = ( + utils._base64_to_bytes(waveform) if waveform is not None else None + ) + + self._flags: int = data.get('flags', 0) + + @property + def flags(self) -> AttachmentFlags: + """:class:`AttachmentFlags`: The attachment's flags.""" + return AttachmentFlags._from_value(self._flags) + + def is_spoiler(self) -> bool: + """:class:`bool`: Whether this attachment contains a spoiler.""" + return self.filename.startswith('SPOILER_') + + def is_voice_message(self) -> bool: + """:class:`bool`: Whether this attachment is a voice message.""" + return self.duration is not None and 'voice-message' in self.url + + def __repr__(self) -> str: + return f'' + + def __str__(self) -> str: + return self.url or '' + + def to_dict(self) -> AttachmentPayload: + result: AttachmentPayload = { + 'filename': self.filename, + 'id': self.id, + 'proxy_url': self.proxy_url, + 'size': self.size, + 'url': self.url, + 'spoiler': self.is_spoiler(), + } + if self.height: + result['height'] = self.height + if self.width: + result['width'] = self.width + if self.content_type: + result['content_type'] = self.content_type + if self.description is not None: + result['description'] = self.description + return result + + +class UnfurledAttachment(AttachmentBase): + """Represents an unfurled attachment item from a :class:`Component`. + + .. versionadded:: tbd + + .. container:: operations + + .. describe:: str(x) + + Returns the URL of the attachment. + + .. describe:: x == y + + Checks if the unfurled attachment is equal to another unfurled attachment. + + .. describe:: x != y + + Checks if the unfurled attachment is not equal to another unfurled attachment. + + Attributes + ---------- + url: :class:`str` + The unfurled attachment URL. + proxy_url: Optional[:class:`str`] + The proxy URL. This is cached version of the :attr:`~UnfurledAttachment.url` in the + case of images. When the message is deleted, this URL might be valid for a few + minutes or not valid at all. + + .. note:: + + This will be ``None`` if :meth:`.is_resolved` is ``False``. + height: Optional[:class:`int`] + The unfurled attachment's height, in pixels. + + .. note:: + + This will be ``None`` if :meth:`.is_resolved` is ``False``. + width: Optional[:class:`int`] + The unfurled attachment's width, in pixels. + + .. note:: + + This will be ``None`` if :meth:`.is_resolved` is ``False``. + content_type: Optional[:class:`str`] + The attachment's `media type `_ + + .. note:: + + This will be ``None`` if :meth:`.is_resolved` is ``False``. + loading_state: :class:`MediaLoadingState` + The load state of this attachment on Discord side. + description + """ + + __slots__ = ( + 'url', + 'proxy_url', + 'height', + 'width', + 'content_type', + 'loading_state', + '_resolved', + '_state', + ) + + def __init__(self, ) diff --git a/discord/components.py b/discord/components.py index 2af2d6d20..141c03cc2 100644 --- a/discord/components.py +++ b/discord/components.py @@ -4,7 +4,7 @@ The MIT License (MIT) Copyright (c) 2015-present Rapptz Permission is hereby granted, free of charge, to any person obtaining a -copy of this software and associated documentation files (the "Software"), +copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the @@ -13,7 +13,7 @@ Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER @@ -24,8 +24,24 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations -from typing import ClassVar, List, Literal, Optional, TYPE_CHECKING, Tuple, Union, overload -from .enums import try_enum, ComponentType, ButtonStyle, TextStyle, ChannelType, SelectDefaultValueType +from typing import ( + ClassVar, + List, + Literal, + Optional, + TYPE_CHECKING, + Tuple, + Union, +) +from .enums import ( + try_enum, + ComponentType, + ButtonStyle, + TextStyle, + ChannelType, + SelectDefaultValueType, + DividerSize, +) from .utils import get_slots, MISSING from .partial_emoji import PartialEmoji, _EmojiTag @@ -33,14 +49,21 @@ if TYPE_CHECKING: from typing_extensions import Self from .types.components import ( + ComponentBase as ComponentBasePayload, Component as ComponentPayload, ButtonComponent as ButtonComponentPayload, SelectMenu as SelectMenuPayload, SelectOption as SelectOptionPayload, ActionRow as ActionRowPayload, TextInput as TextInputPayload, - ActionRowChildComponent as ActionRowChildComponentPayload, SelectDefaultValues as SelectDefaultValuesPayload, + SectionComponent as SectionComponentPayload, + TextComponent as TextComponentPayload, + ThumbnailComponent as ThumbnailComponentPayload, + MediaGalleryComponent as MediaGalleryComponentPayload, + FileComponent as FileComponentPayload, + DividerComponent as DividerComponentPayload, + ComponentContainer as ComponentContainerPayload, ) from .emoji import Emoji from .abc import Snowflake @@ -56,6 +79,13 @@ __all__ = ( 'SelectOption', 'TextInput', 'SelectDefaultValue', + 'SectionComponent', + 'TextComponent', + 'ThumbnailComponent', + 'MediaGalleryComponent', + 'FileComponent', + 'DividerComponent', + 'ComponentContainer', ) @@ -99,7 +129,7 @@ class Component: setattr(self, slot, value) return self - def to_dict(self) -> ComponentPayload: + def to_dict(self) -> ComponentBasePayload: raise NotImplementedError @@ -290,9 +320,13 @@ class SelectMenu(Component): self.placeholder: Optional[str] = data.get('placeholder') self.min_values: int = data.get('min_values', 1) self.max_values: int = data.get('max_values', 1) - self.options: List[SelectOption] = [SelectOption.from_dict(option) for option in data.get('options', [])] + self.options: List[SelectOption] = [ + SelectOption.from_dict(option) for option in data.get('options', []) + ] self.disabled: bool = data.get('disabled', False) - self.channel_types: List[ChannelType] = [try_enum(ChannelType, t) for t in data.get('channel_types', [])] + self.channel_types: List[ChannelType] = [ + try_enum(ChannelType, t) for t in data.get('channel_types', []) + ] self.default_values: List[SelectDefaultValue] = [ SelectDefaultValue.from_dict(d) for d in data.get('default_values', []) ] @@ -312,7 +346,7 @@ class SelectMenu(Component): if self.channel_types: payload['channel_types'] = [t.value for t in self.channel_types] if self.default_values: - payload["default_values"] = [v.to_dict() for v in self.default_values] + payload['default_values'] = [v.to_dict() for v in self.default_values] return payload @@ -408,7 +442,9 @@ class SelectOption: elif isinstance(value, _EmojiTag): self._emoji = value._to_partial() else: - raise TypeError(f'expected str, Emoji, or PartialEmoji, received {value.__class__.__name__} instead') + raise TypeError( + f'expected str, Emoji, or PartialEmoji, received {value.__class__.__name__} instead' + ) else: self._emoji = None @@ -564,7 +600,9 @@ class SelectDefaultValue: @type.setter def type(self, value: SelectDefaultValueType) -> None: if not isinstance(value, SelectDefaultValueType): - raise TypeError(f'expected SelectDefaultValueType, received {value.__class__.__name__} instead') + raise TypeError( + f'expected SelectDefaultValueType, received {value.__class__.__name__} instead' + ) self._type = value @@ -642,17 +680,105 @@ class SelectDefaultValue: ) -@overload -def _component_factory(data: ActionRowChildComponentPayload) -> Optional[ActionRowChildComponentType]: - ... +class SectionComponent(Component): + """Represents a section from the Discord Bot UI Kit. + + This inherits from :class:`Component`. + + .. note:: + + The user constructible and usable type to create a section is :class:`discord.ui.Section` + not this one. + + .. versionadded:: tbd + + Attributes + ---------- + components: List[Union[:class:`TextDisplay`, :class:`Button`]] + The components on this section. + accessory: Optional[:class:`Component`] + The section accessory. + """ + + def __init__(self, data: SectionComponentPayload) -> None: + self.components: List[Union[TextDisplay, Button]] = [] + + for component_data in data['components']: + component = _component_factory(component_data) + if component is not None: + self.components.append(component) + + try: + self.accessory: Optional[Component] = _component_factory(data['accessory']) + except KeyError: + self.accessory = None + + @property + def type(self) -> Literal[ComponentType.section]: + return ComponentType.section + + def to_dict(self) -> SectionComponentPayload: + payload: SectionComponentPayload = { + 'type': self.type.value, + 'components': [c.to_dict() for c in self.components], + } + if self.accessory: + payload['accessory'] = self.accessory.to_dict() + return payload + +class TextDisplay(Component): + """Represents a text display from the Discord Bot UI Kit. -@overload -def _component_factory(data: ComponentPayload) -> Optional[Union[ActionRow, ActionRowChildComponentType]]: - ... + This inherits from :class:`Component`. + + .. versionadded:: tbd + + Parameters + ---------- + content: :class:`str` + The content that this display shows. + """ + + def __init__(self, content: str) -> None: + self.content: str = content + + @property + def type(self) -> Literal[ComponentType.text_display]: + return ComponentType.text_display + + @classmethod + def _from_data(cls, data: TextComponentPayload) -> TextDisplay: + return cls( + content=data['content'], + ) + + def to_dict(self) -> TextComponentPayload: + return { + 'type': self.type.value, + 'content': self.content, + } + + +class ThumbnailComponent(Component): + """Represents a thumbnail display from the Discord Bot UI Kit. + + This inherits from :class:`Component`. + + .. note:: + + The user constructuble and usable type to create a thumbnail + component is :class:`discord.ui.Thumbnail` not this one. + + .. versionadded:: tbd + + Attributes + ---------- + media: :class:`ComponentMedia` + """ -def _component_factory(data: ComponentPayload) -> Optional[Union[ActionRow, ActionRowChildComponentType]]: +def _component_factory(data: ComponentPayload) -> Optional[Component]: if data['type'] == 1: return ActionRow(data) elif data['type'] == 2: @@ -661,3 +787,17 @@ def _component_factory(data: ComponentPayload) -> Optional[Union[ActionRow, Acti return TextInput(data) elif data['type'] in (3, 5, 6, 7, 8): return SelectMenu(data) + elif data['type'] == 9: + return SectionComponent(data) + elif data['type'] == 10: + return TextDisplay._from_data(data) + elif data['type'] == 11: + return ThumbnailComponent(data) + elif data['type'] == 12: + return MediaGalleryComponent(data) + elif data['type'] == 13: + return FileComponent(data) + elif data['type'] == 14: + return DividerComponent(data) + elif data['type'] == 17: + return ComponentContainer(data) diff --git a/discord/enums.py b/discord/enums.py index ce772cc87..fc9303d19 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -77,6 +77,8 @@ __all__ = ( 'VoiceChannelEffectAnimationType', 'SubscriptionStatus', 'MessageReferenceType', + 'DividerSize', + 'MediaLoadingState', ) @@ -641,6 +643,13 @@ class ComponentType(Enum): role_select = 6 mentionable_select = 7 channel_select = 8 + section = 9 + text_display = 10 + thumbnail = 11 + media_gallery = 12 + file = 13 + separator = 14 + container = 17 def __int__(self) -> int: return self.value @@ -863,6 +872,18 @@ class SubscriptionStatus(Enum): inactive = 2 +class DividerSize(Enum): + small = 1 + large = 2 + + +class MediaLoadingState(Enum): + unknown = 0 + loading = 1 + loaded = 2 + not_found = 3 + + def create_unknown_value(cls: Type[E], val: Any) -> E: value_cls = cls._enum_value_cls_ # type: ignore # This is narrowed below name = f'unknown_{val}' diff --git a/discord/flags.py b/discord/flags.py index de806ba9c..3be323983 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -498,6 +498,14 @@ class MessageFlags(BaseFlags): """ return 16384 + @flag_value + def components_v2(self): + """:class:`bool`: Returns ``True`` if the message has Discord's v2 components. + + Does not allow sending any ``content``, ``embed``, or ``embeds``. + """ + return 32768 + @fill_with_flags() class PublicUserFlags(BaseFlags): diff --git a/discord/message.py b/discord/message.py index 3016d2f29..1010e1c12 100644 --- a/discord/message.py +++ b/discord/message.py @@ -27,8 +27,6 @@ from __future__ import annotations import asyncio import datetime import re -import io -from os import PathLike from typing import ( Dict, TYPE_CHECKING, @@ -55,7 +53,7 @@ from .errors import HTTPException from .components import _component_factory from .embeds import Embed from .member import Member -from .flags import MessageFlags, AttachmentFlags +from .flags import MessageFlags from .file import File from .utils import escape_mentions, MISSING, deprecated from .http import handle_message_parameters @@ -65,6 +63,7 @@ from .sticker import StickerItem, GuildSticker from .threads import Thread from .channel import PartialMessageable from .poll import Poll +from .attachment import Attachment if TYPE_CHECKING: from typing_extensions import Self @@ -108,7 +107,6 @@ if TYPE_CHECKING: __all__ = ( - 'Attachment', 'Message', 'PartialMessage', 'MessageInteraction', @@ -140,298 +138,6 @@ def convert_emoji_reaction(emoji: Union[EmojiInputType, Reaction]) -> str: raise TypeError(f'emoji argument must be str, Emoji, or Reaction not {emoji.__class__.__name__}.') -class Attachment(Hashable): - """Represents an attachment from Discord. - - .. container:: operations - - .. describe:: str(x) - - Returns the URL of the attachment. - - .. describe:: x == y - - Checks if the attachment is equal to another attachment. - - .. describe:: x != y - - Checks if the attachment is not equal to another attachment. - - .. describe:: hash(x) - - Returns the hash of the attachment. - - .. versionchanged:: 1.7 - Attachment can now be casted to :class:`str` and is hashable. - - Attributes - ------------ - id: :class:`int` - The attachment ID. - size: :class:`int` - The attachment size in bytes. - height: Optional[:class:`int`] - The attachment's height, in pixels. Only applicable to images and videos. - width: Optional[:class:`int`] - The attachment's width, in pixels. Only applicable to images and videos. - filename: :class:`str` - The attachment's filename. - url: :class:`str` - The attachment URL. If the message this attachment was attached - to is deleted, then this will 404. - proxy_url: :class:`str` - The proxy URL. This is a cached version of the :attr:`~Attachment.url` in the - case of images. When the message is deleted, this URL might be valid for a few - minutes or not valid at all. - content_type: Optional[:class:`str`] - The attachment's `media type `_ - - .. versionadded:: 1.7 - description: Optional[:class:`str`] - The attachment's description. Only applicable to images. - - .. versionadded:: 2.0 - ephemeral: :class:`bool` - Whether the attachment is ephemeral. - - .. versionadded:: 2.0 - duration: Optional[:class:`float`] - The duration of the audio file in seconds. Returns ``None`` if it's not a voice message. - - .. versionadded:: 2.3 - waveform: Optional[:class:`bytes`] - The waveform (amplitudes) of the audio in bytes. Returns ``None`` if it's not a voice message. - - .. versionadded:: 2.3 - title: Optional[:class:`str`] - The normalised version of the attachment's filename. - - .. versionadded:: 2.5 - """ - - __slots__ = ( - 'id', - 'size', - 'height', - 'width', - 'filename', - 'url', - 'proxy_url', - '_http', - 'content_type', - 'description', - 'ephemeral', - 'duration', - 'waveform', - '_flags', - 'title', - ) - - def __init__(self, *, data: AttachmentPayload, state: ConnectionState): - self.id: int = int(data['id']) - self.size: int = data['size'] - self.height: Optional[int] = data.get('height') - self.width: Optional[int] = data.get('width') - self.filename: str = data['filename'] - self.url: str = data['url'] - self.proxy_url: str = data['proxy_url'] - self._http = state.http - self.content_type: Optional[str] = data.get('content_type') - self.description: Optional[str] = data.get('description') - self.ephemeral: bool = data.get('ephemeral', False) - self.duration: Optional[float] = data.get('duration_secs') - self.title: Optional[str] = data.get('title') - - waveform = data.get('waveform') - self.waveform: Optional[bytes] = utils._base64_to_bytes(waveform) if waveform is not None else None - - self._flags: int = data.get('flags', 0) - - @property - def flags(self) -> AttachmentFlags: - """:class:`AttachmentFlags`: The attachment's flags.""" - return AttachmentFlags._from_value(self._flags) - - def is_spoiler(self) -> bool: - """:class:`bool`: Whether this attachment contains a spoiler.""" - return self.filename.startswith('SPOILER_') - - def is_voice_message(self) -> bool: - """:class:`bool`: Whether this attachment is a voice message.""" - return self.duration is not None and 'voice-message' in self.url - - def __repr__(self) -> str: - return f'' - - def __str__(self) -> str: - return self.url or '' - - async def save( - self, - fp: Union[io.BufferedIOBase, PathLike[Any]], - *, - seek_begin: bool = True, - use_cached: bool = False, - ) -> int: - """|coro| - - Saves this attachment into a file-like object. - - Parameters - ----------- - fp: Union[:class:`io.BufferedIOBase`, :class:`os.PathLike`] - The file-like object to save this attachment to or the filename - to use. If a filename is passed then a file is created with that - filename and used instead. - seek_begin: :class:`bool` - Whether to seek to the beginning of the file after saving is - successfully done. - use_cached: :class:`bool` - Whether to use :attr:`proxy_url` rather than :attr:`url` when downloading - the attachment. This will allow attachments to be saved after deletion - more often, compared to the regular URL which is generally deleted right - after the message is deleted. Note that this can still fail to download - deleted attachments if too much time has passed and it does not work - on some types of attachments. - - Raises - -------- - HTTPException - Saving the attachment failed. - NotFound - The attachment was deleted. - - Returns - -------- - :class:`int` - The number of bytes written. - """ - data = await self.read(use_cached=use_cached) - if isinstance(fp, io.BufferedIOBase): - written = fp.write(data) - if seek_begin: - fp.seek(0) - return written - else: - with open(fp, 'wb') as f: - return f.write(data) - - async def read(self, *, use_cached: bool = False) -> bytes: - """|coro| - - Retrieves the content of this attachment as a :class:`bytes` object. - - .. versionadded:: 1.1 - - Parameters - ----------- - use_cached: :class:`bool` - Whether to use :attr:`proxy_url` rather than :attr:`url` when downloading - the attachment. This will allow attachments to be saved after deletion - more often, compared to the regular URL which is generally deleted right - after the message is deleted. Note that this can still fail to download - deleted attachments if too much time has passed and it does not work - on some types of attachments. - - Raises - ------ - HTTPException - Downloading the attachment failed. - Forbidden - You do not have permissions to access this attachment - NotFound - The attachment was deleted. - - Returns - ------- - :class:`bytes` - The contents of the attachment. - """ - url = self.proxy_url if use_cached else self.url - data = await self._http.get_from_cdn(url) - return data - - async def to_file( - self, - *, - filename: Optional[str] = MISSING, - description: Optional[str] = MISSING, - use_cached: bool = False, - spoiler: bool = False, - ) -> File: - """|coro| - - Converts the attachment into a :class:`File` suitable for sending via - :meth:`abc.Messageable.send`. - - .. versionadded:: 1.3 - - Parameters - ----------- - filename: Optional[:class:`str`] - The filename to use for the file. If not specified then the filename - of the attachment is used instead. - - .. versionadded:: 2.0 - description: Optional[:class:`str`] - The description to use for the file. If not specified then the - description of the attachment is used instead. - - .. versionadded:: 2.0 - use_cached: :class:`bool` - Whether to use :attr:`proxy_url` rather than :attr:`url` when downloading - the attachment. This will allow attachments to be saved after deletion - more often, compared to the regular URL which is generally deleted right - after the message is deleted. Note that this can still fail to download - deleted attachments if too much time has passed and it does not work - on some types of attachments. - - .. versionadded:: 1.4 - spoiler: :class:`bool` - Whether the file is a spoiler. - - .. versionadded:: 1.4 - - Raises - ------ - HTTPException - Downloading the attachment failed. - Forbidden - You do not have permissions to access this attachment - NotFound - The attachment was deleted. - - Returns - ------- - :class:`File` - The attachment as a file suitable for sending. - """ - - data = await self.read(use_cached=use_cached) - file_filename = filename if filename is not MISSING else self.filename - file_description = description if description is not MISSING else self.description - return File(io.BytesIO(data), filename=file_filename, description=file_description, spoiler=spoiler) - - def to_dict(self) -> AttachmentPayload: - result: AttachmentPayload = { - 'filename': self.filename, - 'id': self.id, - 'proxy_url': self.proxy_url, - 'size': self.size, - 'url': self.url, - 'spoiler': self.is_spoiler(), - } - if self.height: - result['height'] = self.height - if self.width: - result['width'] = self.width - if self.content_type: - result['content_type'] = self.content_type - if self.description is not None: - result['description'] = self.description - return result - - class DeletedReferencedMessage: """A special sentinel type given when the resolved message reference points to a deleted message. diff --git a/discord/types/attachment.py b/discord/types/attachment.py new file mode 100644 index 000000000..38d8ad667 --- /dev/null +++ b/discord/types/attachment.py @@ -0,0 +1,58 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from typing import Literal, Optional, TypedDict +from typing_extensions import NotRequired + +from .snowflake import Snowflake + +LoadingState = Literal[0, 1, 2, 3] + +class AttachmentBase(TypedDict): + url: str + proxy_url: str + description: NotRequired[str] + spoiler: NotRequired[bool] + height: NotRequired[Optional[int]] + width: NotRequired[Optional[int]] + content_type: NotRequired[str] + flags: NotRequired[int] + + +class Attachment(AttachmentBase): + id: Snowflake + filename: str + size: int + ephemeral: NotRequired[bool] + duration_secs: NotRequired[float] + waveform: NotRequired[str] + + +class UnfurledAttachment(AttachmentBase): + loading_state: LoadingState + src_is_animated: NotRequired[bool] + placeholder: str + placeholder_version: int diff --git a/discord/types/components.py b/discord/types/components.py index 3b1295c13..4521f2514 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -29,19 +29,27 @@ from typing_extensions import NotRequired from .emoji import PartialEmoji from .channel import ChannelType +from .attachment import UnfurledAttachment ComponentType = Literal[1, 2, 3, 4] ButtonStyle = Literal[1, 2, 3, 4, 5, 6] TextStyle = Literal[1, 2] -DefaultValueType = Literal['user', 'role', 'channel'] +DefaultValueType = Literal["user", "role", "channel"] +DividerSize = Literal[1, 2] +MediaItemLoadingState = Literal[0, 1, 2, 3] -class ActionRow(TypedDict): +class ComponentBase(TypedDict): + id: NotRequired[int] + type: int + + +class ActionRow(ComponentBase): type: Literal[1] components: List[ActionRowChildComponent] -class ButtonComponent(TypedDict): +class ButtonComponent(ComponentBase): type: Literal[2] style: ButtonStyle custom_id: NotRequired[str] @@ -52,7 +60,7 @@ class ButtonComponent(TypedDict): sku_id: NotRequired[str] -class SelectOption(TypedDict): +class SelectOption(ComponentBase): label: str value: str default: bool @@ -60,7 +68,7 @@ class SelectOption(TypedDict): emoji: NotRequired[PartialEmoji] -class SelectComponent(TypedDict): +class SelectComponent(ComponentBase): custom_id: str placeholder: NotRequired[str] min_values: NotRequired[int] @@ -99,7 +107,7 @@ class ChannelSelectComponent(SelectComponent): default_values: NotRequired[List[SelectDefaultValues]] -class TextInput(TypedDict): +class TextInput(ComponentBase): type: Literal[4] custom_id: str style: TextStyle @@ -118,5 +126,52 @@ class SelectMenu(SelectComponent): default_values: NotRequired[List[SelectDefaultValues]] +class SectionComponent(ComponentBase): + type: Literal[9] + components: List[Union[TextComponent, ButtonComponent]] + accessory: NotRequired[ComponentBase] + + +class TextComponent(ComponentBase): + type: Literal[10] + content: str + + +class ThumbnailComponent(ComponentBase, UnfurledAttachment): + type: Literal[11] + + +class MediaGalleryComponent(ComponentBase): + type: Literal[12] + items: List[MediaItem] + + +class FileComponent(ComponentBase): + type: Literal[13] + file: MediaItem + spoiler: NotRequired[bool] + + +class DividerComponent(ComponentBase): + type: Literal[14] + divider: NotRequired[bool] + spacing: NotRequired[DividerSize] + + +class ComponentContainer(ComponentBase): + type: Literal[17] + accent_color: NotRequired[int] + spoiler: NotRequired[bool] + components: List[ContainerComponent] + + ActionRowChildComponent = Union[ButtonComponent, SelectMenu, TextInput] -Component = Union[ActionRow, ActionRowChildComponent] +ContainerComponent = Union[ + ActionRow, + TextComponent, + MediaGalleryComponent, + FileComponent, + SectionComponent, + SectionComponent, +] +Component = Union[ActionRowChildComponent, ContainerComponent] diff --git a/discord/types/message.py b/discord/types/message.py index ae38db46f..81bfdd23b 100644 --- a/discord/types/message.py +++ b/discord/types/message.py @@ -33,11 +33,12 @@ from .user import User from .emoji import PartialEmoji from .embed import Embed from .channel import ChannelType -from .components import Component +from .components import ComponentBase from .interactions import MessageInteraction, MessageInteractionMetadata from .sticker import StickerItem from .threads import Thread from .poll import Poll +from .attachment import Attachment class PartialMessage(TypedDict): @@ -69,23 +70,6 @@ class Reaction(TypedDict): burst_colors: List[str] -class Attachment(TypedDict): - id: Snowflake - filename: str - size: int - url: str - proxy_url: str - height: NotRequired[Optional[int]] - width: NotRequired[Optional[int]] - description: NotRequired[str] - content_type: NotRequired[str] - spoiler: NotRequired[bool] - ephemeral: NotRequired[bool] - duration_secs: NotRequired[float] - waveform: NotRequired[str] - flags: NotRequired[int] - - MessageActivityType = Literal[1, 2, 3, 5] @@ -189,7 +173,7 @@ class MessageSnapshot(TypedDict): mentions: List[UserWithMember] mention_roles: SnowflakeList sticker_items: NotRequired[List[StickerItem]] - components: NotRequired[List[Component]] + components: NotRequired[List[ComponentBase]] class Message(PartialMessage): @@ -221,7 +205,7 @@ class Message(PartialMessage): referenced_message: NotRequired[Optional[Message]] interaction: NotRequired[MessageInteraction] # deprecated, use interaction_metadata interaction_metadata: NotRequired[MessageInteractionMetadata] - components: NotRequired[List[Component]] + components: NotRequired[List[ComponentBase]] position: NotRequired[int] role_subscription_data: NotRequired[RoleSubscriptionData] thread: NotRequired[Thread] @@ -229,7 +213,7 @@ class Message(PartialMessage): purchase_notification: NotRequired[PurchaseNotificationResponse] -AllowedMentionType = Literal['roles', 'users', 'everyone'] +AllowedMentionType = Literal["roles", "users", "everyone"] class AllowedMentions(TypedDict): diff --git a/discord/ui/section.py b/discord/ui/section.py new file mode 100644 index 000000000..0f6f76006 --- /dev/null +++ b/discord/ui/section.py @@ -0,0 +1,50 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" +from __future__ import annotations + +from typing import List, Optional + +from .item import Item + + +class Section(Item): + """Represents a UI section. + + .. versionadded:: tbd + + Parameters + ---------- + accessory: Optional[:class:`Item`] + The accessory to show within this section, displayed on the top right of this section. + """ + + __slots__ = ( + 'accessory', + '_children', + ) + + def __init__(self, *, accessory: Optional[Item]) -> None: + self.accessory: Optional[Item] = accessory + self._children: List[Item] = [] + self._underlying = SectionComponent