From 20055e7cc1d631ce7fad10e21ed0feda49f1a58a Mon Sep 17 00:00:00 2001 From: dolfies Date: Wed, 25 Jun 2025 21:05:13 -0400 Subject: [PATCH 1/4] Fix calculation of hashed rate limit keys --- discord/http.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/discord/http.py b/discord/http.py index 7d59c8bfb..f1f4ec58b 100644 --- a/discord/http.py +++ b/discord/http.py @@ -673,14 +673,13 @@ class HTTPClient: _log.debug(fmt, route_key, bucket_hash, discord_hash) self._bucket_hashes[route_key] = discord_hash - recalculated_key = discord_hash + route.major_parameters - self._buckets[recalculated_key] = ratelimit + self._buckets[f'{discord_hash}:{route.major_parameters}'] = ratelimit self._buckets.pop(key, None) elif route_key not in self._bucket_hashes: fmt = '%s has found its initial rate limit bucket hash (%s).' _log.debug(fmt, route_key, discord_hash) self._bucket_hashes[route_key] = discord_hash - self._buckets[discord_hash + route.major_parameters] = ratelimit + self._buckets[f'{discord_hash}:{route.major_parameters}'] = ratelimit if has_ratelimit_headers: if response.status != 429: From 2175bd51c0d0c2817e69a708e507108f3bc902bd Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Mon, 30 Jun 2025 09:14:23 +0200 Subject: [PATCH 2/4] Fix voice connection issues and upgrade to voice v8 --- discord/gateway.py | 16 ++++++++++++++-- discord/voice_state.py | 40 +++++++++++++++++++++++++++++++++++----- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/discord/gateway.py b/discord/gateway.py index 44656df03..a2c3da3d2 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -212,6 +212,9 @@ class KeepAliveHandler(threading.Thread): class VoiceKeepAliveHandler(KeepAliveHandler): + if TYPE_CHECKING: + ws: DiscordVoiceWebSocket + def __init__(self, *args: Any, **kwargs: Any) -> None: name: str = kwargs.pop('name', f'voice-keep-alive-handler:{id(self):#x}') super().__init__(*args, name=name, **kwargs) @@ -223,7 +226,10 @@ class VoiceKeepAliveHandler(KeepAliveHandler): def get_payload(self) -> Dict[str, Any]: return { 'op': self.ws.HEARTBEAT, - 'd': int(time.time() * 1000), + 'd': { + 't': int(time.time() * 1000), + 'seq_ack': self.ws.seq_ack, + }, } def ack(self) -> None: @@ -830,6 +836,8 @@ class DiscordVoiceWebSocket: self._keep_alive: Optional[VoiceKeepAliveHandler] = None self._close_code: Optional[int] = None self.secret_key: Optional[List[int]] = None + # defaulting to -1 + self.seq_ack: int = -1 if hook: self._hook = hook # type: ignore @@ -850,6 +858,7 @@ class DiscordVoiceWebSocket: 'token': state.token, 'server_id': str(state.server_id), 'session_id': state.session_id, + 'seq_ack': self.seq_ack, }, } await self.send_as_json(payload) @@ -874,14 +883,16 @@ class DiscordVoiceWebSocket: *, resume: bool = False, hook: Optional[Callable[..., Coroutine[Any, Any, Any]]] = None, + seq_ack: int = -1, ) -> Self: """Creates a voice websocket for the :class:`VoiceClient`.""" - gateway = f'wss://{state.endpoint}/?v=4' + gateway = f'wss://{state.endpoint}/?v=8' client = state.voice_client http = client._state.http socket = await http.ws_connect(gateway, compress=15) ws = cls(socket, loop=client.loop, hook=hook) ws.gateway = gateway + ws.seq_ack = seq_ack ws._connection = state ws._max_heartbeat_timeout = 60.0 ws.thread_id = threading.get_ident() @@ -934,6 +945,7 @@ class DiscordVoiceWebSocket: _log.debug('Voice websocket frame received: %s', msg) op = msg['op'] data = msg['d'] # According to Discord this key is always given + self.seq_ack = msg.get('seq', self.seq_ack) # this key could not be given if op == self.READY: await self.initial_connection(data) diff --git a/discord/voice_state.py b/discord/voice_state.py index 956f639b8..d2cc0ebc1 100644 --- a/discord/voice_state.py +++ b/discord/voice_state.py @@ -321,7 +321,7 @@ class VoiceConnectionState: ) return - self.endpoint, _, _ = endpoint.rpartition(':') + self.endpoint = endpoint if self.endpoint.startswith('wss://'): # Just in case, strip it off since we're going to add it later self.endpoint = self.endpoint[6:] @@ -574,7 +574,10 @@ class VoiceConnectionState: self._disconnected.clear() async def _connect_websocket(self, resume: bool) -> DiscordVoiceWebSocket: - ws = await DiscordVoiceWebSocket.from_connection_state(self, resume=resume, hook=self.hook) + seq_ack = -1 + if self.ws is not MISSING: + seq_ack = self.ws.seq_ack + ws = await DiscordVoiceWebSocket.from_connection_state(self, resume=resume, hook=self.hook, seq_ack=seq_ack) self.state = ConnectionFlowState.websocket_connected return ws @@ -603,15 +606,17 @@ class VoiceConnectionState: # The following close codes are undocumented so I will document them here. # 1000 - normal closure (obviously) # 4014 - we were externally disconnected (voice channel deleted, we were moved, etc) - # 4015 - voice server has crashed - if exc.code in (1000, 4015): + # 4015 - voice server has crashed, we should resume + # 4021 - rate limited, we should not reconnect + # 4022 - call terminated, similar to 4014 + if exc.code == 1000: # Don't call disconnect a second time if the websocket closed from a disconnect call if not self._expecting_disconnect: _log.info('Disconnecting from voice normally, close code %d.', exc.code) await self.disconnect() break - if exc.code == 4014: + if exc.code in (4014, 4022): # We were disconnected by discord # This condition is a race between the main ws event and the voice ws closing if self._disconnected.is_set(): @@ -631,6 +636,31 @@ class VoiceConnectionState: else: continue + if exc.code == 4021: + _log.warning('We are being ratelimited while trying to connect to voice. Disconnecting...') + if self.state is not ConnectionFlowState.disconnected: + await self.disconnect() + break + + if exc.code == 4015: + _log.info('Disconnected from voice, attempting a resume...') + try: + await self._connect( + reconnect=reconnect, + timeout=self.timeout, + self_deaf=(self.self_voice_state or self).self_deaf, + self_mute=(self.self_voice_state or self).self_mute, + resume=True, + ) + except asyncio.TimeoutError: + _log.info('Could not resume the voice connection... Disconnecting...') + if self.state is not ConnectionFlowState.disconnected: + await self.disconnect() + break + else: + _log.info('Successfully resumed voice connection') + continue + _log.debug('Not handling close code %s (%s)', exc.code, exc.reason or 'no reason') if not reconnect: From 59546a485184cc84fb006e6b2ef7a9e85a6fdc81 Mon Sep 17 00:00:00 2001 From: Joosemi02 <37875402+Joosemi02@users.noreply.github.com> Date: Thu, 3 Jul 2025 00:54:57 +0200 Subject: [PATCH 3/4] Add support for launch_activity interaction response --- discord/enums.py | 1 + discord/interactions.py | 46 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/discord/enums.py b/discord/enums.py index 7915bcb4b..71f755c12 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -621,6 +621,7 @@ class InteractionResponseType(Enum): autocomplete_result = 8 modal = 9 # for modals # premium_required = 10 (deprecated) + launch_activity = 12 class VideoQualityMode(Enum): diff --git a/discord/interactions.py b/discord/interactions.py index abe47efa2..82b35e392 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -1296,6 +1296,52 @@ class InteractionResponse(Generic[ClientT]): self._response_type = InteractionResponseType.autocomplete_result + async def launch_activity(self) -> InteractionCallbackResponse[ClientT]: + """|coro| + + Responds to this interaction by launching the activity associated with the app. + Only available for apps with activities enabled. + + .. versionadded:: 2.6 + + Raises + ------- + HTTPException + Launching the activity failed. + InteractionResponded + This interaction has already been responded to before. + + Returns + ------- + :class:`InteractionCallbackResponse` + The interaction callback data. + """ + if self._response_type: + raise InteractionResponded(self._parent) + + parent = self._parent + + adapter = async_context.get() + http = parent._state.http + + params = interaction_response_params(InteractionResponseType.launch_activity.value) + response = await adapter.create_interaction_response( + parent.id, + parent.token, + session=parent._session, + proxy=http.proxy, + proxy_auth=http.proxy_auth, + params=params, + ) + self._response_type = InteractionResponseType.launch_activity + + return InteractionCallbackResponse( + data=response, + parent=self._parent, + state=self._parent._state, + type=self._response_type, + ) + class _InteractionMessageState: __slots__ = ('_parent', '_interaction') From 774b934f7432ea7274befb3580fd51cdc8cf366e Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Thu, 3 Jul 2025 00:59:40 +0200 Subject: [PATCH 4/4] Add support for guest invites --- discord/abc.py | 12 +++++++++ discord/audit_logs.py | 11 ++++++-- discord/flags.py | 57 +++++++++++++++++++++++++++++++++++++++++ discord/http.py | 4 +++ discord/invite.py | 11 ++++++++ discord/member.py | 3 ++- discord/types/invite.py | 2 ++ discord/types/member.py | 2 +- docs/api.rst | 16 +++++++++--- 9 files changed, 111 insertions(+), 7 deletions(-) diff --git a/discord/abc.py b/discord/abc.py index 692472f8f..713398a7d 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -60,6 +60,7 @@ from .http import handle_message_parameters from .voice_client import VoiceClient, VoiceProtocol from .sticker import GuildSticker, StickerItem from . import utils +from .flags import InviteFlags __all__ = ( 'Snowflake', @@ -1257,6 +1258,7 @@ class GuildChannel: target_type: Optional[InviteTarget] = None, target_user: Optional[User] = None, target_application_id: Optional[int] = None, + guest: bool = False, ) -> Invite: """|coro| @@ -1295,6 +1297,10 @@ class GuildChannel: The id of the embedded application for the invite, required if ``target_type`` is :attr:`.InviteTarget.embedded_application`. .. versionadded:: 2.0 + guest: :class:`bool` + Whether the invite is a guest invite. + + .. versionadded:: 2.6 Raises ------- @@ -1312,6 +1318,11 @@ class GuildChannel: if target_type is InviteTarget.unknown: raise ValueError('Cannot create invite with an unknown target type') + flags: Optional[InviteFlags] = None + if guest: + flags = InviteFlags._from_value(0) + flags.guest = True + data = await self._state.http.create_invite( self.id, reason=reason, @@ -1322,6 +1333,7 @@ class GuildChannel: target_type=target_type.value if target_type else None, target_user_id=target_user.id if target_user else None, target_application_id=target_application_id, + flags=flags.value if flags else None, ) return Invite.from_incomplete(data=data, state=self._state) diff --git a/discord/audit_logs.py b/discord/audit_logs.py index af67855d4..89577769f 100644 --- a/discord/audit_logs.py +++ b/discord/audit_logs.py @@ -145,8 +145,8 @@ def _transform_applied_forum_tags(entry: AuditLogEntry, data: List[Snowflake]) - return [Object(id=tag_id, type=ForumTag) for tag_id in data] -def _transform_overloaded_flags(entry: AuditLogEntry, data: int) -> Union[int, flags.ChannelFlags]: - # The `flags` key is definitely overloaded. Right now it's for channels and threads but +def _transform_overloaded_flags(entry: AuditLogEntry, data: int) -> Union[int, flags.ChannelFlags, flags.InviteFlags]: + # The `flags` key is definitely overloaded. Right now it's for channels, threads and invites but # I am aware of `member.flags` and `user.flags` existing. However, this does not impact audit logs # at the moment but better safe than sorry. channel_audit_log_types = ( @@ -157,9 +157,16 @@ def _transform_overloaded_flags(entry: AuditLogEntry, data: int) -> Union[int, f enums.AuditLogAction.thread_update, enums.AuditLogAction.thread_delete, ) + invite_audit_log_types = ( + enums.AuditLogAction.invite_create, + enums.AuditLogAction.invite_update, + enums.AuditLogAction.invite_delete, + ) if entry.action in channel_audit_log_types: return flags.ChannelFlags._from_value(data) + elif entry.action in invite_audit_log_types: + return flags.InviteFlags._from_value(data) return data diff --git a/discord/flags.py b/discord/flags.py index 20f8c5470..59a4909b8 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -64,6 +64,7 @@ __all__ = ( 'AppInstallationType', 'SKUFlags', 'EmbedFlags', + 'InviteFlags', ) BF = TypeVar('BF', bound='BaseFlags') @@ -2397,3 +2398,59 @@ class EmbedFlags(BaseFlags): longer displayed. """ return 1 << 5 + + +class InviteFlags(BaseFlags): + r"""Wraps up the Discord Invite flags + + .. versionadded:: 2.6 + + .. container:: operations + + .. describe:: x == y + + Checks if two InviteFlags are equal. + + .. describe:: x != y + + Checks if two InviteFlags are not equal. + + .. describe:: x | y, x |= y + + Returns a InviteFlags instance with all enabled flags from + both x and y. + + .. describe:: x ^ y, x ^= y + + Returns a InviteFlags instance with only flags enabled on + only one of x or y, not on both. + + .. describe:: ~x + + Returns a InviteFlags instance with all flags inverted from x. + + .. describe:: hash(x) + + Returns the flag's hash. + + .. describe:: iter(x) + + Returns an iterator of ``(name, value)`` pairs. This allows it + to be, for example, constructed as a dict or a list of pairs. + Note that aliases are not shown. + + .. describe:: bool(b) + + Returns whether any flag is set to ``True``. + + Attributes + ---------- + value: :class:`int` + The raw value. You should query flags via the properties + rather than using this raw value. + """ + + @flag_value + def guest(self): + """:class:`bool`: Returns ``True`` if this is a guest invite for a voice channel.""" + return 1 << 0 diff --git a/discord/http.py b/discord/http.py index f1f4ec58b..71912f71b 100644 --- a/discord/http.py +++ b/discord/http.py @@ -1834,6 +1834,7 @@ class HTTPClient: target_type: Optional[invite.InviteTargetType] = None, target_user_id: Optional[Snowflake] = None, target_application_id: Optional[Snowflake] = None, + flags: Optional[int] = None, ) -> Response[invite.Invite]: r = Route('POST', '/channels/{channel_id}/invites', channel_id=channel_id) payload = { @@ -1852,6 +1853,9 @@ class HTTPClient: if target_application_id: payload['target_application_id'] = str(target_application_id) + if flags: + payload['flags'] = flags + return self.request(r, reason=reason, json=payload) def get_invite( diff --git a/discord/invite.py b/discord/invite.py index 8c37bd232..362f97693 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -32,6 +32,7 @@ from .mixins import Hashable from .enums import ChannelType, NSFWLevel, VerificationLevel, InviteTarget, InviteType, try_enum from .appinfo import PartialAppInfo from .scheduled_event import ScheduledEvent +from .flags import InviteFlags __all__ = ( 'PartialInviteChannel', @@ -379,6 +380,7 @@ class Invite(Hashable): 'scheduled_event', 'scheduled_event_id', 'type', + '_flags', ) BASE = 'https://discord.gg' @@ -432,6 +434,7 @@ class Invite(Hashable): else None ) self.scheduled_event_id: Optional[int] = self.scheduled_event.id if self.scheduled_event else None + self._flags: int = data.get('flags', 0) @classmethod def from_incomplete(cls, *, state: ConnectionState, data: InvitePayload) -> Self: @@ -523,6 +526,14 @@ class Invite(Hashable): url += '?event=' + str(self.scheduled_event_id) return url + @property + def flags(self) -> InviteFlags: + """:class:`InviteFlags`: Returns the flags for this invite. + + .. versionadded:: 2.6 + """ + return InviteFlags._from_value(self._flags) + def set_scheduled_event(self, scheduled_event: Snowflake, /) -> Self: """Sets the scheduled event for this invite. diff --git a/discord/member.py b/discord/member.py index 6af1571f4..ed52600dd 100644 --- a/discord/member.py +++ b/discord/member.py @@ -238,7 +238,8 @@ class Member(discord.abc.Messageable, _UserTag): ---------- joined_at: Optional[:class:`datetime.datetime`] An aware datetime object that specifies the date and time in UTC that the member joined the guild. - If the member left and rejoined the guild, this will be the latest date. In certain cases, this can be ``None``. + If the member left and rejoined the guild, this will be the latest date. + This can be ``None``, such as when the member is a guest. activities: Tuple[Union[:class:`BaseActivity`, :class:`Spotify`]] The activities that the user is currently doing. diff --git a/discord/types/invite.py b/discord/types/invite.py index f5f00078e..47c972994 100644 --- a/discord/types/invite.py +++ b/discord/types/invite.py @@ -65,6 +65,7 @@ class Invite(IncompleteInvite, total=False): target_application: PartialAppInfo guild_scheduled_event: GuildScheduledEvent type: InviteType + flags: NotRequired[int] class InviteWithCounts(Invite, _GuildPreviewUnique): @@ -84,6 +85,7 @@ class GatewayInviteCreate(TypedDict): target_type: NotRequired[InviteTargetType] target_user: NotRequired[PartialUser] target_application: NotRequired[PartialAppInfo] + flags: NotRequired[int] class GatewayInviteDelete(TypedDict): diff --git a/discord/types/member.py b/discord/types/member.py index 88fb619fd..576ef421d 100644 --- a/discord/types/member.py +++ b/discord/types/member.py @@ -34,7 +34,7 @@ class Nickname(TypedDict): class PartialMember(TypedDict): roles: SnowflakeList - joined_at: str + joined_at: Optional[str] # null if guest deaf: bool mute: bool flags: int diff --git a/docs/api.rst b/docs/api.rst index dda5553b7..dc6775ec6 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -2517,6 +2517,7 @@ of :class:`enum.Enum`. - :attr:`~AuditLogDiff.channel` - :attr:`~AuditLogDiff.uses` - :attr:`~AuditLogDiff.max_uses` + - :attr:`~AuditLogDiff.flags` .. attribute:: invite_update @@ -2541,6 +2542,7 @@ of :class:`enum.Enum`. - :attr:`~AuditLogDiff.channel` - :attr:`~AuditLogDiff.uses` - :attr:`~AuditLogDiff.max_uses` + - :attr:`~AuditLogDiff.flags` .. attribute:: webhook_create @@ -4552,11 +4554,11 @@ AuditLogDiff .. attribute:: flags - The channel flags associated with this thread or forum post. + The flags associated with this thread, forum post or invite. - See also :attr:`ForumChannel.flags` and :attr:`Thread.flags` + See also :attr:`ForumChannel.flags`, :attr:`Thread.flags` and :attr:`Invite.flags` - :type: :class:`ChannelFlags` + :type: Union[:class:`ChannelFlags`, :class:`InviteFlags`] .. attribute:: default_thread_slowmode_delay @@ -5734,6 +5736,14 @@ EmbedFlags .. autoclass:: EmbedFlags() :members: +InviteFlags +~~~~~~~~~~~~~~~~ + +.. attributetable:: InviteFlags + +.. autoclass:: InviteFlags() + :members: + ForumTag ~~~~~~~~~