From ba6e15ed611022cb767dab92bf164be90e9c08ba Mon Sep 17 00:00:00 2001 From: dolfies Date: Fri, 24 Mar 2023 20:46:24 -0400 Subject: [PATCH] Implement protobuf settings (#476) * Initial implementation * Internal and documentation changes * Proto editing and update events * Edit overloads and bugfixes * Fix missing defaults in two overloads * More fixers * Black pass * docs! (almost) * Fix incorrect settings accessing * Support setting settings versions * Fix docs * Update timezone_offset documentation --- discord/__init__.py | 1 - discord/activity.py | 84 +- discord/client.py | 197 ++++- discord/enums.py | 100 ++- discord/flags.py | 347 ++++++++ discord/gateway.py | 13 +- discord/guild_folder.py | 113 --- discord/http.py | 14 + discord/settings.py | 1766 +++++++++++++++++++++++++++++++++++--- discord/state.py | 32 +- discord/types/gateway.py | 12 +- discord/types/user.py | 7 + discord/user.py | 91 +- discord/utils.py | 6 + docs/api.rst | 168 +++- docs/conf.py | 3 + docs/migrating.rst | 1 - requirements.txt | 1 + 18 files changed, 2507 insertions(+), 449 deletions(-) delete mode 100644 discord/guild_folder.py diff --git a/discord/__init__.py b/discord/__init__.py index fa6bc1195..c484655c3 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -40,7 +40,6 @@ from .errors import * from .file import * from .flags import * from .guild import * -from .guild_folder import * from .guild_premium import * from .handlers import * from .integrations import * diff --git a/discord/activity.py b/discord/activity.py index 52042d1b6..16e0c683e 100644 --- a/discord/activity.py +++ b/discord/activity.py @@ -31,7 +31,7 @@ from .asset import Asset from .colour import Colour from .enums import ActivityType, ClientType, OperatingSystem, Status, try_enum from .partial_emoji import PartialEmoji -from .utils import _get_as_snowflake +from .utils import _get_as_snowflake, parse_time, parse_timestamp __all__ = ( 'BaseActivity', @@ -796,6 +796,49 @@ class CustomActivity(BaseActivity): else: raise TypeError(f'Expected str, PartialEmoji, or None, received {type(emoji)!r} instead.') + @classmethod + def _from_legacy_settings(cls, *, data: Optional[dict], state: ConnectionState) -> Optional[Self]: + if not data: + return + + emoji = None + if data.get('emoji_id'): + emoji = state.get_emoji(int(data['emoji_id'])) + if not emoji: + emoji = PartialEmoji(id=int(data['emoji_id']), name=data['emoji_name']) + emoji._state = state + else: + emoji = emoji._to_partial() + elif data.get('emoji_name'): + emoji = PartialEmoji(name=data['emoji_name']) + emoji._state = state + + return cls(name=data.get('text'), emoji=emoji, expires_at=parse_time(data.get('expires_at'))) + + @classmethod + def _from_settings(cls, *, data: Any, state: ConnectionState) -> Self: + """ + message CustomStatus { + string text = 1; + fixed64 emoji_id = 2; + string emoji_name = 3; + fixed64 expires_at_ms = 4; + } + """ + emoji = None + if data.emoji_id: + emoji = state.get_emoji(data.emoji_id) + if not emoji: + emoji = PartialEmoji(id=data.emoji_id, name=data.emoji_name) + emoji._state = state + else: + emoji = emoji._to_partial() + elif data.emoji_name: + emoji = PartialEmoji(name=data.emoji_name) + emoji._state = state + + return cls(name=data.text, emoji=emoji, expires_at=parse_timestamp(data.expires_at_ms)) + @property def type(self) -> ActivityType: """:class:`ActivityType`: Returns the activity's type. This is for compatibility with :class:`Activity`. @@ -814,17 +857,32 @@ class CustomActivity(BaseActivity): o['emoji'] = self.emoji.to_dict() return o # type: ignore + def to_legacy_settings_dict(self) -> Dict[str, Any]: + o: Dict[str, Optional[Union[str, int]]] = {} + + if self.name: + o['text'] = self.name + if self.emoji: + emoji = self.emoji + o['emoji_name'] = emoji.name + if emoji.id: + o['emoji_id'] = emoji.id + if self.expires_at is not None: + o['expires_at'] = self.expires_at.isoformat() + return o + def to_settings_dict(self) -> Dict[str, Any]: o: Dict[str, Optional[Union[str, int]]] = {} - if text := self.name: - o['text'] = text - if emoji := self.emoji: + if self.name: + o['text'] = self.name + if self.emoji: + emoji = self.emoji o['emoji_name'] = emoji.name if emoji.id: o['emoji_id'] = emoji.id - if (expiry := self.expires_at) is not None: - o['expires_at'] = expiry.isoformat() + if self.expires_at is not None: + o['expires_at_ms'] = int(self.expires_at.timestamp() * 1000) return o def __eq__(self, other: object) -> bool: @@ -1007,17 +1065,3 @@ def create_activity(data: Optional[ActivityPayload], state: ConnectionState) -> if isinstance(ret.emoji, PartialEmoji): ret.emoji._state = state return ret - - -def create_settings_activity(*, data, state): - if not data: - return - - emoji = None - if (emoji_id := _get_as_snowflake(data, 'emoji_id')) is not None: - emoji = state.get_emoji(emoji_id) - emoji = emoji and emoji._to_partial() - elif (emoji_name := data.get('emoji_name')) is not None: - emoji = PartialEmoji(name=emoji_name) - - return CustomActivity(name=data.get('text'), emoji=emoji, expires_at=data.get('expires_at')) diff --git a/discord/client.py b/discord/client.py index 452a62dd6..55d6818a0 100644 --- a/discord/client.py +++ b/discord/client.py @@ -88,6 +88,7 @@ from .store import SKU, StoreListing, SubscriptionPlan from .guild_premium import * from .library import LibraryApplication from .relationship import Relationship +from .settings import UserSettings, LegacyUserSettings, TrackingSettings, EmailSettings if TYPE_CHECKING: from typing_extensions import Self @@ -290,7 +291,7 @@ class Client: status = self.initial_status if status or activities: if status is None: - status = getattr(state.settings, 'status', None) or Status.online + status = getattr(state.settings, 'status', None) or Status.unknown self.loop.create_task(self.change_presence(activities=activities, status=status)) @property @@ -415,6 +416,22 @@ class Client: """ return self._connection._relationships.get(user_id) + @property + def settings(self) -> Optional[UserSettings]: + """Optional[:class:`.UserSettings`]: Returns the user's settings. + + .. versionadded:: 2.0 + """ + return self._connection.settings + + @property + def tracking_settings(self) -> Optional[TrackingSettings]: + """Optional[:class:`.TrackingSettings`]: Returns your tracking consents, if available. + + .. versionadded:: 2.0 + """ + return self._connection.consents + @property def voice_clients(self) -> List[VoiceProtocol]: """List[:class:`.VoiceProtocol`]: Represents a list of voice connections. @@ -535,21 +552,21 @@ class Client: print(f'Ignoring exception in {event_method}', file=sys.stderr) traceback.print_exc() - async def on_internal_settings_update(self, old_settings, new_settings): + async def on_internal_settings_update(self, old_settings: UserSettings, new_settings: UserSettings): if not self._sync_presences: return if ( old_settings is not None - and old_settings._status == new_settings._status - and old_settings._custom_status == new_settings._custom_status + and old_settings.status == new_settings.status + and old_settings.custom_activity == new_settings.custom_activity ): return # Nothing changed status = new_settings.status activities = [a for a in self.activities if a.type != ActivityType.custom] - if (activity := new_settings.custom_activity) is not None: - activities.append(activity) + if new_settings.custom_activity is not None: + activities.append(new_settings.custom_activity) await self.change_presence(status=status, activities=activities, edit_settings=False) @@ -1416,12 +1433,12 @@ class Client: custom_activity = activity payload: Dict[str, Any] = {} - if status != getattr(self.user.settings, 'status', None): # type: ignore # user is always present when logged in + if status != getattr(self.settings, 'status', None): payload['status'] = status - if custom_activity != getattr(self.user.settings, 'custom_activity', None): # type: ignore # user is always present when logged in + if custom_activity != getattr(self.settings, 'custom_activity', None): payload['custom_activity'] = custom_activity if payload: - await self.user.edit_settings(**payload) # type: ignore # user is always present when logged in + await self.edit_legacy_settings(**payload) async def change_voice_state( self, @@ -2432,6 +2449,168 @@ class Client: channels = await state.http.get_private_channels() return [_private_channel_factory(data['type'])[0](me=self.user, data=data, state=state) for data in channels] # type: ignore # user is always present when logged in + async def fetch_settings(self) -> UserSettings: + """|coro| + + Retrieves your user settings. + + .. versionadded:: 2.0 + + .. note:: + + This method is an API call. For general usage, consider :attr:`settings` instead. + + Raises + ------- + HTTPException + Retrieving your settings failed. + + Returns + -------- + :class:`.UserSettings` + The current settings for your account. + """ + state = self._connection + data = await state.http.get_proto_settings(1) + return UserSettings(state, data['settings']) + + @utils.deprecated('Client.fetch_settings') + async def legacy_settings(self) -> LegacyUserSettings: + """|coro| + + Retrieves your legacy user settings. + + .. versionadded:: 2.0 + + .. deprecated:: 2.0 + + .. note:: + + This method is no longer the recommended way to fetch your settings. Use :meth:`fetch_settings` instead. + + .. note:: + + This method is an API call. For general usage, consider :attr:`settings` instead. + + Raises + ------- + HTTPException + Retrieving your settings failed. + + Returns + -------- + :class:`.LegacyUserSettings` + The current settings for your account. + """ + state = self._connection + data = await state.http.get_settings() + return LegacyUserSettings(data=data, state=state) + + async def email_settings(self) -> EmailSettings: + """|coro| + + Retrieves your email settings. + + .. versionadded:: 2.0 + + Raises + ------- + HTTPException + Getting the email settings failed. + + Returns + ------- + :class:`.EmailSettings` + The email settings. + """ + state = self._connection + data = await state.http.get_email_settings() + return EmailSettings(data=data, state=state) + + async def fetch_tracking_settings(self) -> TrackingSettings: + """|coro| + + Retrieves your Discord tracking consents. + + .. versionadded:: 2.0 + + Raises + ------ + HTTPException + Retrieving the tracking settings failed. + + Returns + ------- + :class:`.TrackingSettings` + The tracking settings. + """ + state = self._connection + data = await state.http.get_tracking() + return TrackingSettings(state=state, data=data) + + @utils.deprecated('Client.edit_settings') + @utils.copy_doc(LegacyUserSettings.edit) + async def edit_legacy_settings(self, **kwargs) -> LegacyUserSettings: + payload = {} + + content_filter = kwargs.pop('explicit_content_filter', None) + if content_filter: + payload['explicit_content_filter'] = content_filter.value + + animate_stickers = kwargs.pop('animate_stickers', None) + if animate_stickers: + payload['animate_stickers'] = animate_stickers.value + + friend_source_flags = kwargs.pop('friend_source_flags', None) + if friend_source_flags: + payload['friend_source_flags'] = friend_source_flags.to_dict() + + friend_discovery_flags = kwargs.pop('friend_discovery_flags', None) + if friend_discovery_flags: + payload['friend_discovery_flags'] = friend_discovery_flags.value + + guild_positions = kwargs.pop('guild_positions', None) + if guild_positions: + guild_positions = [str(x.id) for x in guild_positions] + payload['guild_positions'] = guild_positions + + restricted_guilds = kwargs.pop('restricted_guilds', None) + if restricted_guilds: + restricted_guilds = [str(x.id) for x in restricted_guilds] + payload['restricted_guilds'] = restricted_guilds + + activity_restricted_guilds = kwargs.pop('activity_restricted_guilds', None) + if activity_restricted_guilds: + activity_restricted_guilds = [str(x.id) for x in activity_restricted_guilds] + payload['activity_restricted_guild_ids'] = activity_restricted_guilds + + activity_joining_restricted_guilds = kwargs.pop('activity_joining_restricted_guilds', None) + if activity_joining_restricted_guilds: + activity_joining_restricted_guilds = [str(x.id) for x in activity_joining_restricted_guilds] + payload['activity_joining_restricted_guild_ids'] = activity_joining_restricted_guilds + + status = kwargs.pop('status', None) + if status: + payload['status'] = status.value + + custom_activity = kwargs.pop('custom_activity', MISSING) + if custom_activity is not MISSING: + payload['custom_status'] = custom_activity and custom_activity.to_legacy_settings_dict() + + theme = kwargs.pop('theme', None) + if theme: + payload['theme'] = theme.value + + locale = kwargs.pop('locale', None) + if locale: + payload['locale'] = str(locale) + + payload.update(kwargs) + + state = self._connection + data = await state.http.edit_settings(**payload) + return LegacyUserSettings(data=data, state=state) + async def fetch_relationships(self) -> List[Relationship]: """|coro| diff --git a/discord/enums.py b/discord/enums.py index a4c64a0e6..b6511ead8 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -70,11 +70,12 @@ __all__ = ( 'HypeSquadHouse', 'PremiumType', 'UserContentFilter', - 'FriendFlags', 'Theme', 'StickerAnimationOptions', - 'RelationshipAction', - 'UnavailableGuildType', + 'SpoilerRenderOptions', + 'InboxTab', + 'EmojiPickerSection', + 'StickerPickerSection', 'RequiredActionType', 'ReportType', 'ApplicationVerificationState', @@ -318,54 +319,78 @@ class UserContentFilter(Enum): non_friends = 1 all_messages = 2 + def __int__(self) -> int: + return self.value + class StickerAnimationOptions(Enum): always = 0 on_interaction = 1 never = 2 + def __int__(self) -> int: + return self.value + -class FriendFlags(Enum): - noone = 0 - mutual_guilds = 1 - mutual_friends = 2 - guild_and_friends = 3 - everyone = 4 - - def to_dict(self): - if self.value == 0: - return {'all': False, 'mutual_friends': False, 'mutual_guilds': False} - if self.value == 1: - return {'all': False, 'mutual_friends': False, 'mutual_guilds': True} - if self.value == 2: - return {'all': False, 'mutual_friends': True, 'mutual_guilds': False} - if self.value == 3: - return {'all': False, 'mutual_friends': True, 'mutual_guilds': True} - if self.value == 4: - return {'all': True, 'mutual_friends': True, 'mutual_guilds': True} +class SpoilerRenderOptions(Enum): + always = 'ALWAYS' + on_click = 'ON_CLICK' + if_moderator = 'IF_MODERATOR' + + def __str__(self) -> str: + return self.value + + +class InboxTab(Enum): + default = 0 + mentions = 1 + unreads = 2 + todos = 3 + for_you = 4 + + def __int__(self) -> int: + return self.value - @classmethod - def _from_dict(cls, data): - all = data.get('all') - mutual_guilds = data.get('mutual_guilds') - mutual_friends = data.get('mutual_friends') - - if all: - return cls.everyone - elif mutual_guilds and mutual_friends: - return cls.guild_and_friends - elif mutual_guilds: - return cls.mutual_guilds - elif mutual_friends: - return cls.mutual_friends - else: - return cls.noone + +class EmojiPickerSection(Enum): + favorite = 'FAVORITES' + top_emojis = 'TOP_GUILD_EMOJI' + recent = 'RECENT' + people = 'people' + nature = 'nature' + food = 'food' + activity = 'activity' + travel = 'travel' + objects = 'objects' + symbols = 'symbols' + flags = 'flags' + + def __str__(self) -> str: + return self.value + + +class StickerPickerSection(Enum): + favorite = 'FAVORITE' + recent = 'RECENT' + + def __str__(self) -> str: + return self.value class Theme(Enum): light = 'light' dark = 'dark' + @classmethod + def from_int(cls, value: int) -> Theme: + return cls.light if value == 2 else cls.dark + + def to_int(self) -> int: + return 2 if self is Theme.light else 1 + + def __int__(self) -> int: + return self.to_int() + class Status(Enum): online = 'online' @@ -374,6 +399,7 @@ class Status(Enum): dnd = 'dnd' do_not_disturb = 'dnd' invisible = 'invisible' + unknown = 'unknown' def __str__(self) -> str: return self.value diff --git a/discord/flags.py b/discord/flags.py index 852eeebd0..50a99ae49 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -33,6 +33,7 @@ if TYPE_CHECKING: __all__ = ( + 'Capabilities', 'SystemChannelFlags', 'MessageFlags', 'PublicUserFlags', @@ -49,6 +50,10 @@ __all__ = ( 'GiftFlags', 'LibraryApplicationFlags', 'ApplicationDiscoveryFlags', + 'FriendSourceFlags', + 'FriendDiscoveryFlags', + 'HubProgressFlags', + 'OnboardingProgressFlags', ) BF = TypeVar('BF', bound='BaseFlags') @@ -158,6 +163,111 @@ class BaseFlags: raise TypeError(f'Value to set for {self.__class__.__name__} must be a bool.') +@fill_with_flags() +class Capabilities(BaseFlags): + """Wraps up the Discord gateway capabilities. + + Capabilities are used to determine what gateway features a client support. + + This is meant to be used internally by the library. + + .. container:: operations + + .. describe:: x == y + + Checks if two capabilities are equal. + .. describe:: x != y + + Checks if two capabilities are not equal. + .. describe:: hash(x) + + Return the capability's hash. + .. describe:: iter(x) + + Returns an iterator of ``(name, value)`` pairs. This allows it + to be, for example, constructed as a dict or a list of pairs. + + Attributes + ----------- + value: :class:`int` + The raw value. This value is a bit array field of a 53-bit integer + representing the currently available flags. You should query + flags via the properties rather than using this raw value. + """ + + __slots__ = () + + # The unfortunate thing about capabilities is that while a lot of these options + # may be useful to the library (i.e. to expose to users for customization), + # we match the official client's values for anti-spam purposes :( + + @classmethod + def default(cls: Type[Self]) -> Self: + """Returns a :class:`Capabilities` with the current value used by the library.""" + return cls._from_value(2045) + + @flag_value + def lazy_user_notes(self): + """:class:`bool`: Disable preloading of user notes in READY.""" + return 1 << 0 + + @flag_value + def no_affine_user_ids(self): + """:class:`bool`: Disable implicit relationship updates.""" + return 1 << 1 + + @flag_value + def versioned_read_states(self): + """:class:`bool`: Enable versioned read states (change READY ``read_state`` to an object with ``version``/``partial``).""" + return 1 << 2 + + @flag_value + def versioned_user_guild_settings(self): + """:class:`bool`: Enable versioned user guild settings (change READY ``user_guild_settings`` to an object with ``version``/``partial``).""" + return 1 << 3 + + @flag_value + def dedupe_user_objects(self): + """:class:`bool`: Enable dehydration of the READY payload (move all user objects to a ``users`` array and replace them in various places in the READY payload with ``user_id`` or ``recipient_id``, move member object(s) from initial guild objects to ``merged_members``).""" + return 1 << 4 + + @flag_value + def prioritized_ready_payload(self): + """:class:`bool`: Enable prioritized READY payload (enable READY_SUPPLEMENTAL, move ``voice_states`` and ``embedded_activities`` from initial guild objects and ``merged_presences`` from READY, as well as split ``merged_members`` and (sometimes) ``private_channels``/``lazy_private_channels`` between the events).""" + # Requires self.dedupe_user_objects + return 1 << 5 | 1 << 4 + + @flag_value + def multiple_guild_experiment_populations(self): + """:class:`bool`: Handle multiple guild experiment populations (change the fourth entry of arrays in the ``guild_experiments`` array in READY to have an array of population arrays).""" + return 1 << 6 + + @flag_value + def non_channel_read_states(self): + """:class:`bool`: Handle non-channel read states (change READY ``read_state`` to include read states tied to server events, server home, and the mobile notification center).""" + return 1 << 7 + + @flag_value + def auth_token_refresh(self): + """:class:`bool`: Enable auth token refresh (add ``auth_token?`` to READY; this is sent when Discord wants to change the client's token, and was used for the mfa. token migration).""" + return 1 << 8 + + @flag_value + def user_settings_proto(self): + """:class:`bool`: Disable legacy user settings (remove ``user_settings`` from READY and stop sending USER_SETTINGS_UPDATE).""" + return 1 << 9 + + @flag_value + def client_state_v2(self): + """:class:`bool`: Enable client caching v2 (move guild properties in guild objects to a ``properties`` subkey and add ``data_mode`` and ``version`` to the objects, as well as change ``client_state`` in IDENTIFY).""" + return 1 << 10 + + @flag_value + def passive_guild_update(self): + """:class:`bool`: Enable passive guild update (replace ``CHANNEL_UNREADS_UPDATE`` with ``PASSIVE_UPDATE_V1``, a similar event that includes a ``voice_states`` array and a ``members`` array that includes the members of aforementioned voice states).""" + return 1 << 11 + + @fill_with_flags(inverted=True) class SystemChannelFlags(BaseFlags): r"""Wraps up a Discord system channel flag value. @@ -1420,3 +1530,240 @@ class ApplicationDiscoveryFlags(BaseFlags): def eligible(self): """:class:`bool`: Returns ``True`` if the application has met all the above criteria and is eligible for discovery.""" return 1 << 16 + + +class FriendSourceFlags(BaseFlags): + r"""Wraps up the Discord friend source flags. + + These are used in user settings to control who can add you as a friend. + + .. container:: operations + + .. describe:: x == y + + Checks if two FriendSourceFlags are equal. + .. describe:: x != y + + Checks if two FriendSourceFlags are not equal. + .. describe:: hash(x) + + Return the flag's hash. + .. describe:: iter(x) + + Returns an iterator of ``(name, value)`` pairs. This allows it + to be, for example, constructed as a dict or a list of pairs. + Note that aliases are not shown. + + .. versionadded:: 2.0 + + Attributes + ----------- + value: :class:`int` + The raw value. This value is a bit array field of a 53-bit integer + representing the currently available flags. You should query + flags via the properties rather than using this raw value. + """ + + __slots__ = () + + @classmethod + def _from_dict(cls, data: dict) -> Self: + self = cls() + if data.get('mutual_friends'): + self.mutual_friends = True + if data.get('mutual_guilds'): + self.mutual_guilds = True + if data.get('all'): + self.no_relation = True + return self + + def _to_dict(self) -> dict: + return { + 'mutual_friends': self.mutual_friends, + 'mutual_guilds': self.mutual_guilds, + 'all': self.no_relation, + } + + @classmethod + def none(cls) -> Self: + """A factory method that creates a :class:`FriendSourceFlags` that allows no friend request.""" + return cls() + + @classmethod + def all(cls) -> Self: + """A factory method that creates a :class:`FriendSourceFlags` that allows any friend requests.""" + self = cls() + self.no_relation = True + return self + + @flag_value + def mutual_friends(self): + """:class:`bool`: Returns ``True`` if a user can add you as a friend if you have mutual friends.""" + return 1 << 1 + + @flag_value + def mutual_guilds(self): + """:class:`bool`: Returns ``True`` if a user can add you as a friend if you are in the same guild.""" + return 1 << 2 + + @flag_value + def no_relation(self): + """:class:`bool`: Returns ``True`` if a user can always add you as a friend.""" + # Requires all of the above + return 1 << 3 | 1 << 2 | 1 << 1 + + +@fill_with_flags() +class FriendDiscoveryFlags(BaseFlags): + r"""Wraps up the Discord friend discovery flags. + + These are used in user settings to control how you get recommended friends. + + .. container:: operations + + .. describe:: x == y + + Checks if two FriendDiscoveryFlags are equal. + .. describe:: x != y + + Checks if two FriendDiscoveryFlags are not equal. + .. describe:: hash(x) + + Return the flag's hash. + .. describe:: iter(x) + + Returns an iterator of ``(name, value)`` pairs. This allows it + to be, for example, constructed as a dict or a list of pairs. + Note that aliases are not shown. + + .. versionadded:: 2.0 + + Attributes + ----------- + value: :class:`int` + The raw value. This value is a bit array field of a 53-bit integer + representing the currently available flags. You should query + flags via the properties rather than using this raw value. + """ + + __slots__ = () + + @classmethod + def none(cls) -> Self: + """A factory method that creates a :class:`FriendDiscoveryFlags` that allows no friend discovery.""" + return cls() + + @classmethod + def all(cls) -> Self: + """A factory method that creates a :class:`FriendDiscoveryFlags` that allows all friend discovery.""" + self = cls() + self.find_by_email = True + self.find_by_phone = True + return self + + @flag_value + def find_by_phone(self): + """:class:`bool`: Returns ``True`` if a user can add you as a friend if they have your phone number.""" + return 1 << 1 + + @flag_value + def find_by_email(self): + """:class:`bool`: Returns ``True`` if a user can add you as a friend if they have your email address.""" + return 1 << 2 + + +@fill_with_flags() +class HubProgressFlags(BaseFlags): + """Wraps up the Discord hub progress flags. + + These are used in user settings, specifically guild progress, to track engagement and feature usage in hubs. + + .. container:: operations + + .. describe:: x == y + + Checks if two HubProgressFlags are equal. + .. describe:: x != y + + Checks if two HubProgressFlags are not equal. + .. describe:: hash(x) + + Return the flag's hash. + .. describe:: iter(x) + + Returns an iterator of ``(name, value)`` pairs. This allows it + to be, for example, constructed as a dict or a list of pairs. + Note that aliases are not shown. + + .. versionadded:: 2.0 + + Attributes + ----------- + value: :class:`int` + The raw value. This value is a bit array field of a 53-bit integer + representing the currently available flags. You should query + flags via the properties rather than using this raw value. + """ + + __slots__ = () + + @flag_value + def join_guild(self): + """:class:`bool`: Returns ``True`` if the user has joined a guild in the hub.""" + return 1 << 0 + + @flag_value + def invite_user(self): + """:class:`bool`: Returns ``True`` if the user has sent an invite for the hub.""" + return 1 << 1 + + @flag_value + def contact_sync(self): + """:class:`bool`: Returns ``True`` if the user has accepted the contact sync modal.""" + return 1 << 2 + + +@fill_with_flags() +class OnboardingProgressFlags(BaseFlags): + """Wraps up the Discord guild onboarding progress flags. + + These are used in user settings, specifically guild progress, to track engagement and feature usage in guild onboarding. + + .. container:: operations + + .. describe:: x == y + + Checks if two OnboardingProgressFlags are equal. + .. describe:: x != y + + Checks if two OnboardingProgressFlags are not equal. + .. describe:: hash(x) + + Return the flag's hash. + .. describe:: iter(x) + + Returns an iterator of ``(name, value)`` pairs. This allows it + to be, for example, constructed as a dict or a list of pairs. + Note that aliases are not shown. + + .. versionadded:: 2.0 + + Attributes + ----------- + value: :class:`int` + The raw value. This value is a bit array field of a 53-bit integer + representing the currently available flags. You should query + flags via the properties rather than using this raw value. + """ + + __slots__ = () + + @flag_value + def notice_shown(self): + """:class:`bool`: Returns ``True`` if the user has been shown the onboarding notice.""" + return 1 << 0 + + @flag_value + def notice_cleared(self): + """:class:`bool`: Returns ``True`` if the user has cleared the onboarding notice.""" + return 1 << 1 diff --git a/discord/gateway.py b/discord/gateway.py index f29180cb1..532705017 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -40,6 +40,7 @@ from . import utils from .activity import BaseActivity, Spotify from .enums import SpeakingState from .errors import ConnectionClosed +from .flags import Capabilities _log = logging.getLogger(__name__) @@ -334,6 +335,10 @@ class DiscordWebSocket: def open(self) -> bool: return not self.socket.closed + @property + def capabilities(self) -> Capabilities: + return Capabilities.default() + def is_ratelimited(self) -> bool: return self._rate_limiter.is_ratelimited() @@ -455,10 +460,10 @@ class DiscordWebSocket: 'op': self.IDENTIFY, 'd': { 'token': self.token, - 'capabilities': 509, + 'capabilities': self.capabilities.value, 'properties': self._super_properties, 'presence': presence, - 'compress': False, + 'compress': not self._zlib_enabled, # We require at least one form of compression 'client_state': { 'guild_hashes': {}, 'highest_last_message_id': '0', @@ -468,10 +473,6 @@ class DiscordWebSocket: }, } - if not self._zlib_enabled: - # We require at least one form of compression - payload['d']['compress'] = True - await self.call_hooks('before_identify', initial=self._initial_identify) await self.send_as_json(payload) _log.info('Gateway has sent the IDENTIFY payload.') diff --git a/discord/guild_folder.py b/discord/guild_folder.py deleted file mode 100644 index 6d690864d..000000000 --- a/discord/guild_folder.py +++ /dev/null @@ -1,113 +0,0 @@ -""" -The MIT License (MIT) - -Copyright (c) 2021-present Dolfies - -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, TYPE_CHECKING - -from .colour import Colour -from .object import Object - -if TYPE_CHECKING: - from .guild import Guild - from .state import ConnectionState - from .types.snowflake import Snowflake - -# fmt: off -__all__ = ( - 'GuildFolder', -) -# fmt: on - - -class GuildFolder: - """Represents a guild folder - - .. note:: - Guilds not in folders *are* actually in folders API wise, with them being the only member. - - These folders do not have an ID or name. - - .. versionadded:: 1.9 - - .. versionchanged:: 2.0 - Removed various operations and made ``id`` and ``name`` optional. - - .. container:: operations - - .. describe:: str(x) - - Returns the folder's name. - - .. describe:: len(x) - - Returns the number of guilds in the folder. - - Attributes - ---------- - id: Optional[Union[:class:`str`, :class:`int`]] - The ID of the folder. - name: Optional[:class:`str`] - The name of the folder. - guilds: List[:class:`Guild`] - The guilds in the folder. - """ - - __slots__ = ('_state', 'id', 'name', '_colour', 'guilds') - - def __init__(self, *, data, state: ConnectionState) -> None: - self._state = state - self.id: Optional[Snowflake] = data['id'] - self.name: Optional[str] = data['name'] - self._colour: Optional[int] = data['color'] - self.guilds: List[Guild] = list(filter(None, map(self._get_guild, data['guild_ids']))) # type: ignore # Lying for better developer UX - - def __str__(self) -> str: - return self.name or ', '.join(guild.name for guild in self.guilds) - - def __repr__(self) -> str: - return f'' - - def __len__(self) -> int: - return len(self.guilds) - - def _get_guild(self, id): - return self._state._get_guild(int(id)) or Object(id=int(id)) - - @property - def colour(self) -> Optional[Colour]: - """Optional[:class:`Colour`] The colour of the folder. - - There is an alias for this called :attr:`color`. - """ - colour = self._colour - return Colour(colour) if colour is not None else None - - @property - def color(self) -> Optional[Colour]: - """Optional[:class:`Colour`] The color of the folder. - - This is an alias for :attr:`colour`. - """ - return self.colour diff --git a/discord/http.py b/discord/http.py index 7d9fe661b..4df206b31 100644 --- a/discord/http.py +++ b/discord/http.py @@ -3699,6 +3699,20 @@ class HTTPClient: def leave_hypesquad_house(self) -> Response[None]: return self.request(Route('DELETE', '/hypesquad/online')) + def get_proto_settings(self, type: int) -> Response[user.ProtoSettings]: + return self.request(Route('GET', '/users/@me/settings-proto/{type}', type=type)) + + def edit_proto_settings( + self, type: int, settings: str, required_data_version: Optional[int] = None + ) -> Response[user.ProtoSettings]: + payload: Dict[str, Snowflake] = {'settings': settings} + if required_data_version is not None: + # The required data version of the proto is set to the last known version when an offline edit is made + # so the PATCH doesn't overwrite newer edits made on a different client + payload['required_data_version'] = required_data_version + + return self.request(Route('PATCH', '/users/@me/settings-proto/{type}', type=type), json=payload) + def get_settings(self): # TODO: return type return self.request(Route('GET', '/users/@me/settings')) diff --git a/discord/settings.py b/discord/settings.py index c5572a7c7..368fc4386 100644 --- a/discord/settings.py +++ b/discord/settings.py @@ -24,100 +24,1623 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations -from datetime import datetime -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union, overload +import base64 +from datetime import datetime, timezone +import struct +import logging +from typing import TYPE_CHECKING, Any, Collection, Dict, List, Optional, Sequence, Tuple, Type, Union, overload -from .activity import create_settings_activity +from google.protobuf.json_format import MessageToDict, ParseDict +from discord_protos import PreloadedUserSettings # , FrecencyUserSettings + +from .activity import CustomActivity +from .colour import Colour from .enums import ( - FriendFlags, + EmojiPickerSection, HighlightLevel, + InboxTab, Locale, NotificationLevel, Status, + SpoilerRenderOptions, StickerAnimationOptions, + StickerPickerSection, Theme, UserContentFilter, try_enum, ) -from .guild_folder import GuildFolder +from .flags import FriendDiscoveryFlags, FriendSourceFlags, HubProgressFlags, OnboardingProgressFlags from .object import Object -from .utils import MISSING, _get_as_snowflake, parse_time, utcnow, find +from .utils import MISSING, _get_as_snowflake, _ocast, parse_time, parse_timestamp, utcnow, find if TYPE_CHECKING: - from .abc import GuildChannel, PrivateChannel - from .activity import CustomActivity + from google.protobuf.message import Message + from typing_extensions import Self + + from .abc import GuildChannel, PrivateChannel, Snowflake from .guild import Guild from .state import ConnectionState - from .user import ClientUser + from .user import ClientUser, User __all__ = ( + 'UserSettings', + 'GuildFolder', + 'GuildProgress', + 'AudioContext', + 'LegacyUserSettings', + 'MuteConfig', 'ChannelSettings', 'GuildSettings', - 'UserSettings', 'TrackingSettings', 'EmailSettings', - 'MuteConfig', ) +_log = logging.getLogger(__name__) + + +class _ProtoSettings: + __slots__ = ( + '_state', + 'settings', + ) + + PROTOBUF_CLS: Type[Message] = MISSING + settings: Any + + # I honestly wish I didn't have to vomit properties everywhere like this, + # but unfortunately it's probably the best way to do it + # The discord-protos library is maintained seperately, so any changes + # to the protobufs will have to be reflected here; + # this is why I'm keeping the `settings` attribute public + # I love protobufs :blobcatcozystars: + + def __init__(self, state: ConnectionState, data: str): + self._state: ConnectionState = state + self._update(data) + + def __repr__(self) -> str: + return f'<{self.__class__.__name__}>' + + def __eq__(self, other: Any) -> bool: + if isinstance(other, self.__class__): + return self.settings == other.settings + return False + + def __ne__(self, other: Any) -> bool: + if isinstance(other, self.__class__): + return self.settings != other.settings + return True + + def _update(self, data: str, *, partial: bool = False): + if partial: + self.merge_from_base64(data) + else: + self.from_base64(data) + + @classmethod + def _copy(cls, self: Self, /) -> Self: + new = cls.__new__(cls) + new._state = self._state + new.settings = cls.PROTOBUF_CLS() + new.settings.CopyFrom(self.settings) + return new + + def _get_guild(self, id: int, /) -> Union[Guild, Object]: + id = int(id) + return self._state._get_guild(id) or Object(id=id) -class UserSettings: + def to_dict(self, *, with_defaults: bool = False) -> Dict[str, Any]: + return MessageToDict( + self.settings, + including_default_value_fields=with_defaults, + preserving_proto_field_name=True, + use_integers_for_enums=True, + ) + + def dict_to_base64(self, data: Dict[str, Any]) -> str: + message = ParseDict(data, self.PROTOBUF_CLS()) + return base64.b64encode(message.SerializeToString()).decode('ascii') + + def from_base64(self, data: str): + self.settings = self.PROTOBUF_CLS().FromString(base64.b64decode(data)) + + def merge_from_base64(self, data: str): + self.settings.MergeFromString(base64.b64decode(data)) + + def to_base64(self) -> str: + return base64.b64encode(self.settings.SerializeToString()).decode('ascii') + + +class UserSettings(_ProtoSettings): """Represents the Discord client settings. + .. versionadded:: 2.0 + """ + + __slots__ = () + + PROTOBUF_CLS = PreloadedUserSettings + + # Client versions are supposed to be backwards compatible + # If the client supports a version newer than the one in data, + # it does a migration and updates the version in data + SUPPORTED_CLIENT_VERSION = 17 + SUPPORTED_SERVER_VERSION = 0 + + def __init__(self, *args): + super().__init__(*args) + if self.client_version < self.SUPPORTED_CLIENT_VERSION: + # Migrations are mostly for client state, but we'll throw a debug log anyway + _log.debug('PreloadedUserSettings client version is outdated, migration needed. Unexpected behaviour may occur.') + if self.server_version > self.SUPPORTED_SERVER_VERSION: + # At the time of writing, the server version is not provided (so it's always 0) + # The client does not use the field at all, so there probably won't be any server-side migrations anytime soon + _log.debug('PreloadedUserSettings server version is newer than supported. Unexpected behaviour may occur.') + + @property + def data_version(self) -> int: + """:class:`int`: The version of the settings. Increases on every change.""" + return self.settings.versions.data_version + + @property + def client_version(self) -> int: + """:class:`int`: The client version of the settings. Used for client-side data migrations.""" + return self.settings.versions.client_version + + @property + def server_version(self) -> int: + """:class:`int`: The server version of the settings. Used for server-side data migrations.""" + return self.settings.versions.server_version + + # Inbox Settings + + @property + def inbox_tab(self) -> InboxTab: + """:class:`InboxTab`: The current (last opened) inbox tab.""" + return try_enum(InboxTab, self.settings.inbox.current_tab) + + @property + def inbox_tutorial_viewed(self) -> bool: + """:class:`bool`: Whether the inbox tutorial has been viewed.""" + return self.settings.inbox.viewed_tutorial + + # Guild Settings + + @property + def guild_progress_settings(self) -> List[GuildProgress]: + """List[:class:`GuildProgress`]: A list of guild progress settings.""" + state = self._state + return [ + GuildProgress._from_settings(guild_id, data=settings, state=state) + for guild_id, settings in self.settings.guilds.guilds.items() + ] + + # User Content Settings + + @property + def dismissed_contents(self) -> Tuple[int, ...]: + """Tuple[:class:`int`]: A list of enum values representing dismissable content in the app. + + .. note:: + + For now, this just returns the raw values without converting to a proper enum, + as the enum values change too often to be viably maintained. + """ + contents = self.settings.user_content.dismissed_contents + return struct.unpack(f'>{len(contents)}B', contents) + + @property + def last_dismissed_promotion_start_date(self) -> Optional[datetime]: + """Optional[:class:`datetime.datetime`]: The date the last dismissed promotion started.""" + return parse_time(self.settings.user_content.last_dismissed_outbound_promotion_start_date.value or None) + + @property + def nitro_basic_modal_dismissed_at(self) -> Optional[datetime]: + """Optional[:class:`datetime.datetime`]: The date the Nitro Basic modal was dismissed.""" + return ( + self.settings.user_content.premium_tier_0_modal_dismissed_at.ToDatetime(tzinfo=timezone.utc) + if self.settings.user_content.HasField('premium_tier_0_modal_dismissed_at') + else None + ) + + # Voice and Video Settings + + # TODO: Video filters + + # @property + # def video_filter_background_blur(self) -> bool: + # return self.settings.voice_and_video.blur.use_blur + + @property + def always_preview_video(self) -> bool: + """Whether to always show the preview modal when the user turns on their camera.""" + return self.settings.voice_and_video.always_preview_video.value + + @property + def afk_timeout(self) -> int: + """:class:`int`: How long (in seconds) the user needs to be AFK until Discord sends push notifications to mobile devices (30-600).""" + return self.settings.voice_and_video.afk_timeout.value or 600 + + @property + def stream_notifications_enabled(self) -> bool: + """:class:`bool`: Whether stream notifications for friends will be received.""" + return ( + self.settings.voice_and_video.stream_notifications_enabled.value + if self.settings.voice_and_video.HasField('stream_notifications_enabled') + else True + ) + + @property + def native_phone_integration_enabled(self) -> bool: + """:class:`bool`: Whether to enable the Discord mobile Callkit.""" + return ( + self.settings.voice_and_video.native_phone_integration_enabled.value + if self.settings.voice_and_video.HasField('native_phone_integration_enabled') + else True + ) + + @property + def soundboard_volume(self) -> float: + """:class:`float`: The volume of the soundboard (0-100).""" + return ( + self.settings.voice_and_video.soundboard_settings.volume + if self.settings.voice_and_video.HasField('soundboard_settings') + else 100.0 + ) + + # Text and Images Settings + + @property + def diversity_surrogate(self) -> Optional[str]: + """Optional[:class:`str`]: The unicode character used as the diversity surrogate for supported emojis (i.e. emoji skin tones, ``🏻``).""" + return self.settings.text_and_images.diversity_surrogate.value or None + + @property + def use_thread_sidebar(self) -> bool: + """:class:`bool`: Whether to open threads in split view.""" + return ( + self.settings.text_and_images.use_thread_sidebar.value + if self.settings.text_and_images.HasField('use_thread_sidebar') + else True + ) + + @property + def render_spoilers(self) -> SpoilerRenderOptions: + """:class:`SpoilerRenderOptions`: When to show spoiler content.""" + return try_enum(SpoilerRenderOptions, self.settings.text_and_images.render_spoilers.value or 'ON_CLICK') + + @property + def collapsed_emoji_picker_sections(self) -> Tuple[Union[EmojiPickerSection, Guild, Object], ...]: + """Tuple[Union[:class:`EmojiPickerSection`, :class:`Guild`, :class:`Object`]]: A list of emoji picker sections (including guild IDs) that are collapsed.""" + return tuple( + self._get_guild(section) if section.isdigit() else try_enum(EmojiPickerSection, section) + for section in self.settings.text_and_images.emoji_picker_collapsed_sections + ) + + @property + def collapsed_sticker_picker_sections(self) -> Tuple[Union[StickerPickerSection, Guild, Object], ...]: + """Tuple[Union[:class:`StickerPickerSection`, :class:`Guild`, :class:`Object`]]: A list of sticker picker sections (including guild and sticker pack IDs) that are collapsed.""" + return tuple( + self._get_guild(section) if section.isdigit() else try_enum(StickerPickerSection, section) + for section in self.settings.text_and_images.sticker_picker_collapsed_sections + ) + + @property + def view_image_descriptions(self) -> bool: + """:class:`bool`: Whether to display the alt text of attachments.""" + return self.settings.text_and_images.view_image_descriptions.value + + @property + def show_command_suggestions(self) -> bool: + """:class:`bool`: Whether to show application command suggestions in-chat.""" + return ( + self.settings.text_and_images.show_command_suggestions.value + if self.settings.text_and_images.HasField('show_command_suggestions') + else True + ) + + @property + def inline_attachment_media(self) -> bool: + """:class:`bool`: Whether to display attachments when they are uploaded in chat.""" + return ( + self.settings.text_and_images.inline_attachment_media.value + if self.settings.text_and_images.HasField('inline_attachment_media') + else True + ) + + @property + def inline_embed_media(self) -> bool: + """:class:`bool`: Whether to display videos and images from links posted in chat.""" + return ( + self.settings.text_and_images.inline_embed_media.value + if self.settings.text_and_images.HasField('inline_embed_media') + else True + ) + + @property + def gif_auto_play(self) -> bool: + """:class:`bool`: Whether to automatically play GIFs that are in the chat..""" + return ( + self.settings.text_and_images.gif_auto_play.value + if self.settings.text_and_images.HasField('gif_auto_play') + else True + ) + + @property + def render_embeds(self) -> bool: + """:class:`bool`: Whether to render embeds that are sent in the chat.""" + return ( + self.settings.text_and_images.render_embeds.value + if self.settings.text_and_images.HasField('render_embeds') + else True + ) + + @property + def render_reactions(self) -> bool: + """:class:`bool`: Whether to render reactions that are added to messages.""" + return ( + self.settings.text_and_images.render_reactions.value + if self.settings.text_and_images.HasField('render_reactions') + else True + ) + + @property + def animate_emojis(self) -> bool: + """:class:`bool`: Whether to animate emojis in the chat.""" + return ( + self.settings.text_and_images.animate_emoji.value + if self.settings.text_and_images.HasField('animate_emoji') + else True + ) + + @property + def animate_stickers(self) -> StickerAnimationOptions: + """:class:`StickerAnimationOptions`: Whether to animate stickers in the chat.""" + return try_enum(StickerAnimationOptions, self.settings.text_and_images.animate_stickers.value) + + @property + def enable_tts_command(self) -> bool: + """:class:`bool`: Whether to allow TTS messages to be played/sent.""" + return ( + self.settings.text_and_images.enable_tts_command.value + if self.settings.text_and_images.HasField('enable_tts_command') + else True + ) + + @property + def message_display_compact(self) -> bool: + """:class:`bool`: Whether to use the compact Discord display mode.""" + return self.settings.text_and_images.message_display_compact.value + + @property + def explicit_content_filter(self) -> UserContentFilter: + """:class:`UserContentFilter`: The filter for explicit content in all messages.""" + return try_enum( + UserContentFilter, + self.settings.text_and_images.explicit_content_filter.value + if self.settings.text_and_images.HasField('explicit_content_filter') + else 1, + ) + + @property + def view_nsfw_guilds(self) -> bool: + """:class:`bool`: Whether to show NSFW guilds on iOS.""" + return self.settings.text_and_images.view_nsfw_guilds.value + + @property + def convert_emoticons(self) -> bool: + r""":class:`bool`: Whether to automatically convert emoticons into emojis (e.g. ``:)`` -> 😃).""" + return ( + self.settings.text_and_images.convert_emoticons.value + if self.settings.text_and_images.HasField('convert_emoticons') + else True + ) + + @property + def show_expression_suggestions(self) -> bool: + """:class:`bool`: Whether to show expression (emoji/sticker/soundboard) suggestions in-chat.""" + return ( + self.settings.text_and_images.expression_suggestions_enabled.value + if self.settings.text_and_images.HasField('expression_suggestions_enabled') + else True + ) + + @property + def view_nsfw_commands(self) -> bool: + """:class:`bool`: Whether to show NSFW application commands in DMs.""" + return self.settings.text_and_images.view_nsfw_commands.value + + @property + def use_legacy_chat_input(self) -> bool: + """:class:`bool`: Whether to use the legacy chat input over the new rich input.""" + return self.settings.text_and_images.use_legacy_chat_input.value + + # Notifications Settings + + @property + def in_app_notifications(self) -> bool: + """:class:`bool`: Whether to show notifications directly in the app.""" + return ( + self.settings.notifications.show_in_app_notifications.value + if self.settings.notifications.HasField('show_in_app_notifications') + else True + ) + + @property + def send_stream_notifications(self) -> bool: + """:class:`bool`: Whether to send notifications to friends when using the go live feature.""" + return self.settings.notifications.notify_friends_on_go_live.value + + @property + def notification_center_acked_before_id(self) -> int: + """:class:`int`: The ID of the last notification that was acknowledged in the notification center.""" + return self.settings.notifications.notification_center_acked_before_id + + # Privacy Settings + + @property + def allow_activity_friend_joins(self) -> bool: + """:class:`bool`: Whether to allow friends to join your activity without sending a request.""" + return ( + self.settings.privacy.allow_activity_party_privacy_friends.value + if self.settings.privacy.HasField('allow_activity_party_privacy_friends') + else True + ) + + @property + def allow_activity_voice_channel_joins(self) -> bool: + """:class:`bool`: Whether to allow people in the same voice channel as you to join your activity without sending a request. Does not apply to Community guilds.""" + return ( + self.settings.privacy.allow_activity_party_privacy_voice_channel.value + if self.settings.privacy.HasField('allow_activity_party_privacy_voice_channel') + else True + ) + + @property + def restricted_guilds(self) -> List[Union[Guild, Object]]: + """List[Union[:class:`Guild`, :class:`Object`]]: A list of guilds that you will not receive DMs from.""" + return list(map(self._get_guild, self.settings.privacy.restricted_guild_ids)) + + @property + def default_guilds_restricted(self) -> bool: + """:class:`bool`: Whether to automatically disable DMs between you and members of new guilds you join.""" + return self.settings.privacy.default_guilds_restricted + + @property + def allow_accessibility_detection(self) -> bool: + """:class:`bool`: Whether to allow Discord to track screen reader usage.""" + return self.settings.privacy.allow_accessibility_detection + + @property + def detect_platform_accounts(self) -> bool: + """:class:`bool`: Whether to automatically detect accounts from services like Steam and Blizzard when you open the Discord client.""" + return ( + self.settings.privacy.detect_platform_accounts.value + if self.settings.privacy.HasField('detect_platform_accounts') + else True + ) + + @property + def passwordless(self) -> bool: + """:class:`bool`: Whether to enable passwordless login.""" + return self.settings.privacy.passwordless.value if self.settings.privacy.HasField('passwordless') else True + + @property + def contact_sync_enabled(self) -> bool: + """:class:`bool`: Whether to enable the contact sync on Discord mobile.""" + return self.settings.privacy.contact_sync_enabled.value + + @property + def friend_source_flags(self) -> FriendSourceFlags: + """:class:`FriendSourceFlags`: Who can add you as a friend.""" + return ( + FriendSourceFlags._from_value(self.settings.privacy.friend_source_flags.value) + if self.settings.privacy.HasField('friend_source_flags') + else FriendSourceFlags.all() + ) + + @property + def friend_discovery_flags(self) -> FriendDiscoveryFlags: + """:class:`FriendDiscoveryFlags`: How you get recommended friends.""" + return FriendDiscoveryFlags._from_value(self.settings.privacy.friend_discovery_flags.value) + + @property + def activity_restricted_guilds(self) -> List[Union[Guild, Object]]: + """List[Union[:class:`Guild`, :class:`Object`]]: A list of guilds that your current activity will not be shown in.""" + return list(map(self._get_guild, self.settings.privacy.activity_restricted_guild_ids)) + + @property + def default_guilds_activity_restricted(self) -> bool: + """:class:`bool`: Whether to automatically disable showing your current activity in new large (over 200 member) guilds you join.""" + return self.settings.privacy.default_guilds_activity_restricted + + @property + def activity_joining_restricted_guilds(self) -> List[Union[Guild, Object]]: + """List[Union[:class:`Guild`, :class:`Object`]]: A list of guilds that will not be able to join your current activity.""" + return list(map(self._get_guild, self.settings.privacy.activity_joining_restricted_guild_ids)) + + @property + def message_request_restricted_guilds(self) -> List[Union[Guild, Object]]: + """List[Union[:class:`Guild`, :class:`Object`]]: A list of guilds whose originating DMs will not be filtered into your message requests.""" + return list(map(self._get_guild, self.settings.privacy.message_request_restricted_guild_ids)) + + @property + def default_message_request_restricted(self) -> bool: + """:class:`bool`: Whether to automatically disable the message request system in new guilds you join.""" + return self.settings.privacy.default_message_request_restricted.value + + @property + def drops(self) -> bool: + """:class:`bool`: Whether the Discord drops feature is enabled.""" + return not self.settings.privacy.drops_opted_out.value + + @property + def non_spam_retraining(self) -> Optional[bool]: + """Optional[:class:`bool`]: Whether to help improve Discord spam models when marking messages as non-spam; staff only.""" + return ( + self.settings.privacy.non_spam_retraining_opt_in.value + if self.settings.privacy.HasField('non_spam_retraining_opt_in') + else None + ) + + # Debug Settings + + @property + def rtc_panel_show_voice_states(self) -> bool: + """:class:`bool`: Whether to show voice states in the RTC panel.""" + return self.settings.debug.rtc_panel_show_voice_states.value + + # Game Library Settings + + @property + def install_shortcut_desktop(self) -> bool: + """:class:`bool`: Whether to install a desktop shortcut for games.""" + return self.settings.game_library.install_shortcut_desktop.value + + @property + def install_shortcut_start_menu(self) -> bool: + """:class:`bool`: Whether to install a start menu shortcut for games.""" + return ( + self.settings.game_library.install_shortcut_start_menu.value + if self.settings.game_library.HasField('install_shortcut_start_menu') + else True + ) + + @property + def disable_games_tab(self) -> bool: + """:class:`bool`: Whether to disable the showing of the Games tab.""" + return self.settings.game_library.disable_games_tab.value + + # Status Settings + + @property + def status(self) -> Status: + """:class:`Status`: The configured status.""" + return try_enum(Status, self.settings.status.status.value or 'unknown') + + @property + def custom_activity(self) -> Optional[CustomActivity]: + """:class:`CustomActivity`: The set custom activity.""" + return ( + CustomActivity._from_settings(data=self.settings.status.custom_status, state=self._state) + if self.settings.status.HasField('custom_status') + else None + ) + + @property + def show_current_game(self) -> bool: + """:class:`bool`: Whether to show the current game.""" + return self.settings.status.show_current_game.value if self.settings.status.HasField('show_current_game') else True + + # Localization Settings + + @property + def locale(self) -> Locale: + """:class:`Locale`: The :rfc:`3066` language identifier of the locale to use for the language of the Discord client.""" + return try_enum(Locale, self.settings.localization.locale.value or 'en-US') + + @property + def timezone_offset(self) -> int: + """:class:`int`: The timezone offset from UTC to use (in minutes).""" + return self.settings.localization.timezone_offset.value + + # Appearance Settings + + @property + def theme(self) -> Theme: + """:class:`Theme`: The overall theme of the Discord UI.""" + return Theme.from_int(self.settings.appearance.theme) + + @property + def client_theme(self) -> Optional[Tuple[int, int, float]]: + """Optional[Tuple[:class:`int`, :class:`int`, :class:`float`]]: The client theme settings, in order of primary color, gradient preset, and gradient angle.""" + return ( + ( + self.settings.appearance.client_theme_settings.primary_color.value, + self.settings.appearance.client_theme_settings.background_gradient_preset_id.value, + self.settings.appearance.client_theme_settings.background_gradient_angle.value, + ) + if self.settings.appearance.HasField('client_theme_settings') + else None + ) + + @property + def developer_mode(self) -> bool: + """:class:`bool`: Whether to enable developer mode.""" + return self.settings.appearance.developer_mode + + @property + def disable_mobile_redesign(self) -> bool: + """:class:`bool`: Whether to opt-out of the mobile redesign.""" + return self.settings.appearance.mobile_redesign_disabled + + # Guild Folder Settings + + @property + def guild_folders(self) -> List[GuildFolder]: + """List[:class:`GuildFolder`]: A list of guild folders.""" + state = self._state + return [GuildFolder._from_settings(data=folder, state=state) for folder in self.settings.guild_folders.folders] + + @property + def guild_positions(self) -> List[Union[Guild, Object]]: + """List[Union[:class:`Guild`, :class:`Object`]]: A list of guilds in order of the guild/guild icons that are on the left hand side of the UI.""" + return list(map(self._get_guild, self.settings.guild_folders.guild_positions)) + + # Favorites Settings + + # TODO: Favorites + + # Audio Settings + + @property + def user_audio_settings(self) -> List[AudioContext]: + """List[:class:`AudioContext`]: A list of audio context settings for users.""" + state = self._state + return [ + AudioContext._from_settings(user_id, data=data, state=state) + for user_id, data in self.settings.audio_context_settings.user.items() + ] + + @property + def stream_audio_settings(self) -> List[AudioContext]: + """List[:class:`AudioContext`]: A list of audio context settings for streams.""" + state = self._state + return [ + AudioContext._from_settings(stream_id, data=data, state=state) + for stream_id, data in self.settings.audio_context_settings.stream.items() + ] + + # Communities Settings + + @property + def home_auto_navigation(self) -> bool: + """:class:`bool`: Whether to automatically redirect to guild home for guilds that have not been accessed in a while.""" + return not self.settings.communities.disable_home_auto_nav.value + + @overload + async def edit(self) -> Self: + ... + + @overload + async def edit( + self, + *, + require_version: Union[bool, int] = False, + client_version: int = ..., + inbox_tab: InboxTab = ..., + inbox_tutorial_viewed: bool = ..., + guild_progress_settings: Sequence[GuildProgress] = ..., + dismissed_contents: Sequence[int] = ..., + last_dismissed_promotion_start_date: datetime = ..., + nitro_basic_modal_dismissed_at: datetime = ..., + soundboard_volume: float = ..., + afk_timeout: int = ..., + always_preview_video: bool = ..., + native_phone_integration_enabled: bool = ..., + stream_notifications_enabled: bool = ..., + diversity_surrogate: Optional[str] = ..., + render_spoilers: SpoilerRenderOptions = ..., + collapsed_emoji_picker_sections: Sequence[Union[EmojiPickerSection, Snowflake]] = ..., + collapsed_sticker_picker_sections: Sequence[Union[StickerPickerSection, Snowflake]] = ..., + animate_emojis: bool = ..., + animate_stickers: StickerAnimationOptions = ..., + explicit_content_filter: UserContentFilter = ..., + show_expression_suggestions: bool = ..., + use_thread_sidebar: bool = ..., + view_image_descriptions: bool = ..., + show_command_suggestions: bool = ..., + inline_attachment_media: bool = ..., + inline_embed_media: bool = ..., + gif_auto_play: bool = ..., + render_embeds: bool = ..., + render_reactions: bool = ..., + enable_tts_command: bool = ..., + message_display_compact: bool = ..., + view_nsfw_guilds: bool = ..., + convert_emoticons: bool = ..., + view_nsfw_commands: bool = ..., + use_legacy_chat_input: bool = ..., + in_app_notifications: bool = ..., + send_stream_notifications: bool = ..., + notification_center_acked_before_id: int = ..., + allow_activity_friend_joins: bool = ..., + allow_activity_voice_channel_joins: bool = ..., + friend_source_flags: FriendSourceFlags = ..., + friend_discovery_flags: FriendDiscoveryFlags = ..., + drops: bool = ..., + non_spam_retraining: Optional[bool] = ..., + restricted_guilds: Sequence[Snowflake] = ..., + default_guilds_restricted: bool = ..., + allow_accessibility_detection: bool = ..., + detect_platform_accounts: bool = ..., + passwordless: bool = ..., + contact_sync_enabled: bool = ..., + activity_restricted_guilds: Sequence[Snowflake] = ..., + default_guilds_activity_restricted: bool = ..., + activity_joining_restricted_guilds: Sequence[Snowflake] = ..., + message_request_restricted_guilds: Sequence[Snowflake] = ..., + default_message_request_restricted: bool = ..., + rtc_panel_show_voice_states: bool = ..., + install_shortcut_desktop: bool = ..., + install_shortcut_start_menu: bool = ..., + disable_games_tab: bool = ..., + status: Status = ..., + custom_activity: Optional[CustomActivity] = ..., + show_current_game: bool = ..., + locale: Locale = ..., + timezone_offset: int = ..., + theme: Theme = ..., + client_theme: Optional[Tuple[int, int, float]] = ..., + disable_mobile_redesign: bool = ..., + developer_mode: bool = ..., + guild_folders: Sequence[GuildFolder] = ..., + guild_positions: Sequence[Snowflake] = ..., + user_audio_settings: Collection[AudioContext] = ..., + stream_audio_settings: Collection[AudioContext] = ..., + home_auto_navigation: bool = ..., + ) -> Self: + ... + + async def edit(self, *, require_version: Union[bool, int] = False, **kwargs: Any) -> Self: + r"""|coro| + + Edits the current user's settings. + + .. note:: + + Settings subsections are not idempotently updated. This means if you change one setting in a subsection\* on an outdated + instance of :class:`UserSettings` then the other settings in that subsection\* will be reset to the value of the instance. + + When operating on the cached user settings (i.e. :attr:`Client.settings`), this should not be an issue. However, if you + are operating on a fetched instance, consider using the ``require_version`` parameter to ensure you don't overwrite + newer settings. + + Any field may be explicitly set to ``MISSING`` to reset it to the default value. + + \* A subsection is a group of settings that are stored in the same top-level protobuf message. + Examples include Privacy, Text and Images, Voice and Video, etc. + + .. note:: + + This method is ratelimited heavily. Updates should be batched together and sent at intervals. + + Infrequent actions do not need a delay. Frequent actions should be delayed by 10 seconds and batched. + Automated actions (such as migrations or frecency updates) should be delayed by 30 seconds and batched. + Daily actions (things that change often and are not meaningful, such as emoji frencency) should be delayed by 1 day and batched. + + Parameters + ---------- + require_version: Union[:class:`bool`, :class:`int`] + Whether to require the current version of the settings to be the same as the provided version. + If this is ``True`` then the current version is used. + \*\*kwargs + The settings to edit. Refer to the :class:`UserSettings` properties for the valid fields. Unknown fields are ignored. + + Raises + ------ + HTTPException + Editing the settings failed. + TypeError + At least one setting is required to edit. + + Returns + ------- + :class:`UserSettings` + The edited settings. Note that this is a new instance and not the same as the cached instance as mentioned above. + """ + # As noted above, entire sections MUST be sent, or they will be reset to default values + # Conversely, we want to omit fields that the user requests to be set to default (by explicitly passing MISSING) + # For this, we then remove fields set to MISSING from the payload in the payload construction at the end + + if not kwargs: + raise TypeError('edit() missing at least 1 required keyword-only argument') + + # Only client_version should ever really be sent + versions = {} + for field in ('data_version', 'client_version', 'server_version'): + if field in kwargs: + versions[field] = kwargs.pop(field) + + inbox = {} + if 'inbox_tab' in kwargs: + inbox['current_tab'] = _ocast(kwargs.pop('inbox_tab'), int) + if 'inbox_tutorial_viewed' in kwargs: + inbox['viewed_tutorial'] = kwargs.pop('inbox_tutorial_viewed') + + guilds = {} + if 'guild_progress_settings' in kwargs and kwargs['guild_progress_settings'] is not MISSING: + guilds['guilds'] = ( + {guild.guild_id: guild.to_dict() for guild in kwargs.pop('guild_progress_settings')} + if kwargs['guild_progress_settings'] is not MISSING + else MISSING + ) + + user_content = {} + if 'dismissed_contents' in kwargs: + contents = kwargs.pop('dismissed_contents') + user_content['dismissed_contents'] = ( + struct.pack(f'>{len(contents)}B', *contents) if contents is not MISSING else MISSING + ) + if 'last_dismissed_promotion_start_date' in kwargs: + user_content['last_dismissed_outbound_promotion_start_date'] = ( + kwargs.pop('last_dismissed_promotion_start_date').isoformat() + if kwargs['last_dismissed_promotion_start_date'] is not MISSING + else MISSING + ) + if 'nitro_basic_modal_dismissed_at' in kwargs: + user_content['premium_tier_0_modal_dismissed_at'] = ( + kwargs.pop('nitro_basic_modal_dismissed_at').isoformat() + if kwargs['nitro_basic_modal_dismissed_at'] is not MISSING + else MISSING + ) + + voice_and_video = {} + if 'soundboard_volume' in kwargs: + voice_and_video['soundboard_settings'] = ( + {'volume': kwargs.pop('soundboard_volume')} if kwargs['soundboard_volume'] is not MISSING else {} + ) + for field in ( + 'afk_timeout', + 'always_preview_video', + 'native_phone_integration_enabled', + 'stream_notifications_enabled', + ): + if field in kwargs: + voice_and_video[field] = kwargs.pop(field) + + text_and_images = {} + if 'diversity_surrogate' in kwargs: + text_and_images['diversity_surrogate'] = ( + kwargs.pop('diversity_surrogate') or '' if kwargs['diversity_surrogate'] is not MISSING else MISSING + ) + if 'render_spoilers' in kwargs: + text_and_images['render_spoilers'] = _ocast(kwargs.pop('render_spoilers'), str) + if 'collapsed_emoji_picker_sections' in kwargs: + text_and_images['emoji_picker_collapsed_sections'] = ( + [str(getattr(x, 'id', x)) for x in kwargs.pop('collapsed_emoji_picker_sections')] + if kwargs['collapsed_emoji_picker_sections'] is not MISSING + else MISSING + ) + if 'collapsed_sticker_picker_sections' in kwargs: + text_and_images['sticker_picker_collapsed_sections'] = ( + [str(getattr(x, 'id', x)) for x in kwargs.pop('collapsed_sticker_picker_sections')] + if kwargs['collapsed_sticker_picker_sections'] is not MISSING + else MISSING + ) + if 'animate_emojis' in kwargs: + text_and_images['animate_emoji'] = kwargs.pop('animate_emojis') + if 'animate_stickers' in kwargs: + text_and_images['animate_stickers'] = _ocast(kwargs.pop('animate_stickers'), int) + if 'explicit_content_filter' in kwargs: + text_and_images['explicit_content_filter'] = _ocast(kwargs.pop('explicit_content_filter'), int) + if 'show_expression_suggestions' in kwargs: + text_and_images['expression_suggestions_enabled'] = kwargs.pop('show_expression_suggestions') + for field in ( + 'use_thread_sidebar', + 'view_image_descriptions', + 'show_command_suggestions', + 'inline_attachment_media', + 'inline_embed_media', + 'gif_auto_play', + 'render_embeds', + 'render_reactions', + 'enable_tts_command', + 'message_display_compact', + 'view_nsfw_guilds', + 'convert_emoticons', + 'view_nsfw_commands', + 'use_legacy_chat_input', + 'use_rich_chat_input', + ): + if field in kwargs: + text_and_images[field] = kwargs.pop(field) + + notifications = {} + if 'in_app_notifications' in kwargs: + notifications['show_in_app_notifications'] = kwargs.pop('in_app_notifications') + if 'send_stream_notifications' in kwargs: + notifications['notify_friends_on_go_live'] = kwargs.pop('send_stream_notifications') + for field in ('notification_center_acked_before_id',): + if field in kwargs: + notifications[field] = kwargs.pop(field) + + privacy = {} + if 'allow_activity_friend_joins' in kwargs: + privacy['allow_activity_party_privacy_friends'] = kwargs.pop('allow_activity_friend_joins') + if 'allow_activity_voice_channel_joins' in kwargs: + privacy['allow_activity_party_privacy_voice_channel'] = kwargs.pop('allow_activity_voice_channel_joins') + if 'friend_source_flags' in kwargs: + privacy['friend_source_flags'] = ( + kwargs.pop('friend_source_flags').value if kwargs['friend_source_flags'] is not MISSING else MISSING + ) + if 'friend_discovery_flags' in kwargs: + privacy['friend_discovery_flags'] = ( + kwargs.pop('friend_discovery_flags').value if kwargs['friend_discovery_flags'] is not MISSING else MISSING + ) + if 'drops' in kwargs: + privacy['drops_opted_out'] = not kwargs.pop('drops') if kwargs['drops'] is not MISSING else MISSING + if 'non_spam_retraining' in kwargs: + privacy['non_spam_retraining_opt_in'] = ( + kwargs.pop('non_spam_retraining') if kwargs['non_spam_retraining'] not in {None, MISSING} else MISSING + ) + for field in ( + 'restricted_guilds', + 'default_guilds_restricted', + 'allow_accessibility_detection', + 'detect_platform_accounts', + 'passwordless', + 'contact_sync_enabled', + 'activity_restricted_guilds', + 'default_guilds_activity_restricted', + 'activity_joining_restricted_guilds', + 'message_request_restricted_guilds', + 'default_message_request_restricted', + ): + if field in kwargs: + if field.endswith('_guilds'): + privacy[field.replace('_guilds', '_guild_ids')] = [g.id for g in kwargs.pop(field)] + else: + privacy[field] = kwargs.pop(field) + + debug = {} + for field in ('rtc_panel_show_voice_states',): + if field in kwargs: + debug[field] = kwargs.pop(field) + + game_library = {} + for field in ('install_shortcut_desktop', 'install_shortcut_start_menu', 'disable_games_tab'): + if field in kwargs: + game_library[field] = kwargs.pop(field) + + status = {} + if 'status' in kwargs: + status['status'] = _ocast(kwargs.pop('status'), str) + if 'custom_activity' in kwargs: + status['custom_status'] = ( + kwargs.pop('custom_activity').to_settings_dict() + if kwargs['custom_activity'] not in {MISSING, None} + else MISSING + ) + for field in ('show_current_game',): + if field in kwargs: + status[field] = kwargs.pop(field) + + localization = {} + if 'locale' in kwargs: + localization['locale'] = _ocast(kwargs.pop('locale'), str) + for field in ('timezone_offset',): + if field in kwargs: + localization[field] = kwargs.pop(field) + + appearance = {} + if 'theme' in kwargs: + appearance['theme'] = _ocast(kwargs.pop('theme'), int) + if 'client_theme' in kwargs: + provided: tuple = kwargs.pop('client_theme') + client_theme_settings = {} if provided is not MISSING else MISSING + if provided: + if provided[0] is not MISSING: + client_theme_settings['primary_color'] = provided[0] + if len(provided) > 1 and provided[1] is not MISSING: + client_theme_settings['background_gradient_preset_id'] = provided[1] + if len(provided) > 2 and provided[2] is not MISSING: + client_theme_settings['background_gradient_angle'] = float(provided[2]) + appearance['client_theme_settings'] = client_theme_settings + if 'disable_mobile_redesign' in kwargs: + appearance['mobile_redesign_disabled'] = kwargs.pop('disable_mobile_redesign') + for field in ('developer_mode',): + if field in kwargs: + appearance[field] = kwargs.pop(field) + + guild_folders = {} + if 'guild_folders' in kwargs: + guild_folders['folders'] = ( + [f.to_dict() for f in kwargs.pop('guild_folders')] if kwargs['guild_folders'] is not MISSING else MISSING + ) + if 'guild_positions' in kwargs: + guild_folders['guild_positions'] = ( + [g.id for g in kwargs.pop('guild_positions')] if kwargs['guild_positions'] is not MISSING else MISSING + ) + + audio_context_settings = {} + if 'user_audio_settings' in kwargs: + audio_context_settings['user'] = ( + {s.id: s.to_dict() for s in kwargs.pop('user_audio_settings')} + if kwargs['user_audio_settings'] is not MISSING + else MISSING + ) + if 'stream_audio_settings' in kwargs: + audio_context_settings['stream'] = ( + {s.id: s.to_dict() for s in kwargs.pop('stream_audio_settings')} + if kwargs['stream_audio_settings'] is not MISSING + else MISSING + ) + + communities = {} + if 'home_auto_navigation' in kwargs: + communities['disable_home_auto_nav'] = ( + not kwargs.pop('home_auto_navigation') if kwargs['home_auto_navigation'] is not MISSING else MISSING + ) + + # Now, we do the actual patching + existing = self.to_dict() + payload = {} + for subsetting in ( + 'versions', + 'inbox', + 'guilds', + 'user_content', + 'voice_and_video', + 'text_and_images', + 'notifications', + 'privacy', + 'debug', + 'game_library', + 'status', + 'localization', + 'appearance', + 'guild_folders', + 'audio_context_settings', + 'communities', + ): + subsetting_dict = locals()[subsetting] + if subsetting_dict: + original = existing.get(subsetting, {}) + original.update(subsetting_dict) + for k, v in dict(original).items(): + if v is MISSING: + del original[k] + payload[subsetting] = original + + state = self._state + require_version = self.data_version if require_version == True else require_version + ret = await state.http.edit_proto_settings(1, self.dict_to_base64(payload), require_version or None) + return UserSettings(state, ret['settings']) + + +class GuildFolder: + """Represents a guild folder. + + All properties have setters to faciliate editing the class for use with :meth:`UserSettings.edit`. + + .. container:: operations + + .. describe:: str(x) + + Returns the folder's name. + + .. describe:: len(x) + + Returns the number of guilds in the folder. + + .. versionadded:: 1.9 + + .. versionchanged:: 2.0 + + Removed various operations and made ``id`` and ``name`` optional. + + .. note:: + + Guilds not in folders *are* actually in folders API wise, with them being the only member. + + These folders do not have an ID or name. + + Attributes + ---------- + id: Optional[:class:`int`] + The ID of the folder. + name: Optional[:class:`str`] + The name of the folder. + """ + + __slots__ = ('_state', 'id', 'name', '_colour', '_guild_ids') + + def __init__( + self, + *, + id: Optional[int] = None, + name: Optional[str] = None, + colour: Optional[Colour] = None, + guilds: Sequence[Snowflake] = MISSING, + ): + self._state: Optional[ConnectionState] = None + self.id: Optional[int] = id + self.name: Optional[str] = name + self._colour: Optional[int] = colour.value if colour else None + self._guild_ids: List[int] = [guild.id for guild in guilds] if guilds else [] + + def __str__(self) -> str: + return self.name or ', '.join(guild.name for guild in [guild for guild in self.guilds if isinstance(guild, Guild)]) + + def __repr__(self) -> str: + return f'' + + def __len__(self) -> int: + return len(self._guild_ids) + + @classmethod + def _from_legacy_settings(cls, *, data: Dict[str, Any], state: ConnectionState) -> Self: + self = cls.__new__(cls) + self._state = state + self.id = _get_as_snowflake(data, 'id') + self.name = data.get('name') + self._colour = data.get('color') + self._guild_ids = [int(guild_id) for guild_id in data['guild_ids']] + return self + + @classmethod + def _from_settings(cls, *, data: Any, state: ConnectionState) -> Self: + """ + message GuildFolder { + repeated fixed64 guild_ids = 1; + optional google.protobuf.Int64Value id = 2; + optional google.protobuf.StringValue name = 3; + optional google.protobuf.UInt64Value color = 4; + } + """ + self = cls.__new__(cls) + self._state = state + self.id = data.id.value + self.name = data.name.value + self._colour = data.color.value if data.HasField('color') else None + self._guild_ids = data.guild_ids + return self + + def _get_guild(self, id, /) -> Union[Guild, Object]: + id = int(id) + return self._state._get_guild(id) or Object(id=id) if self._state else Object(id=id) + + def to_dict(self) -> dict: + ret = {} + if self.id is not None: + ret['id'] = self.id + if self.name is not None: + ret['name'] = self.name + if self._colour is not None: + ret['color'] = self._colour + ret['guild_ids'] = [str(guild_id) for guild_id in self._guild_ids] + return ret + + def copy(self) -> Self: + """Returns a shallow copy of the folder.""" + return self.__class__._from_legacy_settings(data=self.to_dict(), state=self._state) # type: ignore + + def add_guild(self, guild: Snowflake) -> Self: + """Adds a guild to the folder. + + This function returns the class instance to allow for fluent-style + chaining. + + .. versionadded:: 2.0 + + Parameters + ----------- + guild: :class:`abc.Snowflake` + The guild to add to the folder. + """ + self._guild_ids.append(guild.id) + return self + + def insert_guild_at(self, index: int, guild: Snowflake) -> Self: + """Inserts a guild before a specified index to the folder. + + This function returns the class instance to allow for fluent-style + chaining. + + .. versionadded:: 2.0 + + Parameters + ----------- + index: :class:`int` + The index of where to insert the field. + guild: :class:`abc.Snowflake` + The guild to add to the folder. + """ + self._guild_ids.insert(index, guild.id) + return self + + def clear_guilds(self) -> None: + """Removes all guilds from this folder. + + .. versionadded:: 2.0 + """ + self._guild_ids.clear() + + def remove_guild(self, index: int) -> None: + """Removes a guild at a specified index. + + If the index is invalid or out of bounds then the error is + silently swallowed. + + .. note:: + + When deleting a field by index, the index of the other fields + shift to fill the gap just like a regular list. + + .. versionadded:: 2.0 + + Parameters + ----------- + index: :class:`int` + The index of the field to remove. + """ + try: + del self._guild_ids[index] + except IndexError: + pass + + def set_guild_at(self, index: int, guild: Snowflake) -> Self: + """Modifies a guild to the guild object. + + The index must point to a valid pre-existing guild. + + This function returns the class instance to allow for fluent-style + chaining. + + .. versionadded:: 2.0 + + Parameters + ----------- + index: :class:`int` + The index of the field to modify. + guild: :class:`abc.Snowflake` + The guild to add to the folder. + + Raises + ------- + IndexError + An invalid index was provided. + """ + self._guild_ids[index] = guild.id + + try: + self._guild_ids[index] = guild.id + except (TypeError, IndexError): + raise IndexError('field index out of range') + return self + + @property + def guilds(self) -> List[Union[Guild, Object]]: + """List[Union[:class:`Guild`, :class:`Object`]]: The guilds in the folder. Always :class:`Object` if state is not attached.""" + return [self._get_guild(guild_id) for guild_id in self._guild_ids] + + @guilds.setter + def guilds(self, value: Sequence[Snowflake]) -> None: + self._guild_ids = [guild.id for guild in value] + + @property + def colour(self) -> Optional[Colour]: + """Optional[:class:`Colour`]: The colour code of the folder. There is an alias for this named :attr:`colour`.""" + return Colour(self._colour) if self._colour is not None else None + + @colour.setter + def colour(self, value: Optional[Union[int, Colour]]) -> None: + if value is None: + self._colour = None + elif isinstance(value, Colour): + self._colour = value.value + elif isinstance(value, int): + self._colour = value + else: + raise TypeError(f'Expected discord.Colour, int, or None but received {value.__class__.__name__} instead.') + + @property + def color(self) -> Optional[Colour]: + """Optional[:class:`Colour`]: The colour code of the folder. There is an alias for this named :attr:`colour`.""" + return self.colour + + @color.setter + def color(self, value: Optional[Union[int, Colour]]) -> None: + self.colour = value + + +class GuildProgress: + """Represents a guild's settings revolving around upsells, promotions, and feature progress. + + All properties have setters to faciliate editing the class for use with :meth:`UserSettings.edit`. + + .. versionadded:: 2.0 + + Attributes + ---------- + guild_id: :class:`int` + The ID of the guild. + recents_dismissed_at: Optional[:class:`datetime.datetime`] + When the guild recents were last dismissed. + """ + + __slots__ = ( + 'guild_id', + '_hub_progress', + '_onboarding_progress', + 'recents_dismissed_at', + '_dismissed_contents', + '_collapsed_channel_ids', + '_state', + ) + + def __init__( + self, + guild_id: int, + *, + hub_progress: HubProgressFlags, + onboarding_progress: OnboardingProgressFlags, + recents_dismissed_at: Optional[datetime] = None, + dismissed_contents: Sequence[int] = MISSING, + collapsed_channels: List[Snowflake] = MISSING, + ) -> None: + self._state: Optional[ConnectionState] = None + self.guild_id = guild_id + self._hub_progress = hub_progress.value + self._onboarding_progress = onboarding_progress.value + self.recents_dismissed_at: Optional[datetime] = recents_dismissed_at + self._dismissed_contents = self._pack_dismissed_contents(dismissed_contents or []) + self._collapsed_channel_ids = [channel.id for channel in collapsed_channels] or [] + + @classmethod + def _from_settings(cls, guild_id: int, *, data: Any, state: ConnectionState) -> Self: + """ + message ChannelSettings { + bool collapsed_in_inbox = 1; + } + + message GuildSettings { + map channels = 1; + uint32 hub_progress = 2; + uint32 guild_onboarding_progress = 3; + optional google.protobuf.Timestamp guild_recents_dismissed_at = 4; + bytes dismissed_guild_content = 5; + } + + message AllGuildSettings { + map guilds = 1; + } + """ + self = cls.__new__(cls) + self._state = state + self.guild_id = guild_id + self._hub_progress = data.hub_progress + self._onboarding_progress = data.guild_onboarding_progress + self.recents_dismissed_at = ( + data.guild_recents_dismissed_at.ToDatetime(tzinfo=timezone.utc) + if data.HasField('guild_recents_dismissed_at') + else None + ) + self._dismissed_contents = data.dismissed_guild_content + self._collapsed_channel_ids = [ + channel_id for channel_id, settings in data.channels.items() if settings.collapsed_in_inbox + ] + return self + + def _get_channel(self, id: int, /) -> Union[GuildChannel, Object]: + id = int(id) + return self.guild.get_channel(id) or Object(id=id) if self.guild is not None else Object(id=id) + + def to_dict(self) -> Dict[str, Any]: + data = { + 'hub_progress': self._hub_progress, + 'guild_onboarding_progress': self._onboarding_progress, + 'dismissed_guild_content': self._dismissed_contents, + 'channels': {id: {'collapsed_in_inbox': True} for id in self._collapsed_channel_ids}, + } + if self.recents_dismissed_at is not None: + data['guild_recents_dismissed_at'] = self.recents_dismissed_at.isoformat() + return data + + def copy(self) -> Self: + """Returns a shallow copy of the progress settings.""" + cls = self.__class__(self.guild_id, hub_progress=self.hub_progress, onboarding_progress=self.onboarding_progress, recents_dismissed_at=self.recents_dismissed_at, dismissed_contents=self.dismissed_contents, collapsed_channels=self.collapsed_channels) # type: ignore + cls._state = self._state + return cls + + @property + def guild(self) -> Optional[Guild]: + """Optional[:class:`Guild`]: The guild this progress belongs to. ``None`` if state is not attached.""" + return self._state._get_guild(self.guild_id) if self._state is not None else None + + @property + def hub_progress(self) -> HubProgressFlags: + """:class:`HubProgressFlags`: The hub's usage and feature progress.""" + return HubProgressFlags._from_value(self._hub_progress) + + @hub_progress.setter + def hub_progress(self, value: HubProgressFlags) -> None: + self._hub_progress = value.value + + @property + def onboarding_progress(self) -> OnboardingProgressFlags: + """:class:`OnboardingProgressFlags`: The guild's onboarding usage and feature progress.""" + return OnboardingProgressFlags._from_value(self._onboarding_progress) + + @onboarding_progress.setter + def onboarding_progress(self, value: OnboardingProgressFlags) -> None: + self._onboarding_progress = value.value + + @staticmethod + def _pack_dismissed_contents(contents: Sequence[int]) -> bytes: + return struct.pack(f'>{len(contents)}B', *contents) + + @property + def dismissed_contents(self) -> Tuple[int, ...]: + """Tuple[:class:`int`]: A list of enum values representing per-guild dismissable content in the app. + + .. note:: + + For now, this just returns the raw values without converting to a proper enum, + as the enum values change too often to be viably maintained. + """ + contents = self._dismissed_contents + return struct.unpack(f'>{len(contents)}B', contents) + + @dismissed_contents.setter + def dismissed_contents(self, value: Sequence[int]) -> None: + self._dismissed_contents = self._pack_dismissed_contents(value) + + @property + def collapsed_channels(self) -> List[Union[GuildChannel, Object]]: + """List[Union[:class:`abc.GuildChannel`, :class:`Object`]]: A list of guild channels that are collapsed in the inbox. Always :class:`Object` if state is not attached.""" + return list(map(self._get_channel, self._collapsed_channel_ids)) + + @collapsed_channels.setter + def collapsed_channels(self, value: Sequence[Snowflake]) -> None: + self._collapsed_channel_ids = [channel.id for channel in value] + + +class AudioContext: + """Represents saved audio settings for a user or stream. + + All properties have setters to faciliate editing the class for use with :meth:`UserSettings.edit`. + + .. versionadded:: 2.0 + + Attributes + ---------- + user_id: :class:`int` + The ID of the user. + muted: :class:`bool` + Whether the user or stream is muted. + volume: :class:`float` + The volume of the user or stream (0-100). + modified_at: :class:`datetime.datetime` + The time the settings were last modified. + """ + + __slots__ = ('_state', 'user_id', 'muted', 'volume', 'modified_at') + + def __init__(self, user_id: int, *, muted: bool = False, volume: float) -> None: + self._state: Optional[ConnectionState] = None + self.user_id = user_id + self.muted = muted + self.volume = volume + self.modified_at = utcnow() + + @classmethod + def _from_settings(cls, user_id: int, *, data: Any, state: ConnectionState) -> Self: + """ + message AudioContextSetting { + bool muted = 1; + float volume = 2; + fixed64 modified_at = 3; + } + """ + self = cls.__new__(cls) + self._state = state + self.user_id = user_id + self.muted = data.muted + self.volume = data.volume + self.modified_at = parse_timestamp(data.modified_at) + return self + + def to_dict(self) -> Dict[str, Any]: + """Converts the object to a dictionary.""" + return { + 'user_id': self.user_id, + 'muted': self.muted, + 'volume': self.volume, + 'modified_at': self.modified_at.isoformat(), + } + + def copy(self) -> Self: + """Returns a shallow copy of the audio context.""" + cls = self.__class__(self.user_id, muted=self.muted, volume=self.volume) + cls.modified_at = self.modified_at + cls._state = self._state + return cls + + @property + def user(self) -> Optional[User]: + """Optional[:class:`User`]: The user the settings are for. ``None`` if state is not attached.""" + return self._state.get_user(self.user_id) if self._state is not None else None + + +class LegacyUserSettings: + """Represents the legacy Discord client settings. + .. versionadded:: 1.9 + .. deprecated:: 2.0 + + .. note:: + + Discord has migrated user settings to a new protocol buffer format. + While these legacy settings still exist, they are no longer sent to newer clients (so they will have to be fetched). + + The new settings are available in :class:`UserSettings`, and this class has been deprecated and renamed to :class:`LegacyUserSettings`. + All options in this class are available in the new format, and changes are reflected in both. + Attributes ---------- afk_timeout: :class:`int` How long (in seconds) the user needs to be AFK until Discord - sends push notifications to your mobile device. + sends push notifications to mobile devices (30-600). allow_accessibility_detection: :class:`bool` - Whether or not to allow Discord to track screen reader usage. + Whether to allow Discord to track screen reader usage. animate_emojis: :class:`bool` - Whether or not to animate emojis in the chat. + Whether to animate emojis in the chat. contact_sync_enabled: :class:`bool` - Whether or not to enable the contact sync on Discord mobile. + Whether to enable the contact sync on Discord mobile. convert_emoticons: :class:`bool` - Whether or not to automatically convert emoticons into emojis. - e.g. :-) -> 😃 + Whether to automatically convert emoticons into emojis (e.g. :) -> 😃). default_guilds_restricted: :class:`bool` - Whether or not to automatically disable DMs between you and + Whether to automatically disable DMs between you and members of new guilds you join. detect_platform_accounts: :class:`bool` - Whether or not to automatically detect accounts from services + Whether to automatically detect accounts from services like Steam and Blizzard when you open the Discord client. developer_mode: :class:`bool` - Whether or not to enable developer mode. + Whether to enable developer mode. disable_games_tab: :class:`bool` - Whether or not to disable the showing of the Games tab. + Whether to disable the showing of the Games tab. enable_tts_command: :class:`bool` - Whether or not to allow tts messages to be played/sent. + Whether to allow TTS messages to be played/sent. gif_auto_play: :class:`bool` - Whether or not to automatically play gifs that are in the chat. + Whether to automatically play GIFs that are in the chat. inline_attachment_media: :class:`bool` - Whether or not to display attachments when they are uploaded in chat. + Whether to display attachments when they are uploaded in chat. inline_embed_media: :class:`bool` - Whether or not to display videos and images from links posted in chat. + Whether to display videos and images from links posted in chat. message_display_compact: :class:`bool` - Whether or not to use the compact Discord display mode. - native_phone_integration_enabled: :class:`bool` - Whether or not to enable the new Discord mobile phone number friend + Whether to use the compact Discord display mode. + native_phone_integration_enabled: :class:`bool` + Whether to enable the new Discord mobile phone number friend requesting features. render_embeds: :class:`bool` - Whether or not to render embeds that are sent in the chat. + Whether to render embeds that are sent in the chat. render_reactions: :class:`bool` - Whether or not to render reactions that are added to messages. + Whether to render reactions that are added to messages. show_current_game: :class:`bool` - Whether or not to display the game that you are currently playing. + Whether to display the game that you are currently playing. stream_notifications_enabled: :class:`bool` Whether stream notifications for friends will be received. timezone_offset: :class:`int` - The timezone offset to use. + The timezone offset from UTC to use (in minutes). view_nsfw_commands: :class:`bool` - Whether or not to show NSFW application commands. + Whether to show NSFW application commands in DMs. .. versionadded:: 2.0 view_nsfw_guilds: :class:`bool` - Whether or not to show NSFW guilds on iOS. + Whether to show NSFW guilds on iOS. """ if TYPE_CHECKING: # Fuck me @@ -149,10 +1672,11 @@ class UserSettings: self._update(data) def __repr__(self) -> str: - return '' + return '' - def _get_guild(self, id: int) -> Guild: - return self._state._get_guild(int(id)) or Object(id=int(id)) # type: ignore # Lying for better developer UX + def _get_guild(self, id: int, /) -> Union[Guild, Object]: + id = int(id) + return self._state._get_guild(id) or Object(id=id) def _update(self, data: Dict[str, Any]) -> None: RAW_VALUES = { @@ -186,7 +1710,7 @@ class UserSettings: else: setattr(self, '_' + key, value) - async def edit(self, **kwargs) -> UserSettings: + async def edit(self, **kwargs) -> Self: """|coro| Edits the client user's settings. @@ -194,85 +1718,90 @@ class UserSettings: .. versionchanged:: 2.0 The edit is no longer in-place, instead the newly edited settings are returned. + .. deprecated:: 2.0 + Parameters ---------- - activity_restricted_guilds: List[:class:`abc.Snowflake`] + activity_restricted_guilds: List[:class:`~discord.abc.Snowflake`] A list of guilds that your current activity will not be shown in. .. versionadded:: 2.0 - activity_joining_restricted_guilds: List[:class:`abc.Snowflake`] + activity_joining_restricted_guilds: List[:class:`~discord.abc.Snowflake`] A list of guilds that will not be able to join your current activity. .. versionadded:: 2.0 afk_timeout: :class:`int` How long (in seconds) the user needs to be AFK until Discord - sends push notifications to your mobile device. + sends push notifications to mobile device (30-600). allow_accessibility_detection: :class:`bool` - Whether or not to allow Discord to track screen reader usage. + Whether to allow Discord to track screen reader usage. animate_emojis: :class:`bool` - Whether or not to animate emojis in the chat. - animate_stickers: :class:`StickerAnimationOptions` - Whether or not to animate stickers in the chat. + Whether to animate emojis in the chat. + animate_stickers: :class:`.StickerAnimationOptions` + Whether to animate stickers in the chat. contact_sync_enabled: :class:`bool` - Whether or not to enable the contact sync on Discord mobile. + Whether to enable the contact sync on Discord mobile. convert_emoticons: :class:`bool` - Whether or not to automatically convert emoticons into emojis. - e.g. :-) -> 😃 + Whether to automatically convert emoticons into emojis (e.g. :) -> 😃). default_guilds_restricted: :class:`bool` - Whether or not to automatically disable DMs between you and + Whether to automatically disable DMs between you and members of new guilds you join. detect_platform_accounts: :class:`bool` - Whether or not to automatically detect accounts from services + Whether to automatically detect accounts from services like Steam and Blizzard when you open the Discord client. developer_mode: :class:`bool` - Whether or not to enable developer mode. + Whether to enable developer mode. disable_games_tab: :class:`bool` - Whether or not to disable the showing of the Games tab. + Whether to disable the showing of the Games tab. enable_tts_command: :class:`bool` - Whether or not to allow tts messages to be played/sent. - explicit_content_filter: :class:`UserContentFilter` + Whether to allow TTS messages to be played/sent. + explicit_content_filter: :class:`.UserContentFilter` The filter for explicit content in all messages. - friend_source_flags: :class:`FriendFlags` + friend_source_flags: :class:`.FriendSourceFlags` Who can add you as a friend. + friend_discovery_flags: :class:`.FriendDiscoveryFlags` + How you get recommended friends. gif_auto_play: :class:`bool` - Whether or not to automatically play gifs that are in the chat. - guild_positions: List[:class:`abc.Snowflake`] + Whether to automatically play GIFs that are in the chat. + guild_positions: List[:class:`~discord.abc.Snowflake`] A list of guilds in order of the guild/guild icons that are on the left hand side of the UI. inline_attachment_media: :class:`bool` - Whether or not to display attachments when they are uploaded in chat. + Whether to display attachments when they are uploaded in chat. inline_embed_media: :class:`bool` - Whether or not to display videos and images from links posted in chat. - locale: :class:`Locale` + Whether to display videos and images from links posted in chat. + locale: :class:`.Locale` The :rfc:`3066` language identifier of the locale to use for the language of the Discord client. message_display_compact: :class:`bool` - Whether or not to use the compact Discord display mode. + Whether to use the compact Discord display mode. native_phone_integration_enabled: :class:`bool` - Whether or not to enable the new Discord mobile phone number friend + Whether to enable the new Discord mobile phone number friend requesting features. passwordless: :class:`bool` - Whether the account is passwordless. + Whether to enable passwordless login. render_embeds: :class:`bool` - Whether or not to render embeds that are sent in the chat. + Whether to render embeds that are sent in the chat. render_reactions: :class:`bool` - Whether or not to render reactions that are added to messages. - restricted_guilds: List[:class:`abc.Snowflake`] + Whether to render reactions that are added to messages. + restricted_guilds: List[:class:`~discord.abc.Snowflake`] A list of guilds that you will not receive DMs from. show_current_game: :class:`bool` - Whether or not to display the game that you are currently playing. + Whether to display the game that you are currently playing. stream_notifications_enabled: :class:`bool` Whether stream notifications for friends will be received. - theme: :class:`Theme` - The theme of the Discord UI. + theme: :class:`.Theme` + The overall theme of the Discord UI. timezone_offset: :class:`int` The timezone offset to use. view_nsfw_commands: :class:`bool` - Whether or not to show NSFW application commands. + Whether to show NSFW application commands in DMs. .. versionadded:: 2.0 view_nsfw_guilds: :class:`bool` - Whether or not to show NSFW guilds on iOS. + Whether to show NSFW guilds on iOS. + + .. versionadded:: 2.0 Raises ------- @@ -284,67 +1813,19 @@ class UserSettings: :class:`.UserSettings` The client user's updated settings. """ - return await self._state.user.edit_settings(**kwargs) # type: ignore - - async def email_settings(self) -> EmailSettings: - """|coro| - - Retrieves your :class:`EmailSettings`. - - .. versionadded:: 2.0 - - Raises - ------- - HTTPException - Getting the email settings failed. - - Returns - ------- - :class:`.EmailSettings` - The email settings. - """ - data = await self._state.http.get_email_settings() - return EmailSettings(data=data, state=self._state) - - async def fetch_tracking_settings(self) -> TrackingSettings: - """|coro| - - Retrieves your :class:`TrackingSettings`. - - .. versionadded:: 2.0 - - Raises - ------ - HTTPException - Retrieving the tracking settings failed. - - Returns - ------- - :class:`TrackingSettings` - The tracking settings. - """ - data = await self._state.http.get_tracking() - return TrackingSettings(state=self._state, data=data) + return await self._state.client.edit_legacy_settings(**kwargs) @property - def tracking_settings(self) -> Optional[TrackingSettings]: - """Optional[:class:`TrackingSettings`]: Returns your tracking settings if available. - - .. versionadded:: 2.0 - """ - return self._state.consents - - @property - def activity_restricted_guilds(self) -> List[Guild]: - """List[:class:`abc.Snowflake`]: A list of guilds that your current activity will not be shown in. + def activity_restricted_guilds(self) -> List[Union[Guild, Object]]: + """List[Union[:class:`Guild`, :class:`Object`]]: A list of guilds that your current activity will not be shown in. .. versionadded:: 2.0 """ return list(map(self._get_guild, getattr(self, '_activity_restricted_guild_ids', []))) @property - def activity_joining_restricted_guilds(self) -> List[Guild]: - """List[:class:`abc.Snowflake`]: A list of guilds that will not be able to join your current activity. + def activity_joining_restricted_guilds(self) -> List[Union[Guild, Object]]: + """List[Union[:class:`Guild`, :class:`Object`]]: A list of guilds that will not be able to join your current activity. .. versionadded:: 2.0 """ @@ -352,13 +1833,13 @@ class UserSettings: @property def animate_stickers(self) -> StickerAnimationOptions: - """:class:`StickerAnimationOptions`: Whether or not to animate stickers in the chat.""" + """:class:`StickerAnimationOptions`: Whether to animate stickers in the chat.""" return try_enum(StickerAnimationOptions, getattr(self, '_animate_stickers', 0)) @property def custom_activity(self) -> Optional[CustomActivity]: """Optional[:class:`CustomActivity`]: The set custom activity.""" - return create_settings_activity(data=getattr(self, '_custom_status', None), state=self._state) + return CustomActivity._from_legacy_settings(data=getattr(self, '_custom_status', None), state=self._state) @property def explicit_content_filter(self) -> UserContentFilter: @@ -366,19 +1847,26 @@ class UserSettings: return try_enum(UserContentFilter, getattr(self, '_explicit_content_filter', 0)) @property - def friend_source_flags(self) -> FriendFlags: - """:class:`FriendFlags`: Who can add you as a friend.""" - return FriendFlags._from_dict(getattr(self, '_friend_source_flags', {'all': True})) + def friend_source_flags(self) -> FriendSourceFlags: + """:class:`FriendSourceFlags`: Who can add you as a friend.""" + return FriendSourceFlags._from_dict(getattr(self, '_friend_source_flags', {'all': True})) + + @property + def friend_discovery_flags(self) -> FriendDiscoveryFlags: + """:class:`FriendDiscoveryFlags`: How you get recommended friends.""" + return FriendDiscoveryFlags._from_value(getattr(self, '_friend_discovery_flags', 0)) @property def guild_folders(self) -> List[GuildFolder]: """List[:class:`GuildFolder`]: A list of guild folders.""" state = self._state - return [GuildFolder(data=folder, state=state) for folder in getattr(self, '_guild_folders', [])] + return [ + GuildFolder._from_legacy_settings(data=folder, state=state) for folder in getattr(self, '_guild_folders', []) + ] @property - def guild_positions(self) -> List[Guild]: - """List[:class:`Guild`]: A list of guilds in order of the guild/guild icons that are on the left hand side of the UI.""" + def guild_positions(self) -> List[Union[Guild, Object]]: + """List[Union[:class:`Guild`, :class:`Object`]]: A list of guilds in order of the guild/guild icons that are on the left hand side of the UI.""" return list(map(self._get_guild, getattr(self, '_guild_positions', []))) @property @@ -393,13 +1881,13 @@ class UserSettings: @property def passwordless(self) -> bool: - """:class:`bool`: Whether the account is passwordless.""" + """:class:`bool`: Whether to enable passwordless login.""" return getattr(self, '_passwordless', False) @property - def restricted_guilds(self) -> List[Guild]: - """List[:class:`Guild`]: A list of guilds that you will not receive DMs from.""" - return list(filter(None, map(self._get_guild, getattr(self, '_restricted_guilds', [])))) + def restricted_guilds(self) -> List[Union[Guild, Object]]: + """List[Union[:class:`Guild`, :class:`Object`]]: A list of guilds that you will not receive DMs from.""" + return list(map(self._get_guild, getattr(self, '_restricted_guilds', []))) @property def status(self) -> Status: @@ -408,7 +1896,7 @@ class UserSettings: @property def theme(self) -> Theme: - """:class:`Theme`: The theme of the Discord UI.""" + """:class:`Theme`: The overall theme of the Discord UI.""" return try_enum(Theme, getattr(self, '_theme', 'dark')) # Sane default :) @@ -510,7 +1998,7 @@ class ChannelSettings: @property def channel(self) -> Union[GuildChannel, PrivateChannel]: - """Union[:class:`.abc.GuildChannel`, :class:`.abc.PrivateChannel`]: Returns the channel these settings are for.""" + """Union[:class:`abc.GuildChannel`, :class:`abc.PrivateChannel`]: Returns the channel these settings are for.""" guild = self._state._get_guild(self._guild_id) if guild: channel = guild.get_channel(self._channel_id) diff --git a/discord/state.py b/discord/state.py index 716c7bf1c..09cf0913b 100644 --- a/discord/state.py +++ b/discord/state.py @@ -48,6 +48,8 @@ import weakref import inspect from math import ceil +from discord_protos import UserSettingsType + from .errors import ClientException, InvalidData, NotFound from .guild import CommandCounts, Guild from .activity import BaseActivity, create_activity, Session @@ -1020,7 +1022,7 @@ class ConnectionState: # Extras self.analytics_token = data.get('analytics_token') self.preferred_regions = data.get('geo_ordered_rtc_regions', ['us-central']) - self.settings = UserSettings(data=data.get('user_settings', {}), state=self) + self.settings = UserSettings(self, data.get('user_settings_proto', '')) self.guild_settings = { utils._get_as_snowflake(entry, 'guild_id'): GuildSettings(data=entry, state=self) for entry in data.get('user_guild_settings', {}).get('entries', []) @@ -1229,12 +1231,28 @@ class ConnectionState: if self.user: self.user._full_update(data) - def parse_user_settings_update(self, data) -> None: - new_settings = self.settings - old_settings = copy.copy(new_settings) - new_settings._update(data) # type: ignore - self.dispatch('settings_update', old_settings, new_settings) - self.dispatch('internal_settings_update', old_settings, new_settings) + # def parse_user_settings_update(self, data) -> None: + # new_settings = self.settings + # old_settings = copy.copy(new_settings) + # new_settings._update(data) + # self.dispatch('settings_update', old_settings, new_settings) + # self.dispatch('internal_settings_update', old_settings, new_settings) + + def parse_user_settings_proto_update(self, data: gw.ProtoSettingsEvent): + type = UserSettingsType(data['settings']['type']) + if type == UserSettingsType.preloaded_user_settings: + settings = self.settings + if settings: + old_settings = UserSettings._copy(settings) + settings._update(data['settings']['proto'], partial=data.get('partial', False)) + self.dispatch('settings_update', old_settings, settings) + self.dispatch('internal_settings_update', old_settings, settings) + elif type == UserSettingsType.frecency_user_settings: + ... + elif type == UserSettingsType.test_settings: + _log.debug('Received test settings proto update. Data: %s', data['settings']['proto']) + else: + _log.warning('Unknown user settings proto type: %s', type.value) def parse_user_guild_settings_update(self, data) -> None: guild_id = utils._get_as_snowflake(data, 'guild_id') diff --git a/discord/types/gateway.py b/discord/types/gateway.py index 686244660..b0750f2dc 100644 --- a/discord/types/gateway.py +++ b/discord/types/gateway.py @@ -39,7 +39,7 @@ from .message import Message from .sticker import GuildSticker from .appinfo import BaseAchievement, PartialApplication from .guild import Guild, UnavailableGuild, SupplementalGuild -from .user import Connection, User, PartialUser, Relationship, RelationshipType +from .user import Connection, User, PartialUser, ProtoSettingsType, Relationship, RelationshipType from .threads import Thread, ThreadMember from .scheduled_event import GuildScheduledEvent from .channel import DMChannel, GroupDMChannel @@ -449,3 +449,13 @@ class RelationshipEvent(TypedDict): id: Snowflake type: RelationshipType nickname: Optional[str] + + +class ProtoSettings(TypedDict): + proto: str + type: ProtoSettingsType + + +class ProtoSettingsEvent(TypedDict): + settings: ProtoSettings + partial: bool diff --git a/discord/types/user.py b/discord/types/user.py index 4b849dde0..c10b9c265 100644 --- a/discord/types/user.py +++ b/discord/types/user.py @@ -121,3 +121,10 @@ class Relationship(TypedDict): user: PartialUser nickname: Optional[str] since: NotRequired[str] + + +class ProtoSettings(TypedDict): + settings: str + + +ProtoSettingsType = Literal[1, 2, 3] diff --git a/discord/user.py b/discord/user.py index 0ed0aa394..4c449f1b5 100644 --- a/discord/user.py +++ b/discord/user.py @@ -40,7 +40,6 @@ from .enums import ( from .errors import ClientException, NotFound from .flags import PublicUserFlags, PrivateUserFlags, PremiumUsageFlags, PurchasedFlags from .relationship import Relationship -from .settings import UserSettings from .utils import _bytes_to_base64_data, _get_as_snowflake, copy_doc, snowflake_time, MISSING if TYPE_CHECKING: @@ -620,21 +619,13 @@ class ClientUser(BaseUser): @property def locale(self) -> Locale: """:class:`Locale`: The IETF language tag used to identify the language the user is using.""" - return self.settings.locale if self.settings else try_enum(Locale, self._locale) + return self._state.settings.locale if self._state.settings else try_enum(Locale, self._locale) @property def premium(self) -> bool: """Indicates if the user is a premium user (i.e. has Discord Nitro).""" return self.premium_type is not None - @property - def settings(self) -> Optional[UserSettings]: - """Optional[:class:`UserSettings`]: Returns the user's settings. - - .. versionadded:: 1.9 - """ - return self._state.settings - @property def flags(self) -> PrivateUserFlags: """:class:`PrivateUserFlags`: Returns the user's flags (including private). @@ -823,86 +814,6 @@ class ClientUser(BaseUser): return ClientUser(state=self._state, data=data) - async def fetch_settings(self) -> UserSettings: - """|coro| - - Retrieves your settings. - - .. note:: - - This method is an API call. For general usage, consider :attr:`settings` instead. - - Raises - ------- - HTTPException - Retrieving your settings failed. - - Returns - -------- - :class:`UserSettings` - The current settings for your account. - """ - data = await self._state.http.get_settings() - return UserSettings(data=data, state=self._state) - - @copy_doc(UserSettings.edit) - async def edit_settings(self, **kwargs) -> UserSettings: # TODO: I really wish I didn't have to do this... - payload = {} - - content_filter = kwargs.pop('explicit_content_filter', None) - if content_filter: - payload['explicit_content_filter'] = content_filter.value - - animate_stickers = kwargs.pop('animate_stickers', None) - if animate_stickers: - payload['animate_stickers'] = animate_stickers.value - - friend_flags = kwargs.pop('friend_source_flags', None) - if friend_flags: - payload['friend_source_flags'] = friend_flags.to_dict() - - guild_positions = kwargs.pop('guild_positions', None) - if guild_positions: - guild_positions = [str(x.id) for x in guild_positions] - payload['guild_positions'] = guild_positions - - restricted_guilds = kwargs.pop('restricted_guilds', None) - if restricted_guilds: - restricted_guilds = [str(x.id) for x in restricted_guilds] - payload['restricted_guilds'] = restricted_guilds - - activity_restricted_guilds = kwargs.pop('activity_restricted_guilds', None) - if activity_restricted_guilds: - activity_restricted_guilds = [str(x.id) for x in activity_restricted_guilds] - payload['activity_restricted_guild_ids'] = activity_restricted_guilds - - activity_joining_restricted_guilds = kwargs.pop('activity_joining_restricted_guilds', None) - if activity_joining_restricted_guilds: - activity_joining_restricted_guilds = [str(x.id) for x in activity_joining_restricted_guilds] - payload['activity_joining_restricted_guild_ids'] = activity_joining_restricted_guilds - - status = kwargs.pop('status', None) - if status: - payload['status'] = status.value - - custom_activity = kwargs.pop('custom_activity', MISSING) - if custom_activity is not MISSING: - payload['custom_status'] = custom_activity and custom_activity.to_settings_dict() - - theme = kwargs.pop('theme', None) - if theme: - payload['theme'] = theme.value - - locale = kwargs.pop('locale', None) - if locale: - payload['locale'] = str(locale) - - payload.update(kwargs) - - state = self._state - data = await state.http.edit_settings(**payload) - return UserSettings(data=data, state=self._state) - class User(BaseUser, discord.abc.Connectable, discord.abc.Messageable): """Represents a Discord user. diff --git a/discord/utils.py b/discord/utils.py index 2de4122f4..52ff49a49 100644 --- a/discord/utils.py +++ b/discord/utils.py @@ -618,6 +618,12 @@ def _get_as_snowflake(data: Any, key: str) -> Optional[int]: return value and int(value) +def _ocast(value: Any, type: Any): + if value is MISSING: + return MISSING + return type(value) + + def _get_mime_type_for_image(data: bytes, with_video: bool = False, fallback: bool = False) -> str: if data.startswith(b'\x89\x50\x4E\x47\x0D\x0A\x1A\x0A'): return 'image/png' diff --git a/docs/api.rst b/docs/api.rst index 87ce7ce40..74b6f4cf1 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -3350,31 +3350,6 @@ of :class:`enum.Enum`. Don't scan any direct messages. -.. class:: FriendFlags - - Represents the options found in ``Settings > Privacy & Safety > Who Can Add You As A Friend`` - in the Discord client. - - .. attribute:: noone - - This allows no-one to add you as a friend. - - .. attribute:: mutual_guilds - - This allows guild members to add you as a friend. - - .. attribute:: mutual_friends - - This allows friends of friends to add you as a friend. - - .. attribute:: guild_and_friends - - This is a superset of :attr:`mutual_guilds` and :attr:`mutual_friends`. - - .. attribute:: everyone - - This allows everyone to add you as a friend. - .. class:: PremiumType Represents the user's Discord Nitro subscription type. @@ -4432,6 +4407,114 @@ of :class:`enum.Enum`. Never animate stickers. +.. class:: SpoilerRenderOptions + + Represents the options found in ``Settings > Text and Images > Show Spoiler Content`` in the Discord client. + + .. versionadded:: 2.0 + + .. attribute:: always + + Always render spoilers. + + .. attribute:: on_click + + Render spoilers when they are interacted with. + + .. attribute:: if_moderator + + Render spoilers if the user is a moderator. + +.. class:: InboxTab + + Represents the tabs found in the Discord inbox. + + .. versionadded:: 2.0 + + .. attribute:: default + + No inbox tab has been yet selected. + + .. attribute:: mentions + + The mentions tab. + + .. attribute:: unreads + + The unreads tab. + + .. attribute:: todos + + The todos tab. + + .. attribute:: for_you + + The for you tab. + +.. class:: EmojiPickerSection + + Represents the sections found in the Discord emoji picker. Any guild is also a valid section. + + .. versionadded:: 2.0 + + .. attribute:: favorite + + The favorite section. + + .. attribute:: top_emojis + + The top emojis section. + + .. attribute:: recent + + The recents section. + + .. attribute:: people + + The people emojis section. + + .. attribute:: nature + + The nature emojis section. + + .. attribute:: food + + The food emojis section. + + .. attribute:: activity + + The activity emojis section. + + .. attribute:: travel + + The travel emojis section. + + .. attribute:: objects + + The objects emojis section. + + .. attribute:: symbols + + The symbols emojis section. + + .. attribute:: flags + + The flags emojis section. + +.. class:: StickerPickerSection + + Represents the sections found in the Discord sticker picker. Any guild and sticker pack SKU is also a valid section. + + .. versionadded:: 2.0 + + .. attribute:: favorite + + The favorite section. + + .. attribute:: recent + + The recents section. + .. class:: Theme Represents the theme synced across all Discord clients. @@ -5818,6 +5901,11 @@ Settings .. autoclass:: UserSettings() :members: +.. attributetable:: LegacyUserSettings + +.. autoclass:: LegacyUserSettings() + :members: + .. attributetable:: GuildSettings .. autoclass:: GuildSettings() @@ -5843,6 +5931,16 @@ Settings .. autoclass:: GuildFolder() :members: +.. attributetable:: GuildProgress + +.. autoclass:: GuildProgress() + :members: + +.. attributetable:: AudioContext + +.. autoclass:: AudioContext() + :members: + .. attributetable:: MuteConfig .. autoclass:: MuteConfig() @@ -6800,16 +6898,36 @@ Flags .. autoclass:: SystemChannelFlags() :members: +.. attributetable:: FriendSourceFlags + +.. autoclass:: FriendSourceFlags() + :members: + +.. attributetable:: FriendDiscoveryFlags + +.. autoclass:: FriendDiscoveryFlags() + :members: + .. attributetable:: GiftFlags .. autoclass:: GiftFlags() :members: +.. attributetable:: HubProgressFlags + +.. autoclass:: HubProgressFlags() + :members: + .. attributetable:: MessageFlags .. autoclass:: MessageFlags() :members: +.. attributetable:: OnboardingProgressFlags + +.. autoclass:: OnboardingProgressFlags() + :members: + .. attributetable:: PaymentFlags .. autoclass:: PaymentFlags() diff --git a/docs/conf.py b/docs/conf.py index 260f7dfcc..23aa80fb2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -367,5 +367,8 @@ texinfo_documents = [ # If true, do not generate a @detailmenu in the "Top" node's menu. #texinfo_no_detailmenu = False +# Extras +autodoc_mock_imports = ['discord_protos'] + def setup(app): pass diff --git a/docs/migrating.rst b/docs/migrating.rst index c9776d181..9b26b5f12 100644 --- a/docs/migrating.rst +++ b/docs/migrating.rst @@ -561,7 +561,6 @@ The following have been changed: - Note that this method will return ``None`` instead of :class:`VoiceChannel` if the edit was only positional. - :meth:`ClientUser.edit` -- :meth:`ClientUser.edit_settings` - :meth:`Emoji.edit` - :meth:`Guild.edit` - :meth:`Message.edit` diff --git a/requirements.txt b/requirements.txt index 046084ebb..0f186ed55 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ aiohttp>=3.7.4,<4 +discord_protos<1.0.0