diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d5c51c220..5d7aec805c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ released. ### Added +- Added support for sending and receiving client themes. + ([#3209](https://github.com/Pycord-Development/pycord/pull/3209)) + ### Changed ### Fixed diff --git a/discord/__init__.py b/discord/__init__.py index 120069b750..76a80a18a7 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -68,6 +68,7 @@ from .role import * from .scheduled_events import * from .shard import * +from .shared_client_theme import * from .soundboard import * from .stage_instance import * from .sticker import * diff --git a/discord/abc.py b/discord/abc.py index 4b6ca924aa..5d13c6d4db 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -56,6 +56,7 @@ from .permissions import PermissionOverwrite, Permissions from .role import Role from .scheduled_events import ScheduledEvent +from .shared_client_theme import SharedClientThemeBaseType from .sticker import GuildSticker, StickerItem from .utils import warn_deprecated @@ -89,6 +90,7 @@ from .member import Member from .message import Message, MessageReference, PartialMessage from .poll import Poll + from .shared_client_theme import SharedClientTheme from .state import ConnectionState from .threads import Thread from .types.channel import Channel as ChannelPayload @@ -1388,6 +1390,7 @@ async def send( suppress: bool = ..., suppress_embeds: bool = ..., silent: bool = ..., + shared_client_theme: SharedClientTheme = ..., ) -> Message: ... @overload @@ -1410,6 +1413,7 @@ async def send( suppress: bool = ..., suppress_embeds: bool = ..., silent: bool = ..., + shared_client_theme: SharedClientTheme = ..., ) -> Message: ... @overload @@ -1432,6 +1436,7 @@ async def send( suppress: bool = ..., suppress_embeds: bool = ..., silent: bool = ..., + shared_client_theme: SharedClientTheme = ..., ) -> Message: ... @overload @@ -1454,6 +1459,7 @@ async def send( suppress: bool = ..., suppress_embeds: bool = ..., silent: bool = ..., + shared_client_theme: SharedClientTheme = ..., ) -> Message: ... async def send( @@ -1477,6 +1483,7 @@ async def send( suppress=None, suppress_embeds=None, silent=None, + shared_client_theme=None, ): """|coro| @@ -1568,6 +1575,10 @@ async def send( The poll to send. .. versionadded:: 2.6 + shared_client_theme: :class:`SharedClientTheme` + The shared client theme to send. + + .. versionadded:: 2.9 Returns ------- @@ -1592,6 +1603,9 @@ async def send( state = self._state content = str(content) if content is not None else None + if shared_client_theme is not None: + shared_client_theme = shared_client_theme.to_dict() + if embed is not None and embeds is not None: raise InvalidArgument( "cannot pass both embed and embeds parameter to send()" @@ -1706,6 +1720,7 @@ async def send( components=components, flags=flags.value, poll=poll, + shared_client_theme=shared_client_theme, ) finally: for f in files: @@ -1725,6 +1740,7 @@ async def send( components=components, flags=flags.value, poll=poll, + shared_client_theme=shared_client_theme, ) ret = state.create_message(channel=channel, data=data) diff --git a/discord/enums.py b/discord/enums.py index 802bb41535..4cc00da6e3 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -88,6 +88,7 @@ "SelectDefaultValueType", "ApplicationEventWebhookStatus", "InviteTargetUsersJobStatusCode", + "SharedClientThemeBaseType", ) @@ -1212,6 +1213,14 @@ class InviteTargetUsersJobStatusCode(Enum): failed = 3 +class SharedClientThemeBaseType(Enum): + unset = 0 + dark = 1 + light = 2 + darker = 3 + midnight = 4 + + T = TypeVar("T") diff --git a/discord/http.py b/discord/http.py index fc6ffb22c3..05aa10bf35 100644 --- a/discord/http.py +++ b/discord/http.py @@ -84,6 +84,7 @@ poll, role, scheduled_events, + shared_client_theme, sticker, template, threads, @@ -507,6 +508,7 @@ def send_message( components: list[components.Component] | None = None, flags: int | None = None, poll: poll.Poll | None = None, + shared_client_theme: shared_client_theme.SharedClientTheme | None = None, ) -> Response[message.Message]: r = Route("POST", "/channels/{channel_id}/messages", channel_id=channel_id) payload = {} @@ -547,6 +549,9 @@ def send_message( if poll: payload["poll"] = poll + if shared_client_theme: + payload["shared_client_theme"] = shared_client_theme + return self.request(r, json=payload) def send_typing(self, channel_id: Snowflake) -> Response[None]: @@ -571,6 +576,7 @@ def send_multipart_helper( components: list[components.Component] | None = None, flags: int | None = None, poll: poll.Poll | None = None, + shared_client_theme: shared_client_theme.SharedClientTheme | None = None, ) -> Response[message.Message]: form = [] @@ -598,6 +604,9 @@ def send_multipart_helper( if poll: payload["poll"] = poll + if shared_client_theme: + payload["shared_client_theme"] = shared_client_theme + attachments = [] form.append({"name": "payload_json"}) for index, file in enumerate(files): @@ -641,6 +650,7 @@ def send_files( components: list[components.Component] | None = None, flags: int | None = None, poll: poll.Poll | None = None, + shared_client_theme: shared_client_theme.SharedClientTheme | None = None, ) -> Response[message.Message]: r = Route("POST", "/channels/{channel_id}/messages", channel_id=channel_id) return self.send_multipart_helper( @@ -658,6 +668,7 @@ def send_files( components=components, flags=flags, poll=poll, + shared_client_theme=shared_client_theme, ) def edit_multipart_helper( diff --git a/discord/message.py b/discord/message.py index 1781bf45f1..6986e98a88 100644 --- a/discord/message.py +++ b/discord/message.py @@ -58,6 +58,7 @@ from .partial_emoji import PartialEmoji from .poll import Poll from .reaction import Reaction +from .shared_client_theme import SharedClientTheme from .sticker import StickerItem from .threads import Thread from .utils import MISSING, escape_mentions, find, warn_deprecated @@ -90,6 +91,7 @@ from .types.message import MessageSnapshot as MessageSnapshotPayload from .types.message import Reaction as ReactionPayload from .types.poll import Poll as PollPayload + from .types.shared_client_theme import SharedClientTheme as SharedClientThemePayload from .types.snowflake import SnowflakeList from .types.threads import ThreadArchiveDuration from .types.user import User as UserPayload @@ -1039,6 +1041,10 @@ class Message(Hashable): The poll associated with this message, if applicable. .. versionadded:: 2.6 + shared_client_theme: Optional[:class:`SharedClientTheme`] + The shared client theme transmitted via this message, if applicable. + + .. versionadded:: 2.9 call: Optional[:class:`MessageCall`] The call information associated with this message, if applicable. @@ -1087,6 +1093,7 @@ class Message(Hashable): "_poll", "call", "snapshots", + "shared_client_theme", ) if TYPE_CHECKING: @@ -1217,6 +1224,13 @@ def __init__( except KeyError: self.call = None + if shared_client_theme := data.get("shared_client_theme"): + self.shared_client_theme: SharedClientTheme | None = ( + SharedClientTheme.from_dict(shared_client_theme) + ) + else: + self.shared_client_theme = None + for handler in ("author", "member", "mentions", "mention_roles"): try: getattr(self, f"_handle_{handler}")(data[handler]) @@ -1328,6 +1342,9 @@ def _handle_poll(self, value: PollPayload) -> None: self._poll = Poll.from_dict(value, self) self._state.store_poll(self._poll, self.id) + def _handle_shared_client_theme(self, value: SharedClientThemePayload) -> None: + self.shared_client_theme = SharedClientTheme.from_dict(value) + def _handle_author(self, author: UserPayload) -> None: self.author = self._state.store_user(author) if isinstance(self.guild, Guild): diff --git a/discord/shared_client_theme.py b/discord/shared_client_theme.py new file mode 100644 index 0000000000..63f4f90328 --- /dev/null +++ b/discord/shared_client_theme.py @@ -0,0 +1,131 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +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 dataclasses import dataclass +from typing import TYPE_CHECKING, Sequence + +from typing_extensions import Self + +from .colour import Colour +from .enums import SharedClientThemeBaseType, try_enum +from .utils import MISSING + +__all__ = ("SharedClientTheme",) + + +if TYPE_CHECKING: + from .types.shared_client_theme import SharedClientTheme as SharedClientThemePayload + + +@dataclass(init=False) +class SharedClientTheme: + """Represents a shared client theme that can be sent in a message. + + A shared client theme lets users transmit a customized Discord client + appearance (colors, gradient, and base mode) through a message. + + .. versionadded:: 2.9 + + Attributes + ---------- + colours: List[:class:`Colour`] + The colours of the theme. A maximum of 5 colours. + colors: List[:class:`Colour`] + Alias for :attr:`colours`. + gradient_angle: :class:`int` + The direction of the theme's colors, in degrees. Must be between ``0`` and ``360``. + base_mix: :class:`int` + The intensity of the theme's colors. Must be between ``0`` and ``100``. + base_theme: Optional[:class:`SharedClientThemeBaseType`] + The mode of the theme. Defaults to :attr:`SharedClientThemeBaseType.unset`, + which Discord treats as :attr:`SharedClientThemeBaseType.dark`. + """ + + gradient_angle: int = 0 + base_mix: int = 0 + colours: list[Colour] + base_theme: SharedClientThemeBaseType | None = SharedClientThemeBaseType.unset + + def __init__( + self, + gradient_angle: int = 0, + base_mix: int = 0, + colors: Sequence[Colour] = MISSING, + colours: Sequence[Colour] = MISSING, + *, + base_theme: SharedClientThemeBaseType = SharedClientThemeBaseType.unset, + ) -> None: + colours = colours if colours is not MISSING else colors + + if len(colours or []) > 5: + raise ValueError("colors or colours must contain at most 5 colours") + if colours is MISSING: + raise TypeError("colors or colours must be provided") + + if not 0 <= gradient_angle <= 360: + raise ValueError("gradient_angle must be between 0 and 360") + + if not 0 <= base_mix <= 100: + raise ValueError("base_mix must be between 0 and 100") + + if base_theme is not None and not isinstance( + base_theme, SharedClientThemeBaseType + ): + raise TypeError("base_theme must be a SharedClientThemeBaseType or None") + + self.gradient_angle = gradient_angle + self.base_mix = base_mix + self.colours = list(colours) + self.base_theme = base_theme + + @property + def colors(self) -> list[Colour]: + return self.colours + + def to_dict(self) -> SharedClientThemePayload: + payload: SharedClientThemePayload = { + "colors": [f"{c.value:0>6x}" for c in self.colours], + "gradient_angle": self.gradient_angle, + "base_mix": self.base_mix, + } + if self.base_theme is not None: + payload["base_theme"] = self.base_theme.value + return payload + + @classmethod + def from_dict(cls, data: SharedClientThemePayload) -> Self: + base_theme_value = data.get("base_theme") + colours = [Colour(int(c, 16)) for c in data.get("colors", [])] + return cls( + colours=colours, + gradient_angle=data["gradient_angle"], + base_mix=data["base_mix"], + base_theme=( + try_enum(SharedClientThemeBaseType, base_theme_value) + if base_theme_value is not None + else None + ), + ) diff --git a/discord/types/message.py b/discord/types/message.py index c6a48881c7..48f35e881a 100644 --- a/discord/types/message.py +++ b/discord/types/message.py @@ -33,6 +33,7 @@ from .emoji import PartialEmoji from .member import Member, UserWithMember from .poll import Poll +from .shared_client_theme import SharedClientTheme from .snowflake import Snowflake, SnowflakeList from .sticker import StickerItem from .threads import Thread @@ -174,6 +175,7 @@ class Message(TypedDict): poll: Poll call: MessageCall message_snapshots: NotRequired[list[MessageSnapshot]] + shared_client_theme: NotRequired[SharedClientTheme] class MessagePin(TypedDict): diff --git a/discord/types/shared_client_theme.py b/discord/types/shared_client_theme.py new file mode 100644 index 0000000000..eebbcd0864 --- /dev/null +++ b/discord/types/shared_client_theme.py @@ -0,0 +1,36 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +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 typing import Literal, TypedDict + +from typing_extensions import NotRequired + +SharedClientThemeBaseType = Literal[0, 1, 2, 3, 4] + + +class SharedClientTheme(TypedDict): + colors: list[str] + gradient_angle: int + base_mix: int + base_theme: NotRequired[SharedClientThemeBaseType] diff --git a/docs/api/data_classes.rst b/docs/api/data_classes.rst index a28cb2a726..7fbb16b0f1 100644 --- a/docs/api/data_classes.rst +++ b/docs/api/data_classes.rst @@ -89,6 +89,11 @@ Message .. autoclass:: File :members: +.. attributetable:: SharedClientTheme + +.. autoclass:: SharedClientTheme + :members: + Embed ~~~~~ diff --git a/docs/api/enums.rst b/docs/api/enums.rst index efa16a4a5e..6a526594b2 100644 --- a/docs/api/enums.rst +++ b/docs/api/enums.rst @@ -2529,6 +2529,33 @@ of :class:`enum.Enum`. Represents the default layout. +.. class:: SharedClientThemeBaseType + + The base theme mode of a :class:`SharedClientTheme`. + + .. versionadded:: 2.9 + + .. attribute:: unset + + No explicit base theme. Treated as :attr:`dark` by Discord. + + .. attribute:: dark + + The dark base theme. + + .. attribute:: light + + The light base theme. + + .. attribute:: darker + + The darker base theme. + + .. attribute:: midnight + + The midnight base theme. + + .. class:: IntegrationType The integration type for an application.