Browse Source

Rework member chunking and events (#662)

pull/10109/head
dolfies 1 year ago
committed by GitHub
parent
commit
51d43a158f
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 15
      discord/abc.py
  2. 10
      discord/channel.py
  3. 32
      discord/client.py
  4. 4
      discord/experiment.py
  5. 43
      discord/flags.py
  6. 96
      discord/gateway.py
  7. 226
      discord/guild.py
  8. 23
      discord/raw_models.py
  9. 994
      discord/state.py
  10. 57
      discord/threads.py
  11. 32
      discord/types/gateway.py
  12. 12
      discord/types/threads.py
  13. 11
      discord/utils.py
  14. 32
      docs/api.rst
  15. 16
      docs/authenticating.rst
  16. 42
      docs/guild_subscriptions.rst
  17. 4
      docs/index.rst
  18. 102
      docs/migrating_from_dpy.rst
  19. 2
      docs/quickstart.rst

15
discord/abc.py

@ -622,6 +622,21 @@ class GuildChannel:
def _sorting_bucket(self) -> int: def _sorting_bucket(self) -> int:
raise NotImplementedError raise NotImplementedError
@property
def member_list_id(self) -> Union[str, Literal["everyone"]]:
if self.permissions_for(self.guild.default_role).read_messages:
return "everyone"
overwrites = []
for overwrite in self._overwrites:
allow, deny = Permissions(overwrite.allow), Permissions(overwrite.deny)
if allow.read_messages:
overwrites.append(f"allow:{overwrite.id}")
elif deny.read_messages:
overwrites.append(f"deny:{overwrite.id}")
return str(utils.murmurhash32(",".join(sorted(overwrites)), signed=False))
def _update(self, guild: Guild, data: Dict[str, Any]) -> None: def _update(self, guild: Guild, data: Dict[str, Any]) -> None:
raise NotImplementedError raise NotImplementedError

10
discord/channel.py

@ -3458,7 +3458,6 @@ class DMChannel(discord.abc.Messageable, discord.abc.Connectable, discord.abc.Pr
'_requested_at', '_requested_at',
'_spam', '_spam',
'_state', '_state',
'_accessed',
) )
def __init__(self, *, me: ClientUser, state: ConnectionState, data: DMChannelPayload): def __init__(self, *, me: ClientUser, state: ConnectionState, data: DMChannelPayload):
@ -3467,7 +3466,6 @@ class DMChannel(discord.abc.Messageable, discord.abc.Connectable, discord.abc.Pr
self.me: ClientUser = me self.me: ClientUser = me
self.id: int = int(data['id']) self.id: int = int(data['id'])
self._update(data) self._update(data)
self._accessed: bool = False
def _update(self, data: DMChannelPayload) -> None: def _update(self, data: DMChannelPayload) -> None:
self.last_message_id: Optional[int] = utils._get_as_snowflake(data, 'last_message_id') self.last_message_id: Optional[int] = utils._get_as_snowflake(data, 'last_message_id')
@ -3486,9 +3484,6 @@ class DMChannel(discord.abc.Messageable, discord.abc.Connectable, discord.abc.Pr
return PrivateCall(**kwargs) return PrivateCall(**kwargs)
async def _get_channel(self) -> Self: async def _get_channel(self) -> Self:
if not self._accessed:
await self._state.call_connect(self.id)
self._accessed = True
return self return self
async def _initial_ring(self) -> None: async def _initial_ring(self) -> None:
@ -3912,7 +3907,6 @@ class GroupChannel(discord.abc.Messageable, discord.abc.Connectable, discord.abc
'name', 'name',
'me', 'me',
'_state', '_state',
'_accessed',
) )
def __init__(self, *, me: ClientUser, state: ConnectionState, data: GroupChannelPayload): def __init__(self, *, me: ClientUser, state: ConnectionState, data: GroupChannelPayload):
@ -3920,7 +3914,6 @@ class GroupChannel(discord.abc.Messageable, discord.abc.Connectable, discord.abc
self.id: int = int(data['id']) self.id: int = int(data['id'])
self.me: ClientUser = me self.me: ClientUser = me
self._update(data) self._update(data)
self._accessed: bool = False
def _update(self, data: GroupChannelPayload) -> None: def _update(self, data: GroupChannelPayload) -> None:
self.owner_id: int = int(data['owner_id']) self.owner_id: int = int(data['owner_id'])
@ -3940,9 +3933,6 @@ class GroupChannel(discord.abc.Messageable, discord.abc.Connectable, discord.abc
return self.me.id, self.id return self.me.id, self.id
async def _get_channel(self) -> Self: async def _get_channel(self) -> Self:
if not self._accessed:
await self._state.call_connect(self.id)
self._accessed = True
return self return self
def _initial_ring(self): def _initial_ring(self):

32
discord/client.py

@ -178,10 +178,38 @@ class Client:
amounts of guilds. The default is ``True``. amounts of guilds. The default is ``True``.
.. versionadded:: 1.5 .. versionadded:: 1.5
guild_subscriptions: :class:`bool`
Whether to subscribe to all guilds at startup.
This is required to receive member events and populate the thread cache.
For larger servers, this is required to receive nearly all events.
See :doc:`guild_subscriptions` for more information.
.. versionadded:: 2.1
.. warning::
If this is set to ``False``, the following consequences will occur:
- Large guilds (over 75,000 members) will not dispatch any non-stateful events (e.g. :func:`.on_message`, :func:`.on_reaction_add`, :func:`.on_typing`, etc.)
- :attr:`~Guild.threads` will only contain threads the client has joined.
- Guilds will not be chunkable and member events (e.g. :func:`.on_member_update`) will not be dispatched.
- Most :func:`.on_user_update` occurences will not be dispatched.
- The member (:attr:`~Guild.members`) and user (:attr:`~Client.users`) cache will be largely incomplete.
- Essentially, only the client user, friends/implicit relationships, voice members, and other subscribed-to users will be cached and dispatched.
This is useful if you want to control subscriptions manually (see :meth:`Guild.subscribe`) to save bandwidth and memory.
Disabling this is not recommended for most use cases.
request_guilds: :class:`bool` request_guilds: :class:`bool`
Whether to request guilds at startup. Defaults to True. See ``guild_subscriptions``.
.. versionadded:: 2.0 .. versionadded:: 2.0
.. deprecated:: 2.1
This is deprecated and will be removed in a future version.
Use ``guild_subscriptions`` instead.
status: Optional[:class:`.Status`] status: Optional[:class:`.Status`]
A status to start your presence with upon logging on to Discord. A status to start your presence with upon logging on to Discord.
activity: Optional[:class:`.BaseActivity`] activity: Optional[:class:`.BaseActivity`]
@ -972,7 +1000,7 @@ class Client:
""" """
self._closed = False self._closed = False
self._ready.clear() self._ready.clear()
self._connection.clear() self._connection.clear(full=True)
self.http.clear() self.http.clear()
async def start(self, token: str, *, reconnect: bool = True) -> None: async def start(self, token: str, *, reconnect: bool = True) -> None:

4
discord/experiment.py

@ -429,7 +429,9 @@ class ExperimentOverride:
return len(self._ids) return len(self._ids)
def __contains__(self, item: Union[int, Snowflake], /) -> bool: def __contains__(self, item: Union[int, Snowflake], /) -> bool:
return getattr(item, 'id', item) in self._ids if isinstance(item, int):
return item in self._ids
return item.id in self._ids
def __iter__(self) -> Iterator[int]: def __iter__(self) -> Iterator[int]:
return iter(self._ids) return iter(self._ids)

43
discord/flags.py

@ -301,7 +301,20 @@ class Capabilities(BaseFlags):
@classmethod @classmethod
def default(cls: Type[Self]) -> Self: def default(cls: Type[Self]) -> Self:
"""Returns a :class:`Capabilities` with the current value used by the library.""" """Returns a :class:`Capabilities` with the current value used by the library."""
return cls._from_value(8189) return cls(
lazy_user_notes=True,
versioned_read_states=True,
versioned_user_guild_settings=True,
dedupe_user_objects=True,
prioritized_ready_payload=True,
multiple_guild_experiment_populations=True,
non_channel_read_states=True,
auth_token_refresh=True,
user_settings_proto=True,
client_state_v2=True,
passive_guild_update=True,
auto_call_connect=True,
)
@flag_value @flag_value
def lazy_user_notes(self): def lazy_user_notes(self):
@ -365,10 +378,16 @@ class Capabilities(BaseFlags):
return 1 << 11 return 1 << 11
@flag_value @flag_value
def unknown_12(self): def auto_call_connect(self):
""":class:`bool`: Unknown.""" """:class:`bool`: Connect user to all existing calls on connect (deprecates ``CALL_CONNECT`` opcode)."""
return 1 << 12 return 1 << 12
@flag_value
def debounce_message_reactions(self):
""":class:`bool`: Debounce message reactions (dispatches ``MESSAGE_REACTION_ADD_MANY`` instead of ``MESSAGE_REACTION_ADD`` when a lot of reactions are sent in quick succession)."""
# Debounced reactions don't have member information, so this is kinda undesirable :(
return 1 << 13
@fill_with_flags(inverted=True) @fill_with_flags(inverted=True)
class SystemChannelFlags(BaseFlags): class SystemChannelFlags(BaseFlags):
@ -1171,23 +1190,17 @@ class MemberCacheFlags(BaseFlags):
return 1 return 1
@flag_value @flag_value
def other(self): def joined(self):
""":class:`bool`: Whether to cache members that are collected from other means. """:class:`bool`: Whether to cache members that joined the guild
or are chunked as part of the initial log in flow.
This does not apply to members explicitly cached (e.g. :attr:`Guild.chunk`, :attr:`Guild.fetch_members`).
There is an alias for this called :attr:`joined`. Members that leave the guild are no longer cached.
""" """
return 2 return 2
@alias_flag_value @alias_flag_value
def joined(self): def other(self):
""":class:`bool`: Whether to cache members that are collected from other means. """:class:`bool`: Alias for :attr:`joined`."""
This does not apply to members explicitly cached (e.g. :attr:`Guild.chunk`, :attr:`Guild.fetch_members`).
This is an alias for :attr:`other`.
"""
return 2 return 2
@property @property

96
discord/gateway.py

@ -61,6 +61,7 @@ if TYPE_CHECKING:
from .enums import Status from .enums import Status
from .state import ConnectionState from .state import ConnectionState
from .types.snowflake import Snowflake from .types.snowflake import Snowflake
from .types.gateway import BulkGuildSubscribePayload
from .voice_client import VoiceClient from .voice_client import VoiceClient
@ -238,45 +239,10 @@ class DiscordWebSocket:
Attributes Attributes
----------- -----------
DISPATCH
Receive only. Denotes an event to be sent to Discord, such as READY.
HEARTBEAT
When received tells Discord to keep the connection alive.
When sent asks if your connection is currently alive.
IDENTIFY
Send only. Starts a new session.
PRESENCE
Send only. Updates your presence.
VOICE_STATE
Send only. Starts a new connection to a voice guild.
VOICE_PING
Send only. Checks ping time to a voice guild, do not use.
RESUME
Send only. Resumes an existing connection.
RECONNECT
Receive only. Tells the client to reconnect to a new gateway.
REQUEST_MEMBERS
Send only. Asks for the guild members.
INVALIDATE_SESSION
Receive only. Tells the client to optionally invalidate the session
and IDENTIFY again.
HELLO
Receive only. Tells the client the heartbeat interval.
HEARTBEAT_ACK
Receive only. Confirms receiving of a heartbeat. Not having it implies
a connection issue.
GUILD_SYNC
Send only. Requests a guild sync. This is unfortunately no longer functional.
CALL_CONNECT
Send only. Maybe used for calling? Probably just tracking.
GUILD_SUBSCRIBE
Send only. Subscribes you to guilds/guild members. Might respond with GUILD_MEMBER_LIST_UPDATE.
REQUEST_COMMANDS
Send only. Requests application commands from a guild. Responds with GUILD_APPLICATION_COMMANDS_UPDATE.
gateway gateway
The gateway we are currently connected to. The gateway we are currently connected to.
token token
The authentication token for discord. The authentication token for Discord.
""" """
if TYPE_CHECKING: if TYPE_CHECKING:
@ -307,11 +273,12 @@ class DiscordWebSocket:
INVALIDATE_SESSION = 9 INVALIDATE_SESSION = 9
HELLO = 10 HELLO = 10
HEARTBEAT_ACK = 11 HEARTBEAT_ACK = 11
GUILD_SYNC = 12 # :( # GUILD_SYNC = 12
CALL_CONNECT = 13 CALL_CONNECT = 13
GUILD_SUBSCRIBE = 14 GUILD_SUBSCRIBE = 14 # Deprecated
REQUEST_COMMANDS = 24 # REQUEST_COMMANDS = 24
SEARCH_RECENT_MEMBERS = 35 SEARCH_RECENT_MEMBERS = 35
BULK_GUILD_SUBSCRIBE = 37
# fmt: on # fmt: on
def __init__(self, socket: aiohttp.ClientWebSocketResponse, *, loop: asyncio.AbstractEventLoop) -> None: def __init__(self, socket: aiohttp.ClientWebSocketResponse, *, loop: asyncio.AbstractEventLoop) -> None:
@ -727,7 +694,7 @@ class DiscordWebSocket:
self.afk = afk self.afk = afk
self.idle_since = since self.idle_since = since
async def request_lazy_guild( async def guild_subscribe(
self, self,
guild_id: Snowflake, guild_id: Snowflake,
*, *,
@ -762,6 +729,17 @@ class DiscordWebSocket:
_log.debug('Subscribing to guild %s with payload %s', guild_id, payload['d']) _log.debug('Subscribing to guild %s with payload %s', guild_id, payload['d'])
await self.send_as_json(payload) await self.send_as_json(payload)
async def bulk_guild_subscribe(self, subscriptions: BulkGuildSubscribePayload) -> None:
payload = {
'op': self.BULK_GUILD_SUBSCRIBE,
'd': {
'subscriptions': subscriptions,
},
}
_log.debug('Subscribing to guilds with payload %s', payload['d'])
await self.send_as_json(payload)
async def request_chunks( async def request_chunks(
self, self,
guild_ids: List[Snowflake], guild_ids: List[Snowflake],
@ -821,44 +799,6 @@ class DiscordWebSocket:
_log.debug('Requesting call connect for channel %s.', channel_id) _log.debug('Requesting call connect for channel %s.', channel_id)
await self.send_as_json(payload) await self.send_as_json(payload)
async def request_commands(
self,
guild_id: Snowflake,
type: int,
*,
nonce: Optional[str] = None,
limit: Optional[int] = None,
applications: Optional[bool] = None,
offset: int = 0,
query: Optional[str] = None,
command_ids: Optional[List[Snowflake]] = None,
application_id: Optional[Snowflake] = None,
) -> None:
payload = {
'op': self.REQUEST_COMMANDS,
'd': {
'guild_id': str(guild_id),
'type': type,
},
}
if nonce is not None:
payload['d']['nonce'] = nonce
if applications is not None:
payload['d']['applications'] = applications
if limit is not None and limit != 25:
payload['d']['limit'] = limit
if offset:
payload['d']['offset'] = offset
if query is not None:
payload['d']['query'] = query
if command_ids is not None:
payload['d']['command_ids'] = command_ids
if application_id is not None:
payload['d']['application_id'] = str(application_id)
await self.send_as_json(payload)
async def search_recent_members( async def search_recent_members(
self, guild_id: Snowflake, query: str = '', *, after: Optional[Snowflake] = None, nonce: Optional[str] = None self, guild_id: Snowflake, query: str = '', *, after: Optional[Snowflake] = None, nonce: Optional[str] = None
) -> None: ) -> None:

226
discord/guild.py

@ -135,7 +135,7 @@ if TYPE_CHECKING:
from .types.embed import EmbedType from .types.embed import EmbedType
from .types.integration import IntegrationType from .types.integration import IntegrationType
from .types.message import MessageSearchAuthorType, MessageSearchHasType from .types.message import MessageSearchAuthorType, MessageSearchHasType
from .types.snowflake import SnowflakeList, Snowflake as _Snowflake from .types.snowflake import SnowflakeList
from .types.widget import EditWidgetSettings from .types.widget import EditWidgetSettings
from .types.audit_log import AuditLogEvent from .types.audit_log import AuditLogEvent
from .types.oauth2 import OAuth2Guild as OAuth2GuildPayload from .types.oauth2 import OAuth2Guild as OAuth2GuildPayload
@ -486,7 +486,6 @@ class Guild(Hashable):
'premium_progress_bar_enabled', 'premium_progress_bar_enabled',
'_presence_count', '_presence_count',
'_true_online_count', '_true_online_count',
'_chunked',
'_member_list', '_member_list',
'keywords', 'keywords',
'primary_category_id', 'primary_category_id',
@ -505,7 +504,6 @@ class Guild(Hashable):
} }
def __init__(self, *, data: Union[BaseGuildPayload, GuildPayload], state: ConnectionState) -> None: def __init__(self, *, data: Union[BaseGuildPayload, GuildPayload], state: ConnectionState) -> None:
self._chunked = False
self._cs_joined: Optional[bool] = None self._cs_joined: Optional[bool] = None
self._roles: Dict[int, Role] = {} self._roles: Dict[int, Role] = {}
self._channels: Dict[int, GuildChannel] = {} self._channels: Dict[int, GuildChannel] = {}
@ -751,7 +749,13 @@ class Guild(Hashable):
@property @property
def _offline_members_hidden(self) -> bool: def _offline_members_hidden(self) -> bool:
return (self._member_count or 0) > 1000 # Member count, hoisted role count, and Online/Offline group
# This may not be 100% accurate because member list groups are cached server-side
return (self._member_count or 0) + len([role for role in self.roles if role.hoist]) + 2 >= 1000
@property
def _extra_large(self) -> bool:
return self._member_count is not None and self._member_count >= 75000
def is_hub(self) -> bool: def is_hub(self) -> bool:
""":class:`bool`: Whether the guild is a Student Hub. """:class:`bool`: Whether the guild is a Student Hub.
@ -1332,13 +1336,18 @@ class Guild(Hashable):
def chunked(self) -> bool: def chunked(self) -> bool:
""":class:`bool`: Returns a boolean indicating if the guild is "chunked". """:class:`bool`: Returns a boolean indicating if the guild is "chunked".
A chunked guild means that :attr:`member_count` is equal to the A chunked guild means that the guild member count is equal to the
number of members stored in the internal :attr:`members` cache. number of members stored in the internal :attr:`members` cache.
If this value returns ``False``, then you should request for If this value returns ``False``, then you should request for
offline members. offline members.
""" """
return self._chunked count = self._member_count
if count is None:
return False
# Member updates must be enabled to have an accurate member count
return count == len(self._members) and self._state.subscriptions.has_feature(self, 'member_updates')
@property @property
def created_at(self) -> datetime: def created_at(self) -> datetime:
@ -1853,7 +1862,12 @@ class Guild(Hashable):
options['video_quality_mode'] = video_quality_mode.value options['video_quality_mode'] = video_quality_mode.value
data = await self._create_channel( data = await self._create_channel(
name, overwrites=overwrites, channel_type=ChannelType.stage_voice, category=category, reason=reason, **options name,
overwrites=overwrites,
channel_type=ChannelType.stage_voice,
category=category,
reason=reason,
**options,
) )
channel = StageChannel(state=self._state, guild=self, data=data) channel = StageChannel(state=self._state, guild=self, data=data)
@ -2118,7 +2132,12 @@ class Guild(Hashable):
options['available_tags'] = [t.to_dict() for t in available_tags] options['available_tags'] = [t.to_dict() for t in available_tags]
data = await self._create_channel( data = await self._create_channel(
name=name, overwrites=overwrites, channel_type=ChannelType.forum, category=category, reason=reason, **options name=name,
overwrites=overwrites,
channel_type=ChannelType.forum,
category=category,
reason=reason,
**options,
) )
channel = ForumChannel(state=self._state, guild=self, data=data) channel = ForumChannel(state=self._state, guild=self, data=data)
@ -2635,7 +2654,10 @@ class Guild(Hashable):
""" """
state = self._state state = self._state
data = await state.http.get_user_profile( data = await state.http.get_user_profile(
member_id, self.id, with_mutual_guilds=with_mutual_guilds, with_mutual_friends_count=with_mutual_friends_count member_id,
self.id,
with_mutual_guilds=with_mutual_guilds,
with_mutual_friends_count=with_mutual_friends_count,
) )
if 'guild_member_profile' not in data: if 'guild_member_profile' not in data:
raise InvalidData('Member is not in this guild') raise InvalidData('Member is not in this guild')
@ -4714,7 +4736,10 @@ class Guild(Hashable):
The applications that belong to this guild. The applications that belong to this guild.
""" """
data = await self._state.http.get_guild_applications( data = await self._state.http.get_guild_applications(
self.id, include_team=with_team, type=int(type) if type else None, channel_id=channel.id if channel else None self.id,
include_team=with_team,
type=int(type) if type else None,
channel_id=channel.id if channel else None,
) )
return [PartialApplication(state=self._state, data=app) for app in data] return [PartialApplication(state=self._state, data=app) for app in data]
@ -4843,50 +4868,63 @@ class Guild(Hashable):
""" """
return await self._state.http.get_price_tier(price_tier) return await self._state.http.get_price_tier(price_tier)
async def chunk(self, channel: Snowflake = MISSING) -> List[Member]: async def chunk(self, *, cache: bool = True) -> List[Member]:
"""|coro| """|coro|
Requests all members that belong to this guild. Requests all members that belong to this guild.
This is a websocket operation and can be slow. This is a websocket operation and can be slow.
.. versionadded:: 2.0 .. versionadded:: 2.0
.. note:: .. note::
This can only be used on guilds with less than 1000 members.
For guilds with more than 1,000 members, this requires the
:attr:`~Permissions.manage_roles`, :attr:`~Permissions.kick_members`,
or :attr:`~Permissions.ban_members` permissions.
For guilds with less than 1,000 members, this requires at least
one channel that is viewable by every member.
Parameters Parameters
----------- -----------
channel: :class:`~abc.Snowflake` cache: :class:`bool`
The channel to request members from. Whether to cache the members as well.
Raises Raises
------- -------
ClientException ClientException
This guild is not subscribed to.
This guild cannot be chunked or chunking failed. This guild cannot be chunked or chunking failed.
Guild is no longer available.
InvalidData InvalidData
Did not receive a response from the gateway. Did not receive a response from the Gateway.
Returns Returns
-------- --------
List[:class:`Member`] List[:class:`Member`]
The members that belong to this guild. The members that belong to this guild.
""" """
if self._offline_members_hidden: state = self._state
if state.is_guild_evicted(self):
return []
if not state.subscriptions.is_subscribed(self):
raise ClientException('This guild is not subscribed to')
if await state._can_chunk_guild(self):
members = await state.chunk_guild(self, cache=cache)
elif not self._offline_members_hidden:
members = await state.scrape_guild(self, cache=cache, chunk=True)
else:
raise ClientException('This guild cannot be chunked') raise ClientException('This guild cannot be chunked')
if self._state.is_guild_evicted(self):
raise ClientException('This guild is no longer available')
members = await self._state.chunk_guild(self, channels=[channel] if channel else [])
return members return members
async def fetch_members( async def fetch_members(
self, self,
channels: List[Snowflake] = MISSING, channels: List[Snowflake] = MISSING,
*, *,
cache: bool = True, cache: bool = False,
force_scraping: bool = False, force_scraping: bool = False,
delay: Union[int, float] = 1, delay: float = 0,
) -> List[Member]: ) -> List[Member]:
"""|coro| """|coro|
@ -4898,10 +4936,10 @@ class Guild(Hashable):
.. versionadded:: 2.0 .. versionadded:: 2.0
.. note:: .. note::
If you are the owner, have either of :attr:`~Permissions.administrator`,
:attr:`~Permissions.kick_members`, :attr:`~Permissions.ban_members`, or :attr:`~Permissions.manage_roles`, If you have any of :attr:`~Permissions.kick_members`, :attr:`~Permissions.ban_members`,
permissions will be fetched through OPcode 8 (this includes offline members). or :attr:`~Permissions.manage_roles`, members will be requested normally (including offline members).
Else, they will be scraped from the member sidebar. Else, this will scrape the member sidebar, which is slower and may not include offline members.
Parameters Parameters
----------- -----------
@ -4912,7 +4950,7 @@ class Guild(Hashable):
Whether to cache the members as well. The cache will not be kept updated. Whether to cache the members as well. The cache will not be kept updated.
force_scraping: :class:`bool` force_scraping: :class:`bool`
Whether to scrape the member sidebar regardless of permissions. Whether to scrape the member sidebar regardless of permissions.
delay: Union[:class:`int`, :class:`float`] delay: :class:`float`
The time in seconds to wait between requests. The time in seconds to wait between requests.
This only applies when scraping from the member sidebar. This only applies when scraping from the member sidebar.
@ -4920,7 +4958,6 @@ class Guild(Hashable):
------- -------
ClientException ClientException
Fetching members failed. Fetching members failed.
Guild is no longer available.
InvalidData InvalidData
Did not receive a response from the gateway. Did not receive a response from the gateway.
@ -4929,12 +4966,11 @@ class Guild(Hashable):
List[:class:`Member`] List[:class:`Member`]
The members that belong to this guild (offline members may not be included). The members that belong to this guild (offline members may not be included).
""" """
if self._state.is_guild_evicted(self): state = self._state
raise ClientException('This guild is no longer available') if state.is_guild_evicted(self):
return []
members = await self._state.scrape_guild( members = await state.scrape_guild(self, cache=cache, force_scraping=force_scraping, delay=delay, channels=channels)
self, cache=cache, force_scraping=force_scraping, delay=delay, channels=channels
)
return members return members
async def query_members( async def query_members(
@ -4982,11 +5018,12 @@ class Guild(Hashable):
.. versionadded:: 1.4 .. versionadded:: 1.4
subscribe: :class:`bool` subscribe: :class:`bool`
Whether to subscribe to the resulting members. This will keep their info and presence updated. Whether to subscribe to the resulting members. This will keep their member info and presence updated.
This requires another request, and defaults to ``False``. This requires another request, and defaults to ``False``.
.. versionadded:: 2.0 See also :meth:`subscribe_to`.
.. versionadded:: 2.0
Raises Raises
------- -------
@ -5011,8 +5048,7 @@ class Guild(Hashable):
self, query=query, limit=limit, user_ids=user_ids, presences=presences, cache=cache # type: ignore # The two types are compatible self, query=query, limit=limit, user_ids=user_ids, presences=presences, cache=cache # type: ignore # The two types are compatible
) )
if subscribe: if subscribe:
ids: List[_Snowflake] = [str(m.id) for m in members] await self._state.subscriptions.subscribe_to_members(self, *members)
await self._state.ws.request_lazy_guild(self.id, members=ids)
return members return members
async def query_recent_members( async def query_recent_members(
@ -5021,7 +5057,6 @@ class Guild(Hashable):
*, *,
limit: int = 1000, limit: int = 1000,
cache: bool = True, cache: bool = True,
subscribe: bool = False,
) -> List[Member]: ) -> List[Member]:
"""|coro| """|coro|
@ -5044,10 +5079,7 @@ class Guild(Hashable):
cache: :class:`bool` cache: :class:`bool`
Whether to cache the members internally. This makes operations Whether to cache the members internally. This makes operations
such as :meth:`get_member` work for those that matched. such as :meth:`get_member` work for those that matched.
The cache will not be kept updated unless ``subscribe`` is set to ``True``. The cache will not be kept updated.
subscribe: :class:`bool`
Whether to subscribe to the resulting members. This will keep their info and presence updated.
This requires another request, and defaults to ``False``.
Raises Raises
------- -------
@ -5062,13 +5094,7 @@ class Guild(Hashable):
The list of members that have matched the query. The list of members that have matched the query.
""" """
limit = min(10000, limit or 1) limit = min(10000, limit or 1)
members = await self._state.search_recent_members(self, query or '', limit, cache) return await self._state.search_recent_members(self, query or '', limit, cache)
if subscribe:
ids: List[_Snowflake] = [str(m.id) for m in members]
for i in range(0, len(ids), 750):
subs = ids[i : i + 750]
await self._state.ws.request_lazy_guild(self.id, members=subs)
return members
async def change_voice_state( async def change_voice_state(
self, self,
@ -5100,6 +5126,7 @@ class Guild(Hashable):
The preferred region to connect to. The preferred region to connect to.
.. versionchanged:: 2.0 .. versionchanged:: 2.0
The type of this parameter has changed to :class:`str`. The type of this parameter has changed to :class:`str`.
""" """
state = self._state state = self._state
@ -5113,20 +5140,107 @@ class Guild(Hashable):
await ws.voice_state(self.id, channel_id, self_mute, self_deaf, self_video, preferred_region=region) await ws.voice_state(self.id, channel_id, self_mute, self_deaf, self_video, preferred_region=region)
async def request(self, **kwargs): # Purposefully left undocumented... async def subscribe(
self, *, typing: bool = MISSING, activities: bool = MISSING, threads: bool = MISSING, member_updates: bool = MISSING
) -> None:
"""|coro| """|coro|
Request a guild. Subscribes to a guild.
This is required to receive most events for large guilds.
.. versionadded:: 2.0 This is required to receive most events and have a populated thread cache for large guilds.
.. note:: .. note::
This is done automatically by default, so you do not need This is done automatically by default, so you do not need
to perform this operation unless you passed ``request_guilds=False`` to perform this operation unless you passed ``guild_subscriptions=False``
to your :class:`Client`. to your :class:`Client`. This is not recommended for most use cases.
.. versionadded:: 2.1
Parameters
-----------
typing: :class:`bool`
Whether to receive typing events (i.e. :func:`discord.on_typing`).
.. note::
This is required to subscribe to large guilds (over 75,000 members).
activities: :class:`bool`
Currently unknown.
threads: :class:`bool`
Whether to populate the thread cache and receive thread events.
member_updates: :class:`bool`
Whether to receive member update events
(i.e. :func:`discord.on_member_join`, :func:`discord.on_member_update`, and :func:`discord.on_member_remove`).
Raises
-------
TypeError
Attempted to subscribe to a guild without subscribing to typing events.
"""
await self._state.subscribe_guild(
self, typing=typing, activities=activities, threads=threads, member_updates=member_updates
)
async def subscribe_to(
self, *, members: Collection[Snowflake] = MISSING, threads: Collection[Snowflake] = MISSING
) -> None:
"""|coro|
Subscribes to specific members and thread member lists in the guild.
Subscribing to members ensures their member and presence information is kept up to date,
ensuring :func:`on_member_update`, :func:`on_user_update`, and :func:`on_presence_update` events are dispatched.
.. versionadded:: 2.1
Parameters
-----------
members: List[:class:`~abc.Snowflake`]
A collection of members to subscribe to.
threads: List[:class:`~abc.Snowflake`]
A collection of threads to subscribe to.
If these threads are cached, they will have their :attr:`~Thread.members` cache populated.
Raises
-------
ValueError
The guild is not subscribed to.
The subscription payload is too large.
"""
subscriptions = self._state.subscriptions
if members:
await subscriptions.subscribe_to_members(self, *members)
if threads:
await subscriptions.subscribe_to_threads(self, *threads)
async def unsubscribe_from(
self, *, members: Collection[Snowflake] = MISSING, threads: Collection[Snowflake] = MISSING
) -> None:
"""|coro|
Unsubscribes from specific members and thread member lists in the guild.
Any unknown members or threads are ignored.
.. versionadded:: 2.1
Parameters
-----------
members: List[:class:`~abc.Snowflake`]
A collection of members to unsubscribe from.
threads: List[:class:`~abc.Snowflake`]
A collection of threads to unsubscribe from.
Raises
-------
ValueError
The guild is not subscribed to.
""" """
await self._state.request_guild(self.id, **kwargs) subscriptions = self._state.subscriptions
if members:
await subscriptions.unsubscribe_from_members(self, *members)
if threads:
await subscriptions.unsubscribe_from_threads(self, *threads)
async def automod_rules(self) -> List[AutoModRule]: async def automod_rules(self) -> List[AutoModRule]:
"""|coro| """|coro|

23
discord/raw_models.py

@ -37,6 +37,7 @@ if TYPE_CHECKING:
from .state import ConnectionState from .state import ConnectionState
from .threads import Thread from .threads import Thread
from .types.gateway import ( from .types.gateway import (
GuildMemberRemoveEvent,
IntegrationDeleteEvent, IntegrationDeleteEvent,
MessageAckEvent, MessageAckEvent,
MessageDeleteBulkEvent as BulkMessageDeleteEvent, MessageDeleteBulkEvent as BulkMessageDeleteEvent,
@ -50,6 +51,7 @@ if TYPE_CHECKING:
ThreadDeleteEvent, ThreadDeleteEvent,
ThreadMembersUpdate, ThreadMembersUpdate,
) )
from .user import User
ReactionActionEvent = Union[MessageReactionAddEvent, MessageReactionRemoveEvent] ReactionActionEvent = Union[MessageReactionAddEvent, MessageReactionRemoveEvent]
ReactionActionType = Literal['REACTION_ADD', 'REACTION_REMOVE'] ReactionActionType = Literal['REACTION_ADD', 'REACTION_REMOVE']
@ -65,6 +67,7 @@ __all__ = (
'RawIntegrationDeleteEvent', 'RawIntegrationDeleteEvent',
'RawThreadDeleteEvent', 'RawThreadDeleteEvent',
'RawThreadMembersUpdate', 'RawThreadMembersUpdate',
'RawMemberRemoveEvent',
'RawMessageAckEvent', 'RawMessageAckEvent',
'RawUserFeatureAckEvent', 'RawUserFeatureAckEvent',
'RawGuildFeatureAckEvent', 'RawGuildFeatureAckEvent',
@ -357,6 +360,26 @@ class RawThreadMembersUpdate(_RawReprMixin):
self.data: ThreadMembersUpdate = data self.data: ThreadMembersUpdate = data
class RawMemberRemoveEvent(_RawReprMixin):
"""Represents the payload for a :func:`on_raw_member_remove` event.
.. versionadded:: 2.1
Attributes
----------
user: Union[:class:`discord.User`, :class:`discord.Member`]
The user that left the guild.
guild_id: :class:`int`
The ID of the guild the user left.
"""
__slots__ = ('user', 'guild_id')
def __init__(self, data: GuildMemberRemoveEvent, user: User, /) -> None:
self.user: Union[User, Member] = user
self.guild_id: int = int(data['guild_id'])
class RawMessageAckEvent(_RawReprMixin): class RawMessageAckEvent(_RawReprMixin):
"""Represents the event payload for a :func:`on_raw_message_ack` event. """Represents the event payload for a :func:`on_raw_message_ack` event.

994
discord/state.py

File diff suppressed because it is too large

57
discord/threads.py

@ -46,7 +46,9 @@ if TYPE_CHECKING:
from datetime import date, datetime from datetime import date, datetime
from typing_extensions import Self from typing_extensions import Self
from .types.gateway import ThreadMemberListUpdateEvent
from .types.threads import ( from .types.threads import (
BaseThreadMember as BaseThreadMemberPayload,
Thread as ThreadPayload, Thread as ThreadPayload,
ThreadMember as ThreadMemberPayload, ThreadMember as ThreadMemberPayload,
ThreadMetadata, ThreadMetadata,
@ -875,18 +877,23 @@ class Thread(Messageable, Hashable):
All thread members in the thread. All thread members in the thread.
""" """
state = self._state state = self._state
await state.ws.request_lazy_guild(self.parent.guild.id, thread_member_lists=[self.id]) # type: ignore await state.subscriptions.subscribe_to_threads(self.guild, self)
future = state.ws.wait_for('thread_member_list_update', lambda d: int(d['thread_id']) == self.id) future = state.ws.wait_for('thread_member_list_update', lambda d: int(d['thread_id']) == self.id)
try: try:
data = await asyncio.wait_for(future, timeout=15) data: ThreadMemberListUpdateEvent = await asyncio.wait_for(future, timeout=15)
except asyncio.TimeoutError as exc: except asyncio.TimeoutError as exc:
raise InvalidData('Didn\'t receieve a response from Discord') from exc raise InvalidData('Didn\'t receieve a response from Discord') from exc
members = [ThreadMember(self, {'member': member}) for member in data['members']] # type: ignore # Check if we are in the cache
for m in members: _self = self.guild.get_thread(self.id)
self._add_member(m) if _self is not None:
return _self.members # Includes correct self.me
return self.members # Includes correct self.me else:
members = [ThreadMember(self, member) for member in data['members']]
for m in members:
self._add_member(m)
return self.members # Includes correct self.me
async def delete(self) -> None: async def delete(self) -> None:
"""|coro| """|coro|
@ -925,7 +932,7 @@ class Thread(Messageable, Hashable):
return PartialMessage(channel=self, id=message_id) return PartialMessage(channel=self, id=message_id)
def _add_member(self, member: ThreadMember, /) -> None: def _add_member(self, member: ThreadMember, /) -> None:
if member.id != self._state.self_id: if member.id != self._state.self_id or self.me is None:
self._members[member.id] = member self._members[member.id] = member
def _pop_member(self, member_id: int, /) -> Optional[ThreadMember]: def _pop_member(self, member_id: int, /) -> Optional[ThreadMember]:
@ -963,10 +970,10 @@ class ThreadMember(Hashable):
The thread's ID. The thread's ID.
joined_at: Optional[:class:`datetime.datetime`] joined_at: Optional[:class:`datetime.datetime`]
The time the member joined the thread in UTC. The time the member joined the thread in UTC.
Only reliably available for yourself or members joined while the user is connected to the gateway. Only reliably available for yourself or members joined while the client is connected to the Gateway.
flags: :class:`int` flags: :class:`int`
The thread member's flags. Will be its own class in the future. The thread member's flags. Will be its own class in the future.
Only reliably available for yourself or members joined while the user is connected to the gateway. Only reliably available for yourself or members joined while the client is connected to the Gateway.
""" """
__slots__ = ( __slots__ = (
@ -978,15 +985,16 @@ class ThreadMember(Hashable):
'parent', 'parent',
) )
def __init__(self, parent: Thread, data: ThreadMemberPayload) -> None: def __init__(self, parent: Thread, data: Union[BaseThreadMemberPayload, ThreadMemberPayload]) -> None:
self.parent: Thread = parent self.parent: Thread = parent
self.thread_id: int = parent.id
self._state: ConnectionState = parent._state self._state: ConnectionState = parent._state
self._from_data(data) self._from_data(data)
def __repr__(self) -> str: def __repr__(self) -> str:
return f'<ThreadMember id={self.id} thread_id={self.thread_id} joined_at={self.joined_at!r}>' return f'<ThreadMember id={self.id} thread_id={self.thread_id} joined_at={self.joined_at!r}>'
def _from_data(self, data: ThreadMemberPayload) -> None: def _from_data(self, data: Union[BaseThreadMemberPayload, ThreadMemberPayload]) -> None:
state = self._state state = self._state
self.id: int self.id: int
@ -995,24 +1003,19 @@ class ThreadMember(Hashable):
except KeyError: except KeyError:
self.id = state.self_id # type: ignore self.id = state.self_id # type: ignore
self.thread_id: int
try:
self.thread_id = int(data['id'])
except KeyError:
self.thread_id = self.parent.id
self.joined_at = parse_time(data.get('join_timestamp')) self.joined_at = parse_time(data.get('join_timestamp'))
self.flags = data.get('flags') self.flags = data.get('flags')
if (mdata := data.get('member')) is not None: guild = state._get_guild(self.parent.guild.id)
guild = self.parent.guild if not guild:
mdata['guild_id'] = guild.id return
self.id = user_id = int(data['user_id'])
mdata['presence'] = data.get('presence') member_data = data.get('member')
if guild.get_member(user_id) is not None: if member_data is not None:
state.parse_guild_member_update(mdata) state._handle_member_update(guild, member_data)
else: presence = data.get('presence')
state.parse_guild_member_add(mdata) if presence is not None:
state._handle_presence_update(guild, presence)
@property @property
def thread(self) -> Thread: def thread(self) -> Thread:

32
discord/types/gateway.py

@ -24,7 +24,7 @@ DEALINGS IN THE SOFTWARE.
from __future__ import annotations from __future__ import annotations
from typing import Generic, List, Literal, Optional, TypedDict, TypeVar, Union from typing import Generic, Dict, List, Literal, Optional, Tuple, TypedDict, TypeVar, Union
from typing_extensions import NotRequired, Required from typing_extensions import NotRequired, Required
from .activity import Activity, BasePresenceUpdate, PartialPresenceUpdate, StatusType from .activity import Activity, BasePresenceUpdate, PartialPresenceUpdate, StatusType
@ -49,7 +49,7 @@ from .scheduled_event import GuildScheduledEvent
from .snowflake import Snowflake from .snowflake import Snowflake
from .sticker import GuildSticker from .sticker import GuildSticker
from .subscriptions import PremiumGuildSubscriptionSlot from .subscriptions import PremiumGuildSubscriptionSlot
from .threads import Thread, ThreadMember from .threads import BaseThreadMember, Thread, ThreadMember
from .user import ( from .user import (
Connection, Connection,
FriendSuggestion, FriendSuggestion,
@ -315,8 +315,15 @@ class ThreadMembersUpdate(TypedDict):
removed_member_ids: NotRequired[List[Snowflake]] removed_member_ids: NotRequired[List[Snowflake]]
class ThreadMemberListUpdateEvent(TypedDict):
guild_id: Snowflake
thread_id: Snowflake
members: List[BaseThreadMember]
class GuildMemberAddEvent(MemberWithUser): class GuildMemberAddEvent(MemberWithUser):
guild_id: Snowflake guild_id: Snowflake
presence: NotRequired[BasePresenceUpdate]
class SnowflakeUser(TypedDict): class SnowflakeUser(TypedDict):
@ -607,6 +614,23 @@ class CallDeleteEvent(TypedDict):
unavailable: NotRequired[bool] unavailable: NotRequired[bool]
class BaseGuildSubscribePayload(TypedDict, total=False):
typing: bool
threads: bool
activities: bool
member_updates: bool
members: List[Snowflake]
channels: Dict[Snowflake, List[Tuple[int, int]]]
thread_member_lists: List[Snowflake]
class GuildSubscribePayload(BaseGuildSubscribePayload):
guild_id: Snowflake
BulkGuildSubscribePayload = Dict[Snowflake, BaseGuildSubscribePayload]
class _GuildMemberListGroup(TypedDict): class _GuildMemberListGroup(TypedDict):
id: Union[Snowflake, Literal['online', 'offline']] id: Union[Snowflake, Literal['online', 'offline']]
@ -628,7 +652,7 @@ GuildMemberListItem = Union[_GuildMemberListGroupItem, _GuildMemberListMemberIte
class GuildMemberListSyncOP(TypedDict): class GuildMemberListSyncOP(TypedDict):
op: Literal['SYNC'] op: Literal['SYNC']
range: tuple[int, int] range: Tuple[int, int]
items: List[GuildMemberListItem] items: List[GuildMemberListItem]
@ -651,7 +675,7 @@ class GuildMemberListDeleteOP(TypedDict):
class GuildMemberListInvalidateOP(TypedDict): class GuildMemberListInvalidateOP(TypedDict):
op: Literal['INVALIDATE'] op: Literal['INVALIDATE']
range: tuple[int, int] range: Tuple[int, int]
GuildMemberListOP = Union[ GuildMemberListOP = Union[

12
discord/types/threads.py

@ -27,18 +27,28 @@ from __future__ import annotations
from typing import List, Literal, Optional, TypedDict from typing import List, Literal, Optional, TypedDict
from typing_extensions import NotRequired from typing_extensions import NotRequired
from .snowflake import Snowflake from .activity import BasePresenceUpdate
from .member import MemberWithUser
from .message import Message from .message import Message
from .snowflake import Snowflake
ThreadType = Literal[10, 11, 12] ThreadType = Literal[10, 11, 12]
ThreadArchiveDuration = Literal[60, 1440, 4320, 10080] ThreadArchiveDuration = Literal[60, 1440, 4320, 10080]
class BaseThreadMember(TypedDict):
user_id: Snowflake
member: MemberWithUser
presence: BasePresenceUpdate
class ThreadMember(TypedDict): class ThreadMember(TypedDict):
id: Snowflake id: Snowflake
user_id: Snowflake user_id: Snowflake
join_timestamp: str join_timestamp: str
flags: int flags: int
member: NotRequired[MemberWithUser]
presence: NotRequired[BasePresenceUpdate]
class ThreadMetadata(TypedDict): class ThreadMetadata(TypedDict):

11
discord/utils.py

@ -885,11 +885,16 @@ class SnowflakeList(_SnowflakeListBase):
if TYPE_CHECKING: if TYPE_CHECKING:
def __init__(self, data: Iterable[int], *, is_sorted: bool = False): def __init__(self, data: Optional[Iterable[int]] = None, *, is_sorted: bool = False):
... ...
def __new__(cls, data: Iterable[int], *, is_sorted: bool = False) -> Self: def __new__(cls, data: Optional[Iterable[int]] = None, *, is_sorted: bool = False) -> Self:
return array.array.__new__(cls, 'Q', data if is_sorted else sorted(data)) # type: ignore if data:
return array.array.__new__(cls, 'Q', data if is_sorted else sorted(data)) # type: ignore
return array.array.__new__(cls, 'Q') # type: ignore
def __contains__(self, element: int) -> bool:
return self.has(element)
def add(self, element: int) -> None: def add(self, element: int) -> None:
i = bisect_left(self, element) i = bisect_left(self, element)

32
docs/api.rst

@ -979,13 +979,34 @@ Members
~~~~~~~~ ~~~~~~~~
.. function:: on_member_join(member) .. function:: on_member_join(member)
on_member_remove(member)
Called when a :class:`Member` join or leaves a :class:`Guild`. Called when a :class:`Member` joins a :class:`Guild`.
:param member: The member who joined or left. :param member: The member who joined.
:type member: :class:`Member`
.. function:: on_member_remove(member)
Called when a :class:`Member` leaves a :class:`Guild`.
If the guild or member could not be found in the internal cache this event
will not be called, you may use :func:`on_raw_member_remove` instead.
:param member: The member who left.
:type member: :class:`Member` :type member: :class:`Member`
.. function:: on_raw_member_remove(payload)
Called when a :class:`Member` leaves a :class:`Guild`.
Unlike :func:`on_member_remove`
this is called regardless of the guild or member being in the internal cache.
.. versionadded:: 2.1
:param payload: The raw event payload data.
:type payload: :class:`RawMemberRemoveEvent`
.. function:: on_member_update(before, after) .. function:: on_member_update(before, after)
Called when a :class:`Member` updates their profile. Called when a :class:`Member` updates their profile.
@ -7911,6 +7932,11 @@ RawEvent
.. autoclass:: RawThreadDeleteEvent() .. autoclass:: RawThreadDeleteEvent()
:members: :members:
.. attributetable:: RawMemberRemoveEvent
.. autoclass:: RawMemberRemoveEvent()
:members:
.. attributetable:: RawMessageAckEvent .. attributetable:: RawMessageAckEvent
.. autoclass:: RawMessageAckEvent() .. autoclass:: RawMessageAckEvent()

16
docs/token.rst → docs/authenticating.rst

@ -1,13 +1,16 @@
:orphan: :orphan:
.. _tokens: .. _authenticating:
Authenticating
==============
Tokens Tokens
======= -------
Tokens are how we authenticate with Discord. Tokens are how we authenticate with Discord. User accounts use the same token system as bots, received after authenticating with the Discord API.
Regular (and bot) tokens have this format: They follow this format:
.. list-table:: Discord Token .. list-table:: Discord Token
:header-rows: 1 :header-rows: 1
@ -25,11 +28,10 @@ Regular (and bot) tokens have this format:
- Unix TS - Unix TS
- HMAC - HMAC
MFA tokens, however, are just the HMAC prefixed with ``mfa.``.
How do I obtain mine? How do I obtain mine?
---------------------- ----------------------
The library does not yet support authenticating traditionally, so you will have to obtain your token manually.
To obtain your token from the Discord client, the easiest way is pasting this into the developer console (CTRL+SHIFT+I): To obtain your token from the Discord client, the easiest way is pasting this into the developer console (CTRL+SHIFT+I):
.. code:: js .. code:: js

42
docs/guild_subscriptions.rst

@ -0,0 +1,42 @@
:orphan:
.. currentmodule:: discord
.. _guild_subscriptions:
Guild Subscriptions
====================
Guild subscriptions are a way for a client to limit the events it receives from a guild.
While this is useful for a traditional client, it may not be of much use for a bot, so the default library behavior is to subscribe to as much as possible at startup.
The client is automatically subscribed to all guilds with less than 75,000 members on connect. For guilds the client is not subscribed to, you will not receive
non-stateful events (e.g. :func:`discord.on_message`, :func:`discord.on_message_edit`, :func:`discord.on_message_delete`, etc.).
Additionally, voice states and channel unreads are kept up to date passively, as the client does not receive events for these updates in real-time.
For every guild, clients can opt to subscribe to additional features, such as typing events (i.e. :func:`discord.on_typing`), a full thread list cache, and member updates (i.e. :func:`discord.on_member_join`, :func:`discord.on_member_update`, and :func:`discord.on_member_remove`).
Additionally, clients can subscribe to specific members and threads within a guild. When subscribed to specific members (or thread member lists), the client will receive member and presence updates for those members (i.e. :func:`discord.on_member_update` and :func:`discord.on_presence_update`).
Additionally, for guilds with less than 75,000 members, the client is automatically subscribed to all friends, implicit relationships, and members the user has open DMs with at startup.
Irrespective of subscribed members, events for actions the client performs (e.g. changing a user's nickname, kicking a user, banning a user, etc.) will always be received.
While events like :func:`discord.on_raw_member_remove` are always dispatched when received, events like :func:`discord.on_member_update` are only dispatched if the member is present in the cache.
Guild subscriptions are also used to subscribe to the member list of a specific channel in a guild, but this ability is not yet exposed in the library.
Drawbacks
~~~~~~~~~~
For library users, the biggest drawback to guild subscriptions is that there is no way to reliably get the entire member list of a guild.
An additional drawback is that there is no way to subscribe to presence updates for all members in a guild. At most, you can subscribe to presence updates for specific members and thread member lists.
Note that clients always receive presence updates for friends, implicit relationships, and users they have an open DM (and mutual server) with.
Implementation
~~~~~~~~~~~~~~~
If you would like to override the default behavior and manage guild subscriptions yourself, you can set the ``guild_subscriptions`` parameter to ``False`` when creating a :class:`Client`.
If you do this, you cannot use the ``chunk_guilds_at_startup`` parameter, as it is dependent on guild subscriptions.
To subscribe to a guild (and manage features), see the :meth:`Guild.subscribe` method. To manage subscriptions to a guild's members or threads, see the :meth:`Guild.subscribe_to` and :meth:`Guild.unsubscribe_from` methods.
Subscription requests are debounced before being sent to the Gateway, so changes may take up to half a second to take effect (this is an implementation detail that may be changed at any time).

4
docs/index.rst

@ -23,7 +23,7 @@ Is this your first time using the library? This is the place to get started!
- **Migrating from discord.py:** :doc:`migrating_from_dpy` - **Migrating from discord.py:** :doc:`migrating_from_dpy`
- **First steps:** :doc:`intro` | :doc:`quickstart` | :doc:`logging` - **First steps:** :doc:`intro` | :doc:`quickstart` | :doc:`logging`
- **Working with Discord:** :doc:`token` - **Working with Discord:** :doc:`authenticating` | :doc:`guild_subscriptions`
- **Examples:** Many examples are available in the :resource:`repository <examples>` - **Examples:** Many examples are available in the :resource:`repository <examples>`
| **Obligatory note:** | **Obligatory note:**
@ -73,4 +73,4 @@ If you're looking for something related to the project itself, it's here.
whats_new whats_new
version_guarantees version_guarantees
migrating_from_dpy migrating_from_dpy
migrating migrating

102
docs/migrating_from_dpy.rst

@ -13,90 +13,62 @@ Most things bots can do, users can (in some capacity) as well. The biggest diffe
However, a number of things have been removed. However, a number of things have been removed.
For example: For example:
- ``Intents``: While the gateway technically accepts intents for user accounts, it can break things and is a giant waving red flag to Discord. - ``Intents``: While the gateway technically accepts intents for user accounts, they are—for the most part—useless and can break things.
- ``Shards``: Again, technically accepted but useless. - ``Shards``: Just like intents, users can utilize sharding but it is not very useful.
- ``discord.ui``: Users cannot utilize the bot UI kit. - ``discord.ui``: Users cannot utilize the bot UI kit.
- ``discord.app_commands``: Users cannot register application commands. - ``discord.app_commands``: Users cannot register application commands.
Additionally, existing payloads and headers have been changed to match the Discord client. However, even in features that are shared between user and bot accounts, there may be variance in functionality or simply different design choices that better reflect a user account implementation.
An effort is made to minimize these differences and avoid migration pain, but it is not always the first priority.
Guild members Guild Subscriptions
-------------- -------------------
| Since the concept of Intents (mostly) doesn't exist for user accounts; you just get all events, right?
| Well, yes but actually no.
For 80% of things, events are identical to bot events. However, other than the quite large amount of new events, not all events work the same. Guild subscriptions are a way for a client to limit the events it receives from a guild. For more information about guild subscriptions, see the :doc:`guild_subscriptions` section.
The biggest example of this are the events ``on_member_add``, ``on_member_update``\/``on_user_update``, ``on_member_remove``, and ``on_presence_update``. When compared to a bot account, the most noticeable differences they introduce are in relation to guild members and presence.
(If you're just looking for the implementation, skip to the bottom of this section.) Guild Members
~~~~~~~~~~~~~~
Bots The concept of privileged intents does not exist for user accounts, so guild member access is limited in different ways.
~~~~~
For bots (with the member intent), it's simple.
They request all guild members with an OPCode 8 (chunk the guild), and receive respective ``GUILD_MEMBER_*`` events, that are then parsed by the library and dispatched to users. By default, the library will subscribe to member updates for all guilds, meaning that events such as :func:`discord.on_member_join` and :func:`discord.on_raw_member_remove` will be dispatched for all guilds the user is in.
However, events that require the member cache to be populated (such as :func:`discord.on_member_update`) are only dispatched for guilds that are chunked.
If the bot has the presence intent, it even gets an initial member cache in the ``GUILD_CREATE`` event and receives ``PRESENCE_UPDATE``. A guild can only be chunked (have the local member cache populated) if the user has the :attr:`~Permissions.manage_roles`, :attr:`~Permissions.kick_members`, or :attr:`~Permissions.ban_members` permissions.
Additionally, guilds with less than 1,000 members may be chunked if there exists at least one channel that everyone can view.
By default, the library will attempt to chunk all guilds that are chunkable. This can be disabled by setting the ``chunk_guilds_at_startup`` parameter to ``False`` when creating a :class:`Client`.
Users If a guild is not chunked, the only members that will be cached are members with an active voice state and, if the guild has less than 75,000 members, members that the user is friends, has an implicit relationship, or has an open DM with.
~~~~~~
| Users, however, do not work like this.
| If you have one of kick members, ban members, or manage roles, you can request all guild members the same way bots do. The client uses this in various areas of guild settings.
| But, here's the twist: users do not receive ``GUILD_MEMBER_*`` reliably. The library offers two avenues to get the "entire" member list of a guild.
| They receive them in certain circumstances (such as when subscribing to updates for specific users), but they're usually rare and nothing to be relied on.
If the Discord client ever needs member objects for specific users, it sends an OPCode 8 with the specific user IDs/names.
This is why this is recommended if you want to fetch specific members (implemented as :func:`Guild.query_members` in the library).
However, the maximum amount of members you can get with this method is 100 per request.
But, you may be thinking, how does the member sidebar work? Why can't you just utilize that? This is where it gets complicated.
First, let's make sure we understand a few things:
- The API doesn't differentiate between offline and invisible members (for a good reason).
- The concept of a member sidebar is not per-guild, it's per-channel. This makes sense if you think about it, since the member sidebar only shows users that have access to a specific channel. Member lists have IDs that can be calculated from channel permission overwrites to find unique member lists.
- If a server has >1,000 members, the member sidebar does **not** have offline members.
The member sidebar uses OPCode 14 and the ``GUILD_MEMBER_LIST_UPDATE`` event.
One more thing you need to understand, is that the member sidebar is lazily loaded.
You usually subscribe to 100 member ranges, and can subscribe to 5 per-channel per-request (up to 5 channels a request).
If the guild's member count has never been above 75,000 members, you can subscribe to 400 member ranges instead.
So, to subscribe to all available ranges, you need to spam the gateway quite a bit (especially for large guilds).
Additionally, while you can subscribe to 5 channels/request, the channels need to have the same permissions, or you'll be subscribing to two different lists (not ideal).
| Once you subscribe to a range, you'll receive ``GUILD_MEMBER_LIST_UPDATE`` s for it whenever someone is added to it (i.e. someone joined the guild, changed their nickname so they moved in the member list alphabetically, came online, etc.), removed from it (i.e. someone left the guild, went offline, changed their nickname so they moved in the member sidebar alphabetically), or updated in it (i.e. someone got their roles changed, or changed their nickname but remained in the same position).
| These can be parsed and dispatched as ``on_member_add``, ``on_member_update``\/``on_user_update``, ``on_member_remove``, and ``on_presence_update``.
You may have already noticed a few problems with this: - :func:`Guild.chunk`: If chunking guilds at startup is disabled, you can use this method to chunk a guild manually.
- :func:`Guild.fetch_members`: If you have the permissions to request all guild members, you can use this method to fetch the entire member list. Else, this method scrapes the member sidebar (which can become very slow), only returning online members if the guild has more than 1,000 members.
1. You'll get spammed with ``member_add/remove`` s whenever someone changes position in the member sidebar. Presence
2. For guilds with >1,000 members, you don't receive offline members. So, you won't know if an offline member is kicked, or an invisible member joins/leaves. You also won't know if someone came online or joined. Or, if someone went offline or left. ~~~~~~~~~
| #1 is mostly solveable with a bit of parsing, but #2 is a huge problem. User accounts are always synced the overall presence of friends and implicit relationships, tracked in the library via the :class:`Relationship` class. Overall user presence updates will dispatch a :func:`discord.on_presence_update` event with :class:`Relationship` instances.
| If you have the permissions to request all guild members, you can combine that with member sidebar scraping and get a *decent* local member cache. However, because of the nature of this (and the fact that you'll have to request all guild membesr again every so often), accurate events are nearly impossible. Additionally, for guilds with less than 75,000 members, they're synced the per-guild presence of members that the user is friends, has an implicit relationship, or has an open DM with.
Additionally, there are more caveats: Outside of these cases, you will not receive presence updates for any other users. To obtain the presence of an arbitrary user, you can use the :meth:`Guild.query_members` method.
To stay informed of presence updates for a specific user, you can subscribe to them using the :meth:`Guild.subscribe_to` method. See the :doc:`guild_subscriptions` section for more information.
1. ``GUILD_MEMBER_LIST_UPDATE`` removes provide an index, not a user ID. The index starts at 0 from the top of the member sidebar and includes hoisted roles. .. note::
2. You get ratelimited pretty fast, so scraping can take minutes for extremely large guilds.
3. The scraping has to happen every time the bot starts. This not only slows things down, but *may* make Discord suspicious.
4. Remember that member sidebars are per-channel? Well, that means you can only subscribe all members that can *see* the channel(s) you're subscribing too.
#1 is again solveable with a bit of parsing. There's not much you can do about #2 and #3. But, to solve #4, you *can* subscribe to multiple channels (which has problems of its own and makes events virtually impossible). User updates (i.e. :func:`discord.on_user_update`) require either member updates (for at least one guild) or presence updates to be dispatched for the user as outlined above.
There are a few more pieces of the puzzle: AutoMod
--------
- There is a ``/guilds/:id/roles/:id/member-ids`` endpoint that provides up to 100 member IDs for any role other than the default role. You can use :func:`Guild.query_members` to fetch all these members in one go. The following Gateway events are not dispatched to user accounts:
- With OPCode 14, you can subscribe to certain member IDs and receive member/presence updates for them. There is no limit to the amount of IDs you can subscribe to (except for the gateway payload size limit).
- Thread member sidebars do *not* work the same. You just send an OPCode 14 with the thread IDs and receive a ``THREAD_MEMBER_LIST_UPDATE`` with all the members. The cache then stays updated with ``GUILD_MEMBER_UPDATE`` and ``THREAD_MEMBERS_UPDATE`` events.
Implementation - ``on_automod_rule_create``
~~~~~~~~~~~~~~~ - ``on_automod_rule_update``
The library offers two avenues to get the "entire" member list of a guild. - ``on_automod_rule_delete``
- ``on_automod_action``
- :func:`Guild.chunk`: If a guild has less than 1,000 members, and has at least one channel that everyone can view, you can use this method to fetch the entire member list by scraping the member sidebar. With this method, you also get events. The first three can be replaced by listening to the :func:`discord.on_audit_log_entry_create` event and checking the :attr:`~discord.AuditLogEntry.action` attribute.
- :func:`Guild.fetch_members`: If you have the permissions to request all guild members, you can use this method to fetch the entire member list. Else, this method scrapes the member sidebar (which can become very slow), this only returns online members if the guild has more than 1,000 members. This method does not get events. The last one is partially replaceable by listening to the :func:`discord.on_message` event and checking for AutoMod system messages, but this is not a perfect solution.

2
docs/quickstart.rst

@ -58,7 +58,7 @@ There's a lot going on here, so let's walk you through it step by step.
then we send a message in the channel it was used in with ``'Hello!'``. This is a basic way of then we send a message in the channel it was used in with ``'Hello!'``. This is a basic way of
handling commands, which can be later automated with the :doc:`./ext/commands/index` framework. handling commands, which can be later automated with the :doc:`./ext/commands/index` framework.
6. Finally, we run the bot with our login token. If you need help getting your token, 6. Finally, we run the bot with our login token. If you need help getting your token,
look in the :doc:`token` section. look in the :doc:`authenticating` section.
Now that we've made a bot, we have to *run* the bot. Luckily, this is simple since this is just a Now that we've made a bot, we have to *run* the bot. Luckily, this is simple since this is just a

Loading…
Cancel
Save