From 388863e5218d2740be03ed60a2f929249fe0019c Mon Sep 17 00:00:00 2001 From: dolfies Date: Sat, 2 Apr 2022 12:04:27 -0400 Subject: [PATCH] Implement joining guilds from id & lurking, bug fixes, doc fixes --- discord/client.py | 171 ++++++++++++++++++++++++++++++++++------- discord/gateway.py | 9 ++- discord/guild.py | 47 ++++++++--- discord/http.py | 147 +++++++++++++++++++++-------------- discord/invite.py | 4 +- discord/team.py | 2 +- discord/tracking.py | 60 +++++++++------ discord/types/guild.py | 28 +------ discord/user.py | 14 ++-- 9 files changed, 326 insertions(+), 156 deletions(-) diff --git a/discord/client.py b/discord/client.py index e47b16b8a..cdef462c8 100644 --- a/discord/client.py +++ b/discord/client.py @@ -232,8 +232,8 @@ class Client: self._client_status: _ClientStatus = _ClientStatus() self._client_activities: Dict[Optional[str], Tuple[ActivityTypes, ...]] = { - None: None, - 'this': None, + None: tuple(), + 'this': tuple(), } self._session_count = 1 @@ -275,8 +275,8 @@ class Client: activities = self.initial_activities status = self.initial_status if status is None: - status = getattr(state.settings, 'status', None) - self.loop.create_task(self.change_presence(activities=activities, status=status)) + status = getattr(state.settings, 'status', None) or Status.online + self.loop.create_task(self.change_presence(activities=activities, status=status)) # type: ignore @property def latency(self) -> float: @@ -435,7 +435,11 @@ class Client: if not self._sync_presences: return - if old_settings._status == new_settings._status and old_settings._custom_status == new_settings._custom_status: + if ( + old_settings is not None + and old_settings._status == new_settings._status + and old_settings._custom_status == new_settings._custom_status + ): return # Nothing changed status = new_settings.status @@ -443,7 +447,7 @@ class Client: if (activity := new_settings.custom_activity) is not None: activities.append(activity) - await self.change_presence(status=status, activities=activities, edit_settings=False) + await self.change_presence(status=status, activities=activities, edit_settings=False) # type: ignore # Hooks @@ -582,7 +586,7 @@ class Client: except ReconnectWebSocket as e: _log.info('Got a request to %s the websocket.', e.op) self.dispatch('disconnect') - ws_params.update(sequence=self.ws.sequence, resume=e.resume, session=self.ws.session_id) + ws_params.update(sequence=self.ws.sequence, resume=e.resume, session=self.ws.session_id) # type: ignore - These are always present at this point continue except ( OSError, @@ -606,7 +610,7 @@ class Client: # If we get connection reset by peer then try to RESUME if isinstance(exc, OSError) and exc.errno in (54, 10054): - ws_params.update(sequence=self.ws.sequence, initial=False, resume=True, session=self.ws.session_id) + ws_params.update(sequence=self.ws.sequence, initial=False, resume=True, session=self.ws.session_id) # type: ignore - These are always present at this point continue # We should only get this when an unhandled close code happens, @@ -624,7 +628,7 @@ class Client: # Always try to RESUME the connection # If the connection is not RESUME-able then the gateway will invalidate the session # This is apparently what the official Discord client does - ws_params.update(sequence=self.ws.sequence, resume=True, session=self.ws.session_id) + ws_params.update(sequence=self.ws.sequence, resume=True, session=self.ws.session_id) # type: ignore - These are always present at this point async def close(self) -> None: """|coro| @@ -734,8 +738,7 @@ class Client: if value is None: self._connection._activities = [] elif isinstance(value, BaseActivity): - # ConnectionState._activities is typehinted as List[ActivityPayload], we're passing List[Dict[str, Any]] - self._connection._activities = [value.to_dict()] # type: ignore + self._connection._activities = [value.to_dict()] else: raise TypeError('activity must derive from BaseActivity') @@ -750,8 +753,7 @@ class Client: if not values: self._connection._activities = [] elif all(isinstance(value, BaseActivity) for value in values): - # ConnectionState._activities is typehinted as List[ActivityPayload], we're passing List[Dict[str, Any]] - self._connection._activities = [value.to_dict() for value in values] # type: ignore + self._connection._activities = [value.to_dict() for value in values] else: raise TypeError('activity must derive from BaseActivity') @@ -854,7 +856,7 @@ class Client: than 128 characters. See :issue:`1738` for more information. """ state = self._connection - activities = tuple(create_activity(d, state) for d in self._client_activities[None]) + activities = tuple(create_activity(d, state) for d in self._client_activities[None]) # type: ignore if activities is None and not self.is_closed(): activities = getattr(state.settings, 'custom_activity', []) activities = [activities] if activities else activities @@ -1363,7 +1365,7 @@ class Client: payload: Dict[str, Any] = {'status': status} payload['custom_activity'] = custom_activity - await self.user.edit_settings(**payload) + await self.user.edit_settings(**payload) # type: ignore - user is always present when logged in status_str = str(status) activities_tuple = tuple(a.to_dict() for a in activities) @@ -1433,7 +1435,7 @@ class Client: Parameters ----------- with_counts: :class:`bool` - Whether to return approximate :attr:`.Guild.member_count` and :attr:`.Guild.presence_count`. + Whether to fill :attr:`.Guild.approximate_member_count` and :attr:`.Guild.approximate_presence_count`. Defaults to ``True``. Raises @@ -1448,7 +1450,10 @@ class Client: """ state = self._connection guilds = await state.http.get_guilds(with_counts) - return [Guild(data=data, state=state) for data in guilds] + guilds = [Guild(data=data, state=state) for data in guilds] + for guild in guilds: + guild._cs_joined = True + return guilds async def fetch_template(self, code: Union[Template, str]) -> Template: """|coro| @@ -1517,6 +1522,28 @@ class Client: The guild from the ID. """ data = await self.http.get_guild(guild_id, with_counts) + guild = Guild(data=data, state=self._connection) + guild._cs_joined = True + return guild + + async def fetch_guild_preview(self, guild_id: int, /) -> Guild: + """|coro| + + Retrieves a public :class:`.Guild` preview from an ID. + + Raises + ------ + NotFound + Guild with given ID does not exist/is not public. + HTTPException + Retrieving the guild failed. + + Returns + -------- + :class:`.Guild` + The guild from the ID. + """ + data = await self.http.get_guild_preview(guild_id) return Guild(data=data, state=self._connection) async def create_guild( @@ -1570,7 +1597,69 @@ class Client: data = await self.http.create_from_template(code, name, icon_base64) else: data = await self.http.create_guild(name, icon_base64) - return Guild(data=data, state=self._connection) + + guild = Guild(data=data, state=self._connection) + guild._cs_joined = True + return guild + + async def join_guild(self, guild_id: int, /, lurking: bool = False) -> Guild: + """|coro| + + Joins a discoverable :class:`.Guild`. + + Parameters + ----------- + guild_id: :class:`int` + The ID of the guild to join. + lurking: :class:`bool` + Whether to lurk the guild. + + Raises + ------- + NotFound + Guild with given ID does not exist/have discovery enabled. + HTTPException + Joining the guild failed. + + Returns + -------- + :class:`.Guild` + The guild that was joined. + """ + state = self._connection + data = await state.http.join_guild(guild_id, lurking, state.session_id) + guild = Guild(data=data, state=state) + guild._cs_joined = not lurking + return guild + + async def leave_guild(self, guild: Snowflake, /, lurking: bool = MISSING) -> None: + """|coro| + + Leaves a guild. Equivalent to :meth:`Guild.leave`. + + .. versionadded:: 2.0 + + Parameters + ----------- + guild: :class:`abc.Snowflake` + The guild to leave. + lurking: :class:`bool` + Whether you are lurking the guild. + + Raises + ------- + HTTPException + Leaving the guild failed. + """ + lurking = lurking if lurking is not MISSING else MISSING + if lurking is MISSING: + attr = getattr(guild, 'joined', lurking) + if attr is not MISSING: + lurking = not attr + elif (new_guild := self._connection._get_guild(guild.id)) is not None: + lurking = not new_guild.joined + + await self.http.leave_guild(guild.id, lurking=lurking) async def fetch_stage_instance(self, channel_id: int, /) -> StageInstance: """|coro| @@ -1700,7 +1789,6 @@ class Client: HTTPException Revoking the invite failed. """ - resolved = utils.resolve_invite(invite) await self.http.delete_invite(resolved.code) @@ -1728,7 +1816,6 @@ class Client: The guild joined. This is not the same guild that is added to cache. """ - if not isinstance(invite, Invite): invite = await self.fetch_invite(invite, with_counts=False, with_expiration=False) @@ -1744,7 +1831,9 @@ class Client: } data = await state.http.accept_invite(invite.code, type, **kwargs) if type is InviteType.guild: - return Guild(data=data['guild'], state=state) + guild = Guild(data=data['guild'], state=state) + guild._cs_joined = True + return guild elif type is InviteType.group_dm: return GroupChannel(data=data['channel'], state=state, me=state.user) # type: ignore else: @@ -1973,7 +2062,7 @@ class Client: The sticker you requested. """ data = await self.http.get_sticker(sticker_id) - cls, _ = _sticker_factory(data['type']) # type: ignore + cls, _ = _sticker_factory(data['type']) return cls(state=self._connection, data=data) # type: ignore async def fetch_sticker_packs( @@ -2014,6 +2103,8 @@ class Client: Retrieves a sticker pack with the specified ID. + .. versionadded:: 2.0 + Raises ------- NotFound @@ -2108,7 +2199,7 @@ class Client: """ state = self._connection 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] + 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 create_dm(self, user: Snowflake) -> DMChannel: """|coro| @@ -2161,10 +2252,10 @@ class Client: :class:`.GroupChannel` The new group channel. """ - users = [str(u.id) for u in recipients] + users = [u.id for u in recipients] state = self._connection data = await state.http.start_group(users) - return GroupChannel(me=self.user, data=data, state=state) + return GroupChannel(me=self.user, data=data, state=state) # type: ignore - user is always present when logged in @overload async def send_friend_request(self, user: BaseUser) -> Relationship: @@ -2225,7 +2316,7 @@ class Client: user = args[0] if isinstance(user, BaseUser): user = str(user) - username, discrim = user.split('#') # type: ignore + username, discrim = user.split('#') elif len(args) == 2: username, discrim = args # type: ignore else: @@ -2278,6 +2369,10 @@ class Client: Raises ------- + NotFound + The application was not found. + Forbidden + You do not own the application. HTTPException Retrieving the application failed. @@ -2295,6 +2390,20 @@ class Client: Retrieves the partial application with the given ID. + .. versionadded:: 2.0 + + Parameters + ----------- + app_id: :class:`int` + The ID of the partial application to fetch. + + Raises + ------- + NotFound + The partial application was not found. + HTTPException + Retrieving the partial application failed. + Returns -------- :class:`.PartialApplication` @@ -2330,6 +2439,8 @@ class Client: Retrieves the team with the given ID. + You must be a part of the team. + .. versionadded:: 2.0 Parameters @@ -2339,6 +2450,10 @@ class Client: Raises ------- + NotFound + The team was not found. + Forbidden + You are not a part of the team. HTTPException Retrieving the team failed. @@ -2356,6 +2471,8 @@ class Client: Creates an application. + .. versionadded:: 2.0 + Parameters ---------- name: :class:`str` @@ -2380,6 +2497,8 @@ class Client: Creates a team. + .. versionadded:: 2.0 + Parameters ---------- name: :class:`str` diff --git a/discord/gateway.py b/discord/gateway.py index 7093bef8f..1d39175a4 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -32,7 +32,7 @@ import threading import traceback import zlib -from typing import Any, Callable, Coroutine, Deque, Dict, List, TYPE_CHECKING, NamedTuple, Optional, TypeVar, Type +from typing import Any, Callable, Coroutine, Deque, Dict, List, TYPE_CHECKING, NamedTuple, Optional, TypeVar import aiohttp @@ -56,6 +56,7 @@ if TYPE_CHECKING: from typing_extensions import Self from .client import Client + from .enums import Status from .state import ConnectionState from .types.snowflake import Snowflake from .voice_client import VoiceClient @@ -654,7 +655,7 @@ class DiscordWebSocket: self, *, activities: Optional[List[BaseActivity]] = None, - status: Optional[str] = None, + status: Optional[Status] = None, since: float = 0.0, afk: bool = False, ) -> None: @@ -670,7 +671,7 @@ class DiscordWebSocket: payload = { 'op': self.PRESENCE, - 'd': {'activities': activities_data, 'afk': afk, 'since': since, 'status': str(status)}, + 'd': {'activities': activities_data, 'afk': afk, 'since': since, 'status': str(status or 'online')}, } sent = utils._to_json(payload) @@ -961,7 +962,7 @@ class DiscordVoiceWebSocket: async def client_connect(self) -> None: payload = { - 'op': self.CLIENT_CONNECT, + 'op': self.VIDEO, 'd': { 'audio_ssrc': self._connection.ssrc, }, diff --git a/discord/guild.py b/discord/guild.py index 9e3ef3775..888c42493 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -105,10 +105,9 @@ _log = logging.getLogger(__name__) if TYPE_CHECKING: from .abc import Snowflake, SnowflakeTime from .types.guild import ( - Ban as BanPayload, Guild as GuildPayload, + GuildPreview as GuildPreviewPayload, RolePositionUpdate as RolePositionUpdatePayload, - GuildFeature, ) from .types.threads import ( Thread as ThreadPayload, @@ -266,6 +265,12 @@ class Guild(Hashable): The notification settings for the guild. .. versionadded:: 2.0 + keywords: Optional[:class:`str`] + Discovery search keywords for the guild. + + .. versionadded:: 2.0 + primary_category_id: Optional[:class:`int`] + The ID of the primary discovery category for the guild. """ __slots__ = ( @@ -320,6 +325,12 @@ class Guild(Hashable): '_true_online_count', '_chunked', '_member_list', + 'keywords', + 'primary_category_id', + 'application_command_count', + '_load_id', + '_joined_at', + '_cs_joined', ) _PREMIUM_GUILD_LIMITS: ClassVar[Dict[Optional[int], _GuildLimit]] = { @@ -330,7 +341,7 @@ class Guild(Hashable): 3: _GuildLimit(emoji=250, stickers=60, bitrate=384e3, filesize=104857600), } - def __init__(self, *, data: GuildPayload, state: ConnectionState) -> None: + def __init__(self, *, data: Union[GuildPayload, GuildPreviewPayload], state: ConnectionState) -> None: self._chunked = False self._roles: Dict[int, Role] = {} self._channels: Dict[int, GuildChannel] = {} @@ -344,6 +355,7 @@ class Guild(Hashable): self.notification_settings: Optional[GuildSettings] = None self.command_counts: Optional[CommandCounts] = None self._member_count: int = 0 + self._presence_count: Optional[int] = None self._from_data(data) def _add_channel(self, channel: GuildChannel, /) -> None: @@ -438,9 +450,9 @@ class Guild(Hashable): return role - def _from_data(self, guild: GuildPayload) -> None: + def _from_data(self, guild: Union[GuildPayload, GuildPreviewPayload]) -> None: try: - self._member_count: int = guild['member_count'] + self._member_count: int = guild['member_count'] # type: ignore - Handled below except KeyError: pass @@ -483,7 +495,8 @@ class Guild(Hashable): self.stickers: Tuple[GuildSticker, ...] = tuple( map(lambda d: state.store_sticker(self, d), guild.get('stickers', [])) ) - self.features: List[GuildFeature] = guild.get('features', []) + self.features: List[str] = guild.get('features', []) + self.keywords: List[str] = guild.get('keywords', []) self._icon: Optional[str] = guild.get('icon') self._banner: Optional[str] = guild.get('banner') self._splash: Optional[str] = guild.get('splash') @@ -506,10 +519,12 @@ class Guild(Hashable): self.mfa_level: MFALevel = try_enum(MFALevel, guild.get('mfa_level', 0)) self.approximate_presence_count: Optional[int] = guild.get('approximate_presence_count') self.approximate_member_count: Optional[int] = guild.get('approximate_member_count') - self._presence_count: Optional[int] = guild.get('approximate_presence_count') self.owner_id: Optional[int] = utils._get_as_snowflake(guild, 'owner_id') self.owner_application_id: Optional[int] = utils._get_as_snowflake(guild, 'application_id') self.premium_progress_bar_enabled: bool = guild.get('premium_progress_bar_enabled', False) + self.application_command_count: int = guild.get('application_command_count', 0) + self.primary_category_id: Optional[int] = guild.get('primary_category_id') + self._joined_at = guild.get('joined_at') large = None if self._member_count is 0 else self._member_count >= 250 self._large: Optional[bool] = guild.get('large', large) @@ -593,9 +608,23 @@ class Guild(Hashable): """:class:`Member`: Similar to :attr:`Client.user` except an instance of :class:`Member`. This is essentially used to get the member version of yourself. """ - self_id = self._state.user.id # type: ignore - state.user won't be None if we're logged in + self_id = self._state.self_id return self.get_member(self_id) # type: ignore - The self member is *always* cached + @utils.cached_slot_property('_cs_joined') + def joined(self) -> bool: + """:class:`bool`: Returns whether you are a member of this guild. + May not be accurate for :class:`Guild`s fetched over HTTP. + """ + if self.me or self.joined_at: + return True + return self._state.is_guild_evicted(self) + + @property + def joined_at(self) -> Optional[datetime]: + """:class:`datetime.datetime`: Returns when you joined the guild.""" + return utils.parse_time(self._joined_at) + @property def voice_client(self) -> Optional[VoiceProtocol]: """Optional[:class:`VoiceProtocol`]: Returns the :class:`VoiceProtocol` associated with this guild, if any.""" @@ -1519,7 +1548,7 @@ class Guild(Hashable): HTTPException Leaving the guild failed. """ - await self._state.http.leave_guild(self.id) + await self._state.http.leave_guild(self.id, lurking=not self.joined) async def delete(self) -> None: """|coro| diff --git a/discord/http.py b/discord/http.py index b813c4495..74c052547 100644 --- a/discord/http.py +++ b/discord/http.py @@ -1197,6 +1197,33 @@ class HTTPClient: return self.request(Route('GET', '/users/@me/guilds'), params=params, super_properties_to_track=True) + def join_guild( + self, + guild_id: Snowflake, + lurker: bool, + session_id: Optional[str] = MISSING, + load_id: str = MISSING, + location: str = MISSING, + ) -> Response[guild.Guild]: + params = { + 'lurker': str(lurker).lower(), + } + if lurker: + params['session_id'] = session_id or utils._generate_session_id() + if load_id is not MISSING: + params['recommendation_load_id'] = load_id + params['location'] = 'Guild%20Discovery' + if location is not MISSING: + params['location'] = location + props = ContextProperties._empty() if lurker else ContextProperties._from_lurking() + + return self.request( + Route('PUT', '/guilds/{guild_id}/members/@me', guild_id=guild_id), + context_properties=props, + params=params, + json={}, + ) + def leave_guild(self, guild_id: Snowflake, lurking: bool = False) -> Response[None]: r = Route('DELETE', '/users/@me/guilds/{guild_id}', guild_id=guild_id) payload = {'lurking': lurking} @@ -2079,64 +2106,7 @@ class HTTPClient: return self.request(Route('PATCH', '/users/@me/relationships/{user_id}', user_id=user_id), json=payload) - # Misc - - async def get_gateway(self, *, encoding: str = 'json', zlib: bool = True) -> str: - # The gateway URL hasn't changed for over 5 years - # And, the official clients aren't GETting it anymore, sooooo... - self.zlib = zlib - if zlib: - value = 'wss://gateway.discord.gg?encoding={0}&v=9&compress=zlib-stream' - else: - value = 'wss://gateway.discord.gg?encoding={0}&v=9' - - return value.format(encoding) - - def get_user(self, user_id: Snowflake) -> Response[user.User]: - return self.request(Route('GET', '/users/{user_id}', user_id=user_id)) - - def get_user_profile( - self, user_id: Snowflake, guild_id: Snowflake = MISSING, *, with_mutual_guilds: bool = True - ): # TODO: return type - params: Dict[str, Any] = {'with_mutual_guilds': str(with_mutual_guilds).lower()} - if guild_id is not MISSING: - params['guild_id'] = guild_id - - return self.request(Route('GET', '/users/{user_id}/profile', user_id=user_id), params=params) - - def get_mutual_friends(self, user_id: Snowflake): # TODO: return type - return self.request(Route('GET', '/users/{user_id}/relationships', user_id=user_id)) - - def get_notes(self): # TODO: return type - return self.request(Route('GET', '/users/@me/notes')) - - def get_note(self, user_id: Snowflake): # TODO: return type - return self.request(Route('GET', '/users/@me/notes/{user_id}', user_id=user_id)) - - def set_note(self, user_id: Snowflake, *, note: Optional[str] = None) -> Response[None]: - payload = {'note': note or ''} - - return self.request(Route('PUT', '/users/@me/notes/{user_id}', user_id=user_id), json=payload) - - def change_hypesquad_house(self, house_id: int) -> Response[None]: - payload = {'house_id': house_id} - - return self.request(Route('POST', '/hypesquad/online'), json=payload) - - def leave_hypesquad_house(self) -> Response[None]: - return self.request(Route('DELETE', '/hypesquad/online')) - - def get_settings(self): # TODO: return type - return self.request(Route('GET', '/users/@me/settings')) - - def edit_settings(self, **payload): # TODO: return type, is this cheating? - return self.request(Route('PATCH', '/users/@me/settings'), json=payload) - - def get_tracking(self): # TODO: return type - return self.request(Route('GET', '/users/@me/consent')) - - def edit_tracking(self, payload): - return self.request(Route('POST', '/users/@me/consent'), json=payload) + # Connections def get_connections(self): return self.request(Route('GET', '/users/@me/connections')) @@ -2150,6 +2120,8 @@ class HTTPClient: def get_connection_token(self, type: str, id: str): return self.request(Route('GET', '/users/@me/connections/{type}/{id}/access-token', type=type, id=id)) + # Applications + def get_my_applications(self, *, with_team_applications: bool = True) -> Response[List[appinfo.AppInfo]]: params = {'with_team_applications': str(with_team_applications).lower()} @@ -2245,6 +2217,65 @@ class HTTPClient: def reset_token(self, app_id: Snowflake): return self.request(Route('POST', '/applications/{app_id}/bot/reset', app_id=app_id), super_properties_to_track=True) + # Misc + + async def get_gateway(self, *, encoding: str = 'json', zlib: bool = True) -> str: + # The gateway URL hasn't changed for over 5 years + # And, the official clients aren't GETting it anymore, sooooo... + self.zlib = zlib + if zlib: + value = 'wss://gateway.discord.gg?encoding={0}&v=9&compress=zlib-stream' + else: + value = 'wss://gateway.discord.gg?encoding={0}&v=9' + + return value.format(encoding) + + def get_user(self, user_id: Snowflake) -> Response[user.User]: + return self.request(Route('GET', '/users/{user_id}', user_id=user_id)) + + def get_user_profile( + self, user_id: Snowflake, guild_id: Snowflake = MISSING, *, with_mutual_guilds: bool = True + ): # TODO: return type + params: Dict[str, Any] = {'with_mutual_guilds': str(with_mutual_guilds).lower()} + if guild_id is not MISSING: + params['guild_id'] = guild_id + + return self.request(Route('GET', '/users/{user_id}/profile', user_id=user_id), params=params) + + def get_mutual_friends(self, user_id: Snowflake): # TODO: return type + return self.request(Route('GET', '/users/{user_id}/relationships', user_id=user_id)) + + def get_notes(self): # TODO: return type + return self.request(Route('GET', '/users/@me/notes')) + + def get_note(self, user_id: Snowflake): # TODO: return type + return self.request(Route('GET', '/users/@me/notes/{user_id}', user_id=user_id)) + + def set_note(self, user_id: Snowflake, *, note: Optional[str] = None) -> Response[None]: + payload = {'note': note or ''} + + return self.request(Route('PUT', '/users/@me/notes/{user_id}', user_id=user_id), json=payload) + + def change_hypesquad_house(self, house_id: int) -> Response[None]: + payload = {'house_id': house_id} + + return self.request(Route('POST', '/hypesquad/online'), json=payload) + + def leave_hypesquad_house(self) -> Response[None]: + return self.request(Route('DELETE', '/hypesquad/online')) + + def get_settings(self): # TODO: return type + return self.request(Route('GET', '/users/@me/settings')) + + def edit_settings(self, **payload): # TODO: return type, is this cheating? + return self.request(Route('PATCH', '/users/@me/settings'), json=payload) + + def get_tracking(self): # TODO: return type + return self.request(Route('GET', '/users/@me/consent')) + + def edit_tracking(self, payload): + return self.request(Route('POST', '/users/@me/consent'), json=payload) + def mobile_report( # Report v1 self, guild_id: Snowflake, channel_id: Snowflake, message_id: Snowflake, reason: str ): # TODO: return type diff --git a/discord/invite.py b/discord/invite.py index 99a35fdc7..fc95d8b51 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -604,7 +604,9 @@ class Invite(Hashable): if type is InviteType.guild: from .guild import Guild - return Guild(data=data['guild'], state=state) + guild = Guild(data=data['guild'], state=state) + guild._cs_joined = True + return guild elif type is InviteType.group_dm: from .channel import GroupChannel diff --git a/discord/team.py b/discord/team.py index d57e96245..92b0cc271 100644 --- a/discord/team.py +++ b/discord/team.py @@ -228,7 +228,7 @@ class Team: user = args[0] if isinstance(user, BaseUser): user = str(user) - username, discrim = user.split('#') # type: ignore + username, discrim = user.split('#') elif len(args) == 2: username, discrim = args # type: ignore else: diff --git a/discord/tracking.py b/discord/tracking.py index f5b258fb9..055dd15e6 100644 --- a/discord/tracking.py +++ b/discord/tracking.py @@ -26,12 +26,15 @@ from __future__ import annotations from base64 import b64encode import json +from random import choice -from typing import Any, Dict, overload, Optional, TYPE_CHECKING +from typing import Dict, overload, Optional, TYPE_CHECKING from .utils import MISSING if TYPE_CHECKING: + from typing_extensions import Self + from .enums import ChannelType from .types.snowflake import Snowflake from .state import ConnectionState @@ -56,6 +59,8 @@ class ContextProperties: # Thank you Discord-S.C.U.M def _encode_data(self, data) -> str: library = { + 'None': 'e30=', + # Locations 'Friends': 'eyJsb2NhdGlvbiI6IkZyaWVuZHMifQ==', 'ContextMenu': 'eyJsb2NhdGlvbiI6IkNvbnRleHRNZW51In0=', 'User Profile': 'eyJsb2NhdGlvbiI6IlVzZXIgUHJvZmlsZSJ9', @@ -69,80 +74,82 @@ class ContextProperties: # Thank you Discord-S.C.U.M 'Verify Email': 'eyJsb2NhdGlvbiI6IlZlcmlmeSBFbWFpbCJ9', 'New Group DM': 'eyJsb2NhdGlvbiI6Ik5ldyBHcm91cCBETSJ9', 'Add Friends to DM': 'eyJsb2NhdGlvbiI6IkFkZCBGcmllbmRzIHRvIERNIn0=', - 'None': 'e30=', + # Sources + 'Chat Input Blocker - Lurker Mode': 'eyJzb3VyY2UiOiJDaGF0IElucHV0IEJsb2NrZXIgLSBMdXJrZXIgTW9kZSJ9', + 'Notice - Lurker Mode': 'eyJzb3VyY2UiOiJOb3RpY2UgLSBMdXJrZXIgTW9kZSJ9', } try: - return library[data.get('location', 'None')] + return library[self.target or 'None'] except KeyError: return b64encode(json.dumps(data, separators=(',', ':')).encode()).decode('utf-8') @classmethod - def _empty(cls) -> ContextProperties: + def _empty(cls) -> Self: return cls({}) @classmethod - def _from_friends_page(cls) -> ContextProperties: + def _from_friends_page(cls) -> Self: data = {'location': 'Friends'} return cls(data) @classmethod - def _from_context_menu(cls) -> ContextProperties: + def _from_context_menu(cls) -> Self: data = {'location': 'ContextMenu'} return cls(data) @classmethod - def _from_user_profile(cls) -> ContextProperties: + def _from_user_profile(cls) -> Self: data = {'location': 'User Profile'} return cls(data) @classmethod - def _from_add_friend_page(cls) -> ContextProperties: + def _from_add_friend_page(cls) -> Self: data = {'location': 'Add Friend'} return cls(data) @classmethod - def _from_guild_header_menu(cls) -> ContextProperties: + def _from_guild_header_menu(cls) -> Self: data = {'location': 'Guild Header'} return cls(data) @classmethod - def _from_group_dm(cls) -> ContextProperties: + def _from_group_dm(cls) -> Self: data = {'location': 'Group DM'} return cls(data) @classmethod - def _from_new_group_dm(cls) -> ContextProperties: + def _from_new_group_dm(cls) -> Self: data = {'location': 'New Group DM'} return cls(data) @classmethod - def _from_dm_channel(cls) -> ContextProperties: + def _from_dm_channel(cls) -> Self: data = {'location': 'DM Channel'} return cls(data) @classmethod - def _from_add_to_dm(cls) -> ContextProperties: + def _from_add_to_dm(cls) -> Self: data = {'location': 'Add Friends to DM'} return cls(data) @classmethod - def _from_app(cls) -> ContextProperties: + def _from_app(cls) -> Self: data = {'location': '/app'} return cls(data) @classmethod - def _from_login(cls) -> ContextProperties: + def _from_login(cls) -> Self: data = {'location': 'Login'} return cls(data) @classmethod - def _from_register(cls) -> ContextProperties: + def _from_register(cls) -> Self: data = {'location': 'Register'} return cls(data) @classmethod - def _from_verification(cls) -> ContextProperties: + def _from_verification(cls) -> Self: data = {'location': 'Verify Email'} return cls(data) @@ -153,7 +160,7 @@ class ContextProperties: # Thank you Discord-S.C.U.M guild_id: Snowflake = MISSING, channel_id: Snowflake = MISSING, channel_type: ChannelType = MISSING, - ) -> ContextProperties: + ) -> Self: data: Dict[str, Snowflake] = { 'location': 'Accept Invite Page', } @@ -172,7 +179,7 @@ class ContextProperties: # Thank you Discord-S.C.U.M guild_id: Snowflake = MISSING, channel_id: Snowflake = MISSING, channel_type: ChannelType = MISSING, - ) -> ContextProperties: + ) -> Self: data: Dict[str, Snowflake] = { 'location': 'Join Guild', } @@ -192,7 +199,7 @@ class ContextProperties: # Thank you Discord-S.C.U.M channel_id: Snowflake, message_id: Snowflake, channel_type: Optional[ChannelType], - ) -> ContextProperties: + ) -> Self: data = { 'location': 'Invite Button Embed', 'location_guild_id': str(guild_id) if guild_id else None, @@ -202,9 +209,14 @@ class ContextProperties: # Thank you Discord-S.C.U.M } return cls(data) + @classmethod + def _from_lurking(cls, source: str = MISSING) -> Self: + data = {'source': source or choice(('Chat Input Blocker - Lurker Mode', 'Notice - Lurker Mode'))} + return cls(data) + @property - def location(self) -> Optional[str]: - return self._data.get('location') # type: ignore + def target(self) -> Optional[str]: + return self._data.get('location', data.get('source')) # type: ignore @property def guild_id(self) -> Optional[int]: @@ -232,10 +244,10 @@ class ContextProperties: # Thank you Discord-S.C.U.M return self.value is not None def __str__(self) -> str: - return self._data.get('location', 'None') # type: ignore + return self.target or 'None' def __repr__(self) -> str: - return f'' + return f'' def __eq__(self, other) -> bool: return isinstance(other, ContextProperties) and self.value == other.value diff --git a/discord/types/guild.py b/discord/types/guild.py index dd265e1cb..e6e26cd12 100644 --- a/discord/types/guild.py +++ b/discord/types/guild.py @@ -55,7 +55,6 @@ class UnavailableGuild(_UnavailableGuildOptional): class _GuildOptional(TypedDict, total=False): icon_hash: Optional[str] owner: bool - permissions: str widget_enabled: bool widget_channel_id: Optional[Snowflake] joined_at: Optional[str] @@ -78,31 +77,6 @@ MFALevel = Literal[0, 1] VerificationLevel = Literal[0, 1, 2, 3, 4] NSFWLevel = Literal[0, 1, 2, 3] PremiumTier = Literal[0, 1, 2, 3] -GuildFeature = Literal[ - 'ANIMATED_ICON', - 'BANNER', - 'COMMERCE', - 'COMMUNITY', - 'DISCOVERABLE', - 'FEATURABLE', - 'INVITE_SPLASH', - 'MEMBER_VERIFICATION_GATE_ENABLED', - 'MONETIZATION_ENABLED', - 'MORE_EMOJI', - 'MORE_STICKERS', - 'NEWS', - 'PARTNERED', - 'PREVIEW_ENABLED', - 'PRIVATE_THREADS', - 'ROLE_ICONS', - 'SEVEN_DAY_THREAD_ARCHIVE', - 'THREE_DAY_THREAD_ARCHIVE', - 'TICKETED_EVENTS_ENABLED', - 'VANITY_URL', - 'VERIFIED', - 'VIP_REGIONS', - 'WELCOME_SCREEN_ENABLED', -] class _BaseGuildPreview(UnavailableGuild): @@ -112,7 +86,7 @@ class _BaseGuildPreview(UnavailableGuild): discovery_splash: Optional[str] emojis: List[Emoji] stickers: List[GuildSticker] - features: List[GuildFeature] + features: List[str] description: Optional[str] diff --git a/discord/user.py b/discord/user.py index 444b9b1cf..1081e9255 100644 --- a/discord/user.py +++ b/discord/user.py @@ -485,8 +485,6 @@ class ClientUser(BaseUser): The IETF language tag used to identify the language the user is using. mfa_enabled: :class:`bool` Specifies if the user has MFA turned on and working. - premium: :class:`bool` - Specifies if the user is a premium user (i.e. has Discord Nitro). premium_type: Optional[:class:`PremiumType`] Specifies the type of premium a user has (i.e. Nitro or Nitro Classic). Could be None if the user is not premium. note: :class:`Note` @@ -507,7 +505,6 @@ class ClientUser(BaseUser): 'phone', 'premium_type', 'note', - 'premium', 'bio', 'nsfw_allowed', ) @@ -519,7 +516,6 @@ class ClientUser(BaseUser): locale: Locale _flags: int mfa_enabled: bool - premium: bool premium_type: Optional[PremiumType] bio: Optional[str] nsfw_allowed: bool @@ -542,8 +538,9 @@ class ClientUser(BaseUser): self.locale = try_enum(Locale, data.get('locale', 'en-US')) self._flags = data.get('flags', 0) self.mfa_enabled = data.get('mfa_enabled', False) - self.premium = data.get('premium', False) - self.premium_type = try_enum(PremiumType, data.get('premium_type', None)) + self.premium_type = try_enum(PremiumType, data['premium_type']) if 'premium_type' in data else None + self.bio = data.get('bio') + self.nsfw_allowed = data.get('nsfw_allowed', False) self.bio = data.get('bio') or None self.nsfw_allowed = data.get('nsfw_allowed', False) @@ -562,6 +559,11 @@ class ClientUser(BaseUser): """ return self._state._relationships.get(user_id) + @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 relationships(self) -> List[Relationship]: """List[:class:`User`]: Returns all the relationships that the user has."""