Browse Source

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
pull/10109/head
dolfies 2 years ago
committed by GitHub
parent
commit
ba6e15ed61
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      discord/__init__.py
  2. 84
      discord/activity.py
  3. 197
      discord/client.py
  4. 100
      discord/enums.py
  5. 347
      discord/flags.py
  6. 13
      discord/gateway.py
  7. 113
      discord/guild_folder.py
  8. 14
      discord/http.py
  9. 1766
      discord/settings.py
  10. 32
      discord/state.py
  11. 12
      discord/types/gateway.py
  12. 7
      discord/types/user.py
  13. 91
      discord/user.py
  14. 6
      discord/utils.py
  15. 168
      docs/api.rst
  16. 3
      docs/conf.py
  17. 1
      docs/migrating.rst
  18. 1
      requirements.txt

1
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 *

84
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'))

197
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|

100
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

347
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

13
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.')

113
discord/guild_folder.py

@ -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'<GuildFolder id={self.id} name={self.name!r} guilds={self.guilds!r}>'
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

14
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'))

1766
discord/settings.py

File diff suppressed because it is too large

32
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')

12
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

7
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]

91
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.

6
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'

168
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()

3
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

1
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`

1
requirements.txt

@ -1 +1,2 @@
aiohttp>=3.7.4,<4
discord_protos<1.0.0

Loading…
Cancel
Save