diff --git a/discord/abc.py b/discord/abc.py index 95ccfd67b..9527ff48d 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -87,7 +87,7 @@ if TYPE_CHECKING: from .member import Member from .channel import CategoryChannel from .embeds import Embed - from .message import Message, MessageReference, PartialMessage + from .message import Message, MessageReference, PartialMessage, SharedClientTheme from .channel import ( TextChannel, DMChannel, @@ -1458,6 +1458,7 @@ class Messageable: suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., + shared_client_theme: SharedClientTheme = ..., ) -> Message: ... @overload @@ -1478,6 +1479,7 @@ class Messageable: suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., + shared_client_theme: SharedClientTheme = ..., ) -> Message: ... @overload @@ -1498,6 +1500,7 @@ class Messageable: suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., + shared_client_theme: SharedClientTheme = ..., ) -> Message: ... @overload @@ -1518,6 +1521,7 @@ class Messageable: suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., + shared_client_theme: SharedClientTheme = ..., ) -> Message: ... async def send( @@ -1539,6 +1543,7 @@ class Messageable: suppress_embeds: bool = False, silent: bool = False, poll: Optional[Poll] = None, + shared_client_theme: Optional[SharedClientTheme] = None, ) -> Message: """|coro| @@ -1629,6 +1634,10 @@ class Messageable: The poll to send with this message. .. versionadded:: 2.4 + shared_client_theme: :class:`~discord.SharedClientTheme` + The shared client theme to send with this message. + + .. versionadded:: 2.8 Raises -------- @@ -1702,6 +1711,7 @@ class Messageable: view=view, flags=flags, poll=poll, + shared_client_theme=shared_client_theme, ) as params: data = await state.http.send_message(channel.id, params=params) diff --git a/discord/enums.py b/discord/enums.py index 025b54cb4..2c97aac24 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -87,6 +87,7 @@ __all__ = ( 'MediaItemLoadingState', 'CollectibleType', 'NameplatePalette', + 'BaseTheme', ) @@ -1006,6 +1007,14 @@ class NameplatePalette(Enum): white = 'white' +class BaseTheme(Enum): + unset = 0 + light = 1 + dark = 2 + darker = 3 + midnight = 4 + + 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/ext/commands/context.py b/discord/ext/commands/context.py index 54b3dd973..803786784 100644 --- a/discord/ext/commands/context.py +++ b/discord/ext/commands/context.py @@ -48,7 +48,7 @@ if TYPE_CHECKING: from discord.file import File from discord.mentions import AllowedMentions from discord.sticker import GuildSticker, StickerItem - from discord.message import MessageReference, PartialMessage + from discord.message import MessageReference, PartialMessage, SharedClientTheme from discord.ui.view import BaseView, View, LayoutView from discord.types.interactions import ApplicationCommandInteractionData from discord.poll import Poll @@ -681,6 +681,7 @@ class Context(discord.abc.Messageable, Generic[BotT]): ephemeral: bool = ..., silent: bool = ..., poll: Poll = ..., + shared_client_theme: SharedClientTheme = ..., ) -> Message: ... @overload @@ -702,6 +703,7 @@ class Context(discord.abc.Messageable, Generic[BotT]): ephemeral: bool = ..., silent: bool = ..., poll: Poll = ..., + shared_client_theme: SharedClientTheme = ..., ) -> Message: ... @overload @@ -723,6 +725,7 @@ class Context(discord.abc.Messageable, Generic[BotT]): ephemeral: bool = ..., silent: bool = ..., poll: Poll = ..., + shared_client_theme: SharedClientTheme = ..., ) -> Message: ... @overload @@ -744,6 +747,7 @@ class Context(discord.abc.Messageable, Generic[BotT]): ephemeral: bool = ..., silent: bool = ..., poll: Poll = ..., + shared_client_theme: SharedClientTheme = ..., ) -> Message: ... async def reply(self, content: Optional[str] = None, **kwargs: Any) -> Message: @@ -898,6 +902,7 @@ class Context(discord.abc.Messageable, Generic[BotT]): ephemeral: bool = ..., silent: bool = ..., poll: Poll = ..., + shared_client_theme: SharedClientTheme = ..., ) -> Message: ... @overload @@ -919,6 +924,7 @@ class Context(discord.abc.Messageable, Generic[BotT]): ephemeral: bool = ..., silent: bool = ..., poll: Poll = ..., + shared_client_theme: SharedClientTheme = ..., ) -> Message: ... @overload @@ -940,6 +946,7 @@ class Context(discord.abc.Messageable, Generic[BotT]): ephemeral: bool = ..., silent: bool = ..., poll: Poll = ..., + shared_client_theme: SharedClientTheme = ..., ) -> Message: ... @overload @@ -961,6 +968,7 @@ class Context(discord.abc.Messageable, Generic[BotT]): ephemeral: bool = ..., silent: bool = ..., poll: Poll = ..., + shared_client_theme: SharedClientTheme = ..., ) -> Message: ... async def send( @@ -983,6 +991,7 @@ class Context(discord.abc.Messageable, Generic[BotT]): ephemeral: bool = False, silent: bool = False, poll: Optional[Poll] = None, + shared_client_theme: Optional[SharedClientTheme] = None, ) -> Message: """|coro| @@ -1078,6 +1087,10 @@ class Context(discord.abc.Messageable, Generic[BotT]): .. versionadded:: 2.4 .. versionchanged:: 2.6 This can now be ``None`` and defaults to ``None`` instead of ``MISSING``. + shared_client_theme: Optional[:class:`~discord.SharedClientTheme`] + The shared client theme to send with this message. + + .. versionadded:: 2.8 Raises -------- @@ -1117,6 +1130,7 @@ class Context(discord.abc.Messageable, Generic[BotT]): suppress_embeds=suppress_embeds, silent=silent, poll=poll, + shared_client_theme=shared_client_theme, ) # type: ignore # The overloads don't support Optional but the implementation does # Convert the kwargs from None to MISSING to appease the remaining implementations @@ -1133,6 +1147,7 @@ class Context(discord.abc.Messageable, Generic[BotT]): 'ephemeral': ephemeral, 'silent': silent, 'poll': MISSING if poll is None else poll, + 'shared_client_theme': MISSING if shared_client_theme is None else shared_client_theme, } if self.interaction.response.is_done(): diff --git a/discord/http.py b/discord/http.py index 05c1e69fc..b1edb3d53 100644 --- a/discord/http.py +++ b/discord/http.py @@ -66,7 +66,7 @@ if TYPE_CHECKING: from .ui.view import BaseView from .embeds import Embed - from .message import Attachment + from .message import Attachment, SharedClientTheme from .poll import Poll from .types import ( @@ -161,6 +161,7 @@ def handle_message_parameters( channel_payload: Dict[str, Any] = MISSING, applied_tags: Optional[SnowflakeList] = MISSING, poll: Optional[Poll] = MISSING, + shared_client_theme: Optional[SharedClientTheme] = MISSING, ) -> MultipartParameters: if files is not MISSING and file is not MISSING: raise TypeError('Cannot mix file and files keyword arguments.') @@ -203,6 +204,9 @@ def handle_message_parameters( else: payload['components'] = [] + if shared_client_theme not in (MISSING, None): + payload['shared_client_theme'] = shared_client_theme.to_dict() + if nonce is not None: payload['nonce'] = str(nonce) payload['enforce_nonce'] = True diff --git a/discord/message.py b/discord/message.py index 5192a54d2..a3ffac1ee 100644 --- a/discord/message.py +++ b/discord/message.py @@ -44,13 +44,14 @@ from typing import ( Type, overload, ) +from collections.abc import Iterable from . import utils from .asset import Asset from .reaction import Reaction from .emoji import Emoji from .partial_emoji import PartialEmoji -from .enums import InteractionType, MessageReferenceType, MessageType, ChannelType, try_enum +from .enums import InteractionType, MessageReferenceType, MessageType, ChannelType, BaseTheme, try_enum from .errors import HTTPException from .components import _component_factory from .embeds import Embed @@ -65,6 +66,7 @@ from .sticker import StickerItem, GuildSticker from .threads import Thread from .channel import PartialMessageable from .poll import Poll +from .colour import Colour if TYPE_CHECKING: from typing_extensions import Self @@ -81,6 +83,7 @@ if TYPE_CHECKING: CallMessage as CallMessagePayload, PurchaseNotificationResponse as PurchaseNotificationResponsePayload, GuildProductPurchase as GuildProductPurchasePayload, + SharedClientTheme as SharedClientThemePayload, ) from .types.interactions import MessageInteraction as MessageInteractionPayload @@ -120,6 +123,7 @@ __all__ = ( 'CallMessage', 'GuildProductPurchase', 'PurchaseNotification', + 'SharedClientTheme', ) @@ -950,6 +954,138 @@ class MessageInteractionMetadata(Hashable): return self.user.id == self._integration_owners.get(1) +class SharedClientTheme: + """Represents a shared client theme from a :class:`~discord.Message`. + + This can be constructed by users to create a new shared client theme for sending and + is received using :attr:`Message.shared_client_theme` when a message contains a shared client theme. + + .. versionadded:: 2.8 + + Parameters + ----------- + colours: Iterable[Union[:class:`Colour`, :class:`int`]] + An iterable of the theme's colours. Must be between 1 and 5 colours. + colors: Iterable[Union[:class:`Colour`, :class:`int`]] + An alias for ``colours``. + gradient_angle: :class:`int` + The direction of the theme's gradient in degrees. Must be between 0 and 360. + This is only applicable if there are at least 2 colours. + intensity: :class:`int` + The intensity of the theme's colors. Must be between 0 and 100. + theme: :class:`BaseTheme` + The base theme to use for this client theme. Defaults to :attr:`BaseTheme.dark`. + + Raises + ------- + ValueError + - If the number of colours is not between 1 and 5. + - If ``gradient_angle`` is set but there are less than 2 colours. + - If ``gradient_angle`` is not between 0 and 360. + - If ``intensity`` is not between 0 and 100. + - If ``theme`` is not an instance of :class:`BaseTheme`. + """ + + def __init__( + self, + *, + colours: Iterable[Union[Colour, int]] = MISSING, + colors: Iterable[Union[Colour, int]] = MISSING, + gradient_angle: int = 0, + intensity: int = 0, + theme: BaseTheme = BaseTheme.dark, + ) -> None: + colours = colours if colours is not MISSING else colors + self._colours = [colour if isinstance(colour, Colour) else Colour(colour) for colour in colours] + self.gradient_angle = gradient_angle + self.intensity = intensity + self.theme = theme + + @classmethod + def from_dict(cls, data: SharedClientThemePayload) -> Self: + """Creates a :class:`SharedClientTheme` from a dictionary. + + Possible keys can be found in the + :ddocs:`api docs `. + """ + return cls( + colours=[Colour(int(colour, 16)) for colour in data.get('colors', [])], + gradient_angle=data.get('gradient_angle', 0), + intensity=data.get('base_mix', 0), + theme=try_enum(BaseTheme, data.get('base_theme', 'dark')), + ) + + def to_dict(self) -> SharedClientThemePayload: + return { + 'colors': [str(colour).lstrip('#') for colour in self._colours], + 'gradient_angle': self.gradient_angle, + 'base_mix': self.intensity, + 'base_theme': self.theme.value, + } + + def __repr__(self) -> str: + return f'' + + @property + def colours(self) -> List[Colour]: + """List[:class:`Colour`]: A list of the theme's colours.""" + return self._colours + + colors = colours + + @colours.setter + def colours(self, value: List[Colour]) -> None: + if not value: + raise ValueError('colours cannot be empty') + + if len(value) > 5: + raise ValueError('cannot have more than 5 colours') + + if len(value) < 2: + self.intensity = 0 + + self._colours = value + + @property + def gradient_angle(self) -> int: + """:class:`int`: The direction of the theme's gradient in degrees. + + This is only applicable if there are at least 2 colours. + """ + return self._gradient_angle + + @gradient_angle.setter + def gradient_angle(self, value: int) -> None: + if len(self.colours) < 2 and value != 0: + raise ValueError('gradient_angle may only be set if there are at least 2 colours') + + if not 0 <= value <= 360: + raise ValueError('gradient_angle must be between 0 and 360') + self._gradient_angle = value + + @property + def intensity(self) -> int: + """:class:`int`: The intensity of the theme's colors.""" + return self._intensity + + @intensity.setter + def intensity(self, value: int) -> None: + if not 0 <= value <= 100: + raise ValueError('intensity must be between 0 and 100') + self._intensity = value + + @property + def theme(self) -> BaseTheme: + """:class:`BaseTheme`: The base theme to use for this client theme.""" + return self._theme + + @theme.setter + def theme(self, value: BaseTheme) -> None: + if not isinstance(value, BaseTheme): + raise ValueError('theme must be an instance of BaseTheme') + self._theme = value + + def flatten_handlers(cls: Type[Message]) -> Type[Message]: prefix = len('_handle_') handlers = [ @@ -1781,6 +1917,7 @@ class PartialMessage(Hashable): suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., + shared_client_theme: SharedClientTheme = ..., ) -> Message: ... @overload @@ -1801,6 +1938,7 @@ class PartialMessage(Hashable): suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., + shared_client_theme: SharedClientTheme = ..., ) -> Message: ... @overload @@ -1821,6 +1959,7 @@ class PartialMessage(Hashable): suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., + shared_client_theme: SharedClientTheme = ..., ) -> Message: ... @overload @@ -1841,6 +1980,7 @@ class PartialMessage(Hashable): suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., + shared_client_theme: SharedClientTheme = ..., ) -> Message: ... async def reply(self, content: Optional[str] = None, **kwargs: Any) -> Message: @@ -2138,6 +2278,10 @@ class Message(PartialMessage, Hashable): The message snapshots attached to this message. .. versionadded:: 2.5 + shared_client_theme: Optional[:class:`SharedClientTheme`] + The client theme shared in this message. + + .. versionadded:: 2.8 """ __slots__ = ( @@ -2178,6 +2322,7 @@ class Message(PartialMessage, Hashable): 'purchase_notification', 'message_snapshots', '_pinned_at', + 'shared_client_theme', ) if TYPE_CHECKING: @@ -2323,6 +2468,14 @@ class Message(PartialMessage, Hashable): else: self.purchase_notification = PurchaseNotification(purchase_notification) + self.shared_client_theme: Optional[SharedClientTheme] = None + try: + shared_client_theme = data['shared_client_theme'] # pyright: ignore[reportTypedDictNotRequiredAccess] + except KeyError: + pass + else: + self.shared_client_theme = SharedClientTheme.from_dict(shared_client_theme) + for handler in ('author', 'member', 'mentions', 'mention_roles', 'components', 'call'): try: getattr(self, f'_handle_{handler}')(data[handler]) # type: ignore @@ -2509,6 +2662,9 @@ class Message(PartialMessage, Hashable): def _handle_interaction_metadata(self, data: MessageInteractionMetadataPayload): self.interaction_metadata = MessageInteractionMetadata(state=self._state, guild=self.guild, data=data) + def _handle_shared_client_theme(self, data: SharedClientThemePayload): + self.shared_client_theme = SharedClientTheme.from_dict(data) + def _handle_call(self, data: CallMessagePayload): if data is not None: self.call = CallMessage(state=self._state, message=self, data=data) diff --git a/discord/types/message.py b/discord/types/message.py index c7631ffc3..a6c75fb3b 100644 --- a/discord/types/message.py +++ b/discord/types/message.py @@ -228,6 +228,7 @@ class Message(PartialMessage): thread: NotRequired[Thread] call: NotRequired[CallMessage] purchase_notification: NotRequired[PurchaseNotificationResponse] + shared_client_theme: NotRequired[SharedClientTheme] AllowedMentionType = Literal['roles', 'users', 'everyone'] @@ -248,3 +249,13 @@ class MessagePin(TypedDict): class ChannelPins(TypedDict): items: List[MessagePin] has_more: bool + + +BaseTheme = Literal[0, 1, 2, 3, 4] + + +class SharedClientTheme(TypedDict): + colors: List[str] + gradient_angle: int + base_mix: int + base_theme: BaseTheme diff --git a/docs/api.rst b/docs/api.rst index 5ed9ffb39..1d1e96b1d 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -4154,6 +4154,35 @@ of :class:`enum.Enum`. The collectible nameplate palette is white. +.. class:: BaseTheme + + Represents the available themes for a shared client theme. + + .. versionadded:: 2.8 + + .. attribute:: unset + + The theme is unset. + + This is equivalent to :attr:`dark`. + + .. attribute:: dark + + The shared theme is based on the dark theme. + + .. attribute:: light + + The shared theme is based on the light theme. + + .. attribute:: darker + + The shared theme is based on the darker theme. + + .. attribute:: midnight + + The shared theme is based on the midnight theme. + + .. _discord-api-audit-logs: Audit Log Data @@ -6181,6 +6210,14 @@ PollMedia :members: +SharedClientTheme +~~~~~~~~~~~~~~~~~~ + +.. attributetable:: SharedClientTheme + +.. autoclass:: SharedClientTheme + :members: + Exceptions ------------