From b31b9527ed2cd7357f674580782d75020a9fb6e4 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Wed, 18 Jun 2025 22:38:11 +0200 Subject: [PATCH 1/2] add cache options and global ratelimit preventions --- discord/client.py | 156 ++++++++++++++++++++++++++++++++++++++++++++++ discord/http.py | 23 +++++++ discord/state.py | 24 +++++-- 3 files changed, 198 insertions(+), 5 deletions(-) diff --git a/discord/client.py b/discord/client.py index c620dc23a9b5..811c10b50dd4 100644 --- a/discord/client.py +++ b/discord/client.py @@ -125,6 +125,7 @@ # fmt: off __all__ = ( 'Client', + 'CacheOptions', ) # fmt: on @@ -150,6 +151,151 @@ def __getattr__(self, attr: str) -> None: _loop: Any = _LoopSentinel() +class CacheOptions: + """Represents a :class:`discord.Client` cache control. + + Using this may allow you to control what things you want your client + to cache. + + .. warning:: + + Using this may result on unexpected behaviour on certain parts of the + library. For example, disabling ``guilds`` may cause problems on objects + with a ``.guild`` attribute returning ``None``. + + All parameters default to ``True`` unless explicitly set to ``False``. + + Parameters + ---------- + guilds: :class:`bool` + Whether to cache guilds. Defaults to whether :attr:`discord.Intents.guilds` is enabled + or not. + users: :class:`bool` + Whether to cache users. + members: :class:`bool` + Whether to cache members. Defaults to whether :attr:`discord.Intents.members` is enabled + or not. + presences: :class:`bool` + Whether to cache members. Defaults to whether :attr:`discord.Intents.presences` is enabled + or not. + voice_states: :class:`bool` + Whether to cache voice states. Defaults to whether :attr:`discord.Intents.voice_states` is enabled + or not. + emojis_and_stickers: :class:`bool` + Whether to cache emojis and stickers. Defaults to whether :attr:`discord.Intents.emojis_and_stickers` is enabled + or not. + soundboard_sounds: :class:`bool` + Whether to cache guild's soundboard sounds. + private_channels: :class:`bool` + Whether to cache private channels (aka DMs/Group DMS). + guild_channels: :class:`bool` + Whether to cache guild channels. + roles: :class:`bool` + Whether to cache guild roles. + threads: :class:`bool` + Whether to cache threads. + """ + + __valid_flags__ = ( + 'guilds', + 'users', + 'members', + 'presences', + 'voice_states', + 'emojis_and_stickers', + 'soundboard_sounds', + 'private_channels', + 'guild_channels', + 'roles', + 'threads', + ) + + if TYPE_CHECKING: + guilds: bool + users: bool + members: bool + presences: bool + voice_states: bool + emojis_and_stickers: bool + soundboard_sounds: bool + private_channels: bool + guild_channels: bool + roles: bool + threads: bool + + @overload + def __init__( + self, + *, + guilds: bool = MISSING, + users: bool = MISSING, + members: bool = MISSING, + presences: bool = MISSING, + voice_states: bool = MISSING, + emojis_and_stickers: bool = MISSING, + soundboard_sounds: bool = MISSING, + private_channels: bool = MISSING, + guild_channels: bool = MISSING, + roles: bool = MISSING, + threads: bool = MISSING, + ) -> None: ... + + @overload + def __init__( + self, + **kwargs: bool, + ) -> None: ... + + def __init__( + self, + **kwargs: bool, + ) -> None: + self._cache_data = kwargs + + def __getattr__(self, name: str) -> Any: + + val = self._cache_data.get(name) + if val is not None: + return val if val is not MISSING else False + if name in self.__valid_flags__: + return True # the flag has not been set, but assume they want it enabled + raise AttributeError(f'attribute {name!r} does not exist for {self.__class__.__name__!r}') + + def __setattr__(self, name: str, value: bool) -> None: + if name not in self.__valid_flags__: + raise AttributeError(f'attribute {name!r} does not exist for {self.__class__.__name__!r}') + self._cache_data[name] = value + + @classmethod + def from_intents(cls, intents: Intents, /) -> CacheOptions: + """Creates a new :class:`CacheOptions` instance from a :class:`Intents` + one. + + Parameters + ---------- + intents: :class:`Intents` + The intents to use. + + Returns + ------- + :class:`CacheOptions` + A new instance of cache options. + """ + + return cls( + guilds=intents.guilds, + members=intents.members, + presences=intents.presences, + voice_states=intents.voice_states, + emojis_and_stickers=intents.emojis_and_stickers, + ) + + def _update_from_intents(self, intents: Intents) -> None: + for key in ('guilds', 'members', 'presences', 'voice_states', 'emojis_and_stickers'): + ret = getattr(self, key, False) + self._cache_data[key] = ret or getattr(intents, key, False) + + class Client: r"""Represents a client connection that connects to Discord. This class is used to interact with the Discord WebSocket and API. @@ -265,6 +411,10 @@ class Client: behavior, such as setting a dns resolver or sslcontext. .. versionadded:: 2.5 + global_ratelimit_sleep_time: :class:`float` + The time to sleep to prevent global ratelimits. Defaults to ``5``. + cache_options: :class:`CacheOptions` + The cache options of this client. Attributes ----------- @@ -286,6 +436,7 @@ def __init__(self, *, intents: Intents, **options: Any) -> None: unsync_clock: bool = options.pop('assume_unsync_clock', True) http_trace: Optional[aiohttp.TraceConfig] = options.pop('http_trace', None) max_ratelimit_timeout: Optional[float] = options.pop('max_ratelimit_timeout', None) + global_sleep_time: Optional[float] = options.pop('global_ratelimit_sleep_time', None) self.http: HTTPClient = HTTPClient( self.loop, connector, @@ -294,6 +445,7 @@ def __init__(self, *, intents: Intents, **options: Any) -> None: unsync_clock=unsync_clock, http_trace=http_trace, max_ratelimit_timeout=max_ratelimit_timeout, + global_sleep_time=global_sleep_time, ) self._handlers: Dict[str, Callable[..., None]] = { @@ -304,6 +456,10 @@ def __init__(self, *, intents: Intents, **options: Any) -> None: 'before_identify': self._call_before_identify_hook, } + cache_options: Optional[CacheOptions] = options.get('cache_options') + if cache_options is None: + options['cache_options'] = CacheOptions.from_intents(intents) + self._enable_debug_events: bool = options.pop('enable_debug_events', False) self._connection: ConnectionState[Self] = self._get_state(intents=intents, **options) self._connection.shard_count = self.shard_count diff --git a/discord/http.py b/discord/http.py index 4e12de8bd47c..ee3fd8d68630 100644 --- a/discord/http.py +++ b/discord/http.py @@ -516,6 +516,7 @@ def __init__( unsync_clock: bool = True, http_trace: Optional[aiohttp.TraceConfig] = None, max_ratelimit_timeout: Optional[float] = None, + global_sleep_time: Optional[float] = None, ) -> None: self.loop: asyncio.AbstractEventLoop = loop self.connector: aiohttp.BaseConnector = connector or MISSING @@ -539,6 +540,8 @@ def __init__( user_agent = 'DiscordBot (https://github.com/Rapptz/discord.py {0}) Python/{1[0]}.{1[1]} aiohttp/{2}' self.user_agent: str = user_agent.format(__version__, sys.version_info, aiohttp.__version__) + self._request_history: deque[float] = deque() + self.global_sleep_time: float = global_sleep_time if global_sleep_time is not None else 5 def clear(self) -> None: if self.__session and self.__session.closed: @@ -575,6 +578,15 @@ def get_ratelimit(self, key: str) -> Ratelimit: self._try_clear_expired_ratelimits() return value + def clear_global_requests(self, now: Optional[float] = None) -> None: + if now is None: + now = self.loop.time() + + self._request_history = deque( + t for t in self._request_history + if t > (now - 1) + ) + async def request( self, route: Route, @@ -633,6 +645,17 @@ async def request( data: Optional[Union[Dict[str, Any], str]] = None async with ratelimit: for tries in range(5): + now = self.loop.time() + if len(self._request_history) + 1 >= 50: + first = self._request_history.popleft() + + if now - first <= 1: + _log.info(f'Sleeping {self.global_sleep_time} seconds to prevent global ratelimit...') + await asyncio.sleep(self.global_sleep_time) + self.clear_global_requests(now) + + self._request_history.append(now) + if files: for f in files: f.reset(seek=tries) diff --git a/discord/state.py b/discord/state.py index 37bd138a7dd9..b7e39ab3ff79 100644 --- a/discord/state.py +++ b/discord/state.py @@ -94,6 +94,7 @@ from .ui.dynamic import DynamicItem from .app_commands import CommandTree, Translator from .poll import Poll + from .client import CacheOptions from .types.automod import AutoModerationRule, AutoModerationActionExecution from .types.snowflake import Snowflake @@ -271,6 +272,7 @@ def __init__( for attr, func in inspect.getmembers(self): if attr.startswith('parse_'): parsers[attr[6:].upper()] = func + self.cache_options: CacheOptions = options['cache_options'] # this will always be present self.clear() @@ -279,7 +281,7 @@ def __init__( # So this is checked instead, it's a small penalty to pay @property def cache_guild_expressions(self) -> bool: - return self._intents.emojis_and_stickers + return self._intents.emojis_and_stickers and self.cache_options.emojis_and_stickers async def close(self) -> None: for voice in self.voice_clients: @@ -388,7 +390,11 @@ def store_user(self, data: Union[UserPayload, PartialUserPayload], *, cache: boo return self._users[user_id] except KeyError: user = User(state=self, data=data) - if cache: + if cache and self.cache_options.users: + self._users[user_id] = user + + # always keep a reference to the self user + if user_id == self.self_id: self._users[user_id] = user return user @@ -404,12 +410,16 @@ def get_user(self, id: int) -> Optional[User]: def store_emoji(self, guild: Guild, data: EmojiPayload) -> Emoji: # the id will be present here emoji_id = int(data['id']) # type: ignore - self._emojis[emoji_id] = emoji = Emoji(guild=guild, state=self, data=data) + emoji = Emoji(guild=guild, state=self, data=data) + if self.cache_guild_expressions: + self._emojis[emoji_id] = emoji return emoji def store_sticker(self, guild: Guild, data: GuildStickerPayload) -> GuildSticker: sticker_id = int(data['id']) - self._stickers[sticker_id] = sticker = GuildSticker(state=self, data=data) + sticker = GuildSticker(state=self, data=data) + if self.cache_guild_expressions: + self._stickers[sticker_id] = sticker return sticker def store_view(self, view: BaseView, message_id: Optional[int] = None, interaction_id: Optional[int] = None) -> None: @@ -442,7 +452,8 @@ def _get_or_create_unavailable_guild(self, guild_id: int, *, data: Optional[Dict return self._guilds.get(guild_id) or Guild._create_unavailable(state=self, guild_id=guild_id, data=data) def _add_guild(self, guild: Guild) -> None: - self._guilds[guild.id] = guild + if self.cache_options.guilds: + self._guilds[guild.id] = guild def _remove_guild(self, guild: Guild) -> None: self._guilds.pop(guild.id, None) @@ -499,6 +510,9 @@ def _get_private_channel_by_user(self, user_id: Optional[int]) -> Optional[DMCha return self._private_channels_by_user.get(user_id) # type: ignore def _add_private_channel(self, channel: PrivateChannel) -> None: + if not self.cache_options.private_channels: + return + channel_id = channel.id self._private_channels[channel_id] = channel From 604d9747f9c10d0917c3156349f3126be479a244 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 19 Jun 2025 21:59:47 +0200 Subject: [PATCH 2/2] do the cache things --- discord/client.py | 38 ++++++++++++++++++++++++++++++-------- discord/guild.py | 16 ++++++++-------- discord/state.py | 17 +++++++++++++++++ 3 files changed, 55 insertions(+), 16 deletions(-) diff --git a/discord/client.py b/discord/client.py index 811c10b50dd4..2286a272c0b8 100644 --- a/discord/client.py +++ b/discord/client.py @@ -152,14 +152,14 @@ def __getattr__(self, attr: str) -> None: class CacheOptions: - """Represents a :class:`discord.Client` cache control. + """Represents a :class:`Client` cache control. Using this may allow you to control what things you want your client to cache. .. warning:: - Using this may result on unexpected behaviour on certain parts of the + Using this may result in unexpected behaviour in certain parts of the library. For example, disabling ``guilds`` may cause problems on objects with a ``.guild`` attribute returning ``None``. @@ -168,21 +168,21 @@ class CacheOptions: Parameters ---------- guilds: :class:`bool` - Whether to cache guilds. Defaults to whether :attr:`discord.Intents.guilds` is enabled + Whether to cache guilds. Defaults to whether :attr:`Intents.guilds` is enabled or not. users: :class:`bool` Whether to cache users. members: :class:`bool` - Whether to cache members. Defaults to whether :attr:`discord.Intents.members` is enabled + Whether to cache members. Defaults to whether :attr:`Intents.members` is enabled or not. presences: :class:`bool` - Whether to cache members. Defaults to whether :attr:`discord.Intents.presences` is enabled + Whether to cache members. Defaults to whether :attr:`Intents.presences` is enabled or not. voice_states: :class:`bool` - Whether to cache voice states. Defaults to whether :attr:`discord.Intents.voice_states` is enabled + Whether to cache voice states. Defaults to whether :attr:`Intents.voice_states` is enabled or not. emojis_and_stickers: :class:`bool` - Whether to cache emojis and stickers. Defaults to whether :attr:`discord.Intents.emojis_and_stickers` is enabled + Whether to cache emojis and stickers. Defaults to whether :attr:`Intents.emojis_and_stickers` is enabled or not. soundboard_sounds: :class:`bool` Whether to cache guild's soundboard sounds. @@ -194,6 +194,10 @@ class CacheOptions: Whether to cache guild roles. threads: :class:`bool` Whether to cache threads. + scheduled_events: :class:`bool` + Whether to cache scheduled events. + stage_instances: :class:`bool` + Whether to cache stage instances. """ __valid_flags__ = ( @@ -208,6 +212,8 @@ class CacheOptions: 'guild_channels', 'roles', 'threads', + 'scheduled_events', + 'stage_instances', ) if TYPE_CHECKING: @@ -222,6 +228,8 @@ class CacheOptions: guild_channels: bool roles: bool threads: bool + scheduled_events: bool + stage_instances: bool @overload def __init__( @@ -238,6 +246,8 @@ def __init__( guild_channels: bool = MISSING, roles: bool = MISSING, threads: bool = MISSING, + scheduled_events: bool = MISSING, + stage_instances: bool = MISSING, ) -> None: ... @overload @@ -253,7 +263,6 @@ def __init__( self._cache_data = kwargs def __getattr__(self, name: str) -> Any: - val = self._cache_data.get(name) if val is not None: return val if val is not MISSING else False @@ -290,6 +299,19 @@ def from_intents(cls, intents: Intents, /) -> CacheOptions: emojis_and_stickers=intents.emojis_and_stickers, ) + @classmethod + def none(cls) -> CacheOptions: + """Creates a new :class:`CacheOptions` instance with nothing enabled. + + Returns + ------- + :class:`CacheOptions` + A new instance of cache options. + """ + return cls( + **{flag: False for flag in cls.__valid_flags__} + ) + def _update_from_intents(self, intents: Intents) -> None: for key in ('guilds', 'members', 'presences', 'voice_states', 'emojis_and_stickers'): ret = getattr(self, key, False) diff --git a/discord/guild.py b/discord/guild.py index 20a50d4e932f..f38e53fbbccf 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -458,13 +458,13 @@ class Guild(Hashable): } def __init__(self, *, data: GuildPayload, state: ConnectionState) -> None: - self._channels: Dict[int, GuildChannel] = {} - self._members: Dict[int, Member] = {} - self._voice_states: Dict[int, VoiceState] = {} - self._threads: Dict[int, Thread] = {} - self._stage_instances: Dict[int, StageInstance] = {} - self._scheduled_events: Dict[int, ScheduledEvent] = {} - self._soundboard_sounds: Dict[int, SoundboardSound] = {} + self._channels: Dict[int, GuildChannel] = {} if state.cache_options.guild_channels else state.create_immutable_dict() + self._members: Dict[int, Member] = {} if state.cache_options.members else state.create_immutable_dict() + self._voice_states: Dict[int, VoiceState] = {} if state.cache_options.voice_states else state.create_immutable_dict() + self._threads: Dict[int, Thread] = {} if state.cache_options.threads else state.create_immutable_dict() + self._stage_instances: Dict[int, StageInstance] = {} if state.cache_options.stage_instances else state.create_immutable_dict() + self._scheduled_events: Dict[int, ScheduledEvent] = {} if state.cache_options.scheduled_events else state.create_immutable_dict() + self._soundboard_sounds: Dict[int, SoundboardSound] = {} if state.cache_options.soundboard_sounds else state.create_immutable_dict() self._state: ConnectionState = state self._member_count: Optional[int] = None self._from_data(data) @@ -589,7 +589,7 @@ def _from_data(self, guild: GuildPayload) -> None: self._banner: Optional[str] = guild.get('banner') self.unavailable: bool = guild.get('unavailable', False) self.id: int = int(guild['id']) - self._roles: Dict[int, Role] = {} + self._roles: Dict[int, Role] = {} if self._state.cache_options.roles else self._state.create_immutable_dict() state = self._state # speed up attribute access for r in guild.get('roles', []): role = Role(guild=self, data=r, state=state) diff --git a/discord/state.py b/discord/state.py index b7e39ab3ff79..b161c6d593d4 100644 --- a/discord/state.py +++ b/discord/state.py @@ -111,6 +111,9 @@ T = TypeVar('T') Channel = Union[GuildChannel, PrivateChannel, PartialMessageable] +K = TypeVar('K') +V = TypeVar('V') + class ChunkRequest: def __init__( @@ -172,6 +175,16 @@ async def logging_coroutine(coroutine: Coroutine[Any, Any, T], *, info: str) -> _log.exception('Exception occurred during %s', info) +class ImmutableDict(Generic[K, V], Dict[K, V]): + def __init__(self, state: ConnectionState) -> None: + self.__state: ConnectionState = state + super().__init__() + + def __setitem__(self, key: K, value: V) -> None: + if self.__state.self_id and key == self.__state.self_id: + super().__setitem__(key, value) + + class ConnectionState(Generic[ClientT]): if TYPE_CHECKING: _get_websocket: Callable[..., DiscordWebSocket] @@ -273,6 +286,7 @@ def __init__( if attr.startswith('parse_'): parsers[attr[6:].upper()] = func self.cache_options: CacheOptions = options['cache_options'] # this will always be present + self.cache_options._update_from_intents(self._intents) self.clear() @@ -283,6 +297,9 @@ def __init__( def cache_guild_expressions(self) -> bool: return self._intents.emojis_and_stickers and self.cache_options.emojis_and_stickers + def create_immutable_dict(self, key_type: K = Any, value_type: V = Any) -> ImmutableDict[K, V]: + return ImmutableDict(self) + async def close(self) -> None: for voice in self.voice_clients: try: