From b2c4b6f03d6779c8ab3d639bbf811a8b545d8be5 Mon Sep 17 00:00:00 2001 From: dolfies Date: Sun, 23 Oct 2022 19:50:52 -0400 Subject: [PATCH] Eliminate channel-less interaction case, clean up and fix interaction execution (fixes #380) --- discord/commands.py | 30 +++++-------- discord/components.py | 53 ++++++++-------------- discord/interactions.py | 98 ++++++++++++++++++++++++++++++----------- discord/modal.py | 32 +++++--------- discord/state.py | 8 +++- 5 files changed, 121 insertions(+), 100 deletions(-) diff --git a/discord/commands.py b/discord/commands.py index 16f2f31b9..d63c81f9e 100644 --- a/discord/commands.py +++ b/discord/commands.py @@ -28,6 +28,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Protocol, Tuple, Ty from .enums import AppCommandOptionType, AppCommandType, ChannelType, InteractionType, try_enum from .errors import InvalidData +from .interactions import _wrapped_interaction from .mixins import Hashable from .permissions import Permissions from .utils import _generate_nonce, _get_as_snowflake @@ -117,24 +118,17 @@ class ApplicationCommand(Protocol): channel = channel or self.target_channel if channel is None: raise TypeError('__call__() missing 1 required argument: \'channel\'') - state = self._state - acc_channel = await channel._get_channel() - nonce = _generate_nonce() - type = InteractionType.application_command - - state._interaction_cache[nonce] = (type.value, data['name'], acc_channel) - try: - await state.http.interact(type, data, acc_channel, files=files, nonce=nonce, application_id=self.application_id) - i = await state.client.wait_for( - 'interaction_finish', - check=lambda d: d.nonce == nonce, - timeout=6, - ) - except TimeoutError as exc: - raise InvalidData('Did not receive a response from Discord') from exc - finally: # Cleanup even if we failed - state._interaction_cache.pop(nonce, None) - return i + + return await _wrapped_interaction( + self._state, + _generate_nonce(), + InteractionType.application_command, + data['name'], + await channel._get_channel(), # type: ignore # acc_channel is always correct here + data, + files=files, + application_id=self.application_id, + ) @property def guild(self) -> Optional[Guild]: diff --git a/discord/components.py b/discord/components.py index b17877fd1..e97c415f6 100644 --- a/discord/components.py +++ b/discord/components.py @@ -24,11 +24,10 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations -from asyncio import TimeoutError from typing import Any, ClassVar, Dict, List, Optional, TYPE_CHECKING, Tuple, Union from .enums import try_enum, ComponentType, ButtonStyle, TextStyle, InteractionType -from .errors import InvalidData +from .interactions import _wrapped_interaction from .utils import _generate_nonce, get_slots, MISSING from .partial_emoji import PartialEmoji, _EmojiTag @@ -218,23 +217,15 @@ class Button(Component): return self.url message = self.message - state = message._state - nonce = _generate_nonce() - type = InteractionType.component - - state._interaction_cache[nonce] = (int(type), None, message.channel) - try: - await state.http.interact(type, self.to_dict(), message.channel, message, nonce=nonce) - i = await state.client.wait_for( - 'interaction_finish', - check=lambda d: d.nonce == nonce, - timeout=6, - ) - except TimeoutError as exc: - raise InvalidData('Did not receive a response from Discord') from exc - finally: # Cleanup even if we failed - state._interaction_cache.pop(nonce, None) - return i + return await _wrapped_interaction( + message._state, + _generate_nonce(), + InteractionType.component, + None, + message.channel, # type: ignore # acc_channel is always correct here + self.to_dict(), + message=message, + ) class SelectMenu(Component): @@ -314,21 +305,15 @@ class SelectMenu(Component): The interaction that was created. """ message = self.message - state = message._state - nonce = _generate_nonce() - type = InteractionType.component - - state._interaction_cache[nonce] = (int(type), None, message.channel) - await state.http.interact(type, self.to_dict(options), message.channel, message, nonce=nonce) - try: - i = await state.client.wait_for( - 'interaction_finish', - check=lambda d: d.nonce == nonce, - timeout=6, - ) - except TimeoutError as exc: - raise InvalidData('Did not receive a response from Discord') from exc - return i + return await _wrapped_interaction( + message._state, + _generate_nonce(), + InteractionType.component, + None, + message.channel, # type: ignore # acc_channel is always correct here + self.to_dict(options), + message=message, + ) class SelectOption: diff --git a/discord/interactions.py b/discord/interactions.py index 204abcd6e..4c4f3569c 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -24,14 +24,16 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations -from typing import Optional, TYPE_CHECKING, Union +import asyncio +from typing import TYPE_CHECKING, Optional, Union from .enums import InteractionType, try_enum +from .errors import InvalidData from .mixins import Hashable -from .utils import cached_slot_property, find, MISSING +from .utils import MISSING, cached_slot_property, find if TYPE_CHECKING: - from .channel import DMChannel, GroupChannel, TextChannel, VoiceChannel + from .channel import DMChannel, TextChannel, VoiceChannel from .guild import Guild from .message import Message from .modal import Modal @@ -41,7 +43,7 @@ if TYPE_CHECKING: from .types.user import User as UserPayload from .user import BaseUser, ClientUser - MessageableChannel = Union[TextChannel, Thread, DMChannel, GroupChannel, VoiceChannel] + MessageableChannel = Union[TextChannel, Thread, DMChannel, VoiceChannel] # fmt: off __all__ = ( @@ -69,30 +71,36 @@ class Interaction(Hashable): Return the interaction's hash. + .. describe:: str(x) + + Returns a string representation of the interaction, if any. + Attributes ------------ id: :class:`int` The interaction ID. + type: :class:`InteractionType` + The type of interaction. nonce: Optional[Union[:class:`int`, :class:`str`]] The interaction's nonce. Not always present. + channel: Union[:class:`TextChannel`, :class:`VoiceChannel`, :class:`Thread`, :class:`DMChannel`] + The channel this interaction originated from. + user: Union[:class:`Member`, :class:`abc.User`] + The :class:`Member` who initiated the interaction. + If :attr:`channel` is a private channel or the + user has the left the guild, then it is a :class:`User` instead. name: Optional[:class:`str`] The name of the application command, if applicable. - type: :class:`InteractionType` - The type of interaction. successful: :class:`bool` Whether the interaction succeeded. If this is your interaction, this is not immediately available. It is filled when Discord notifies us about the outcome of the interaction. - user: Union[:class:`Member`, :class:`abc.User`] - The :class:`Member` who initiated the interaction. - If :attr:`channel` is a private channel or the - user has the left the guild, then it is a :class:`User` instead. modal: Optional[:class:`Modal`] The modal that is in response to this interaction. This is not immediately available and is filled when the modal is dispatched. """ - __slots__ = ('id', 'type', 'nonce', 'user', 'name', 'successful', 'modal', '_cs_message', '_cs_channel', '_state') + __slots__ = ('id', 'type', 'nonce', 'channel', 'user', 'name', 'successful', 'modal', '_cs_message', '_state') def __init__( self, @@ -100,15 +108,16 @@ class Interaction(Hashable): type: int, nonce: Optional[Snowflake] = None, *, + channel: MessageableChannel, user: BaseUser, state: ConnectionState, name: Optional[str] = None, message: Optional[Message] = None, - channel: Optional[MessageableChannel] = None, ) -> None: self.id = id - self.nonce = nonce self.type = try_enum(InteractionType, type) + self.nonce = nonce + self.channel = channel self.user = user self.name = name self.successful: bool = MISSING @@ -116,8 +125,6 @@ class Interaction(Hashable): self._state = state if message is not None: self._cs_message = message - if channel is not None: - self._cs_channel = channel @classmethod def _from_self( @@ -137,13 +144,28 @@ class Interaction(Hashable): state = message._state name = data.get('name') user_cls = state.store_user(user) - self = cls(int(id), type, user=user_cls, name=name, message=message, state=state) + self = cls( + int(id), + type, + channel=message.channel, # type: ignore # message.channel is always correct here + user=user_cls, + name=name, + message=message, + state=state, + ) self.successful = True return self def __repr__(self) -> str: s = self.successful - return f'' + return ( + f'' + ) + + def __str__(self) -> str: + if self.name: + return f'{self.user.name} used **{"/" if self.type == InteractionType.application_command else ""}{self.name}**' + return '' def __bool__(self) -> bool: if self.successful is not MISSING: @@ -164,11 +186,37 @@ class Interaction(Hashable): @property def guild(self) -> Optional[Guild]: """Optional[:class:`Guild`]: Returns the guild the interaction originated from.""" - return getattr(self.channel, 'guild', getattr(self.message, 'guild', None)) - - @cached_slot_property('_cs_channel') - def channel(self) -> MessageableChannel: - """Union[:class:`TextChannel`, :class:`Thread`, :class:`DMChannel`, :class:`GroupChannel`]: - Returns the channel this interaction originated from. - """ - return getattr(self.message, 'channel', None) + return getattr(self.channel, 'guild', None) + + +async def _wrapped_interaction( + state: ConnectionState, + nonce: str, + type: InteractionType, + name: Optional[str], + channel: MessageableChannel, + data: dict, + **kwargs, +) -> Interaction: + state._interaction_cache[nonce] = (type.value, name, channel) + + try: + await state.http.interact(type, data, channel, nonce=nonce, **kwargs) + # The maximum possible time a response can take is 3 seconds, + # +/- a few milliseconds for network latency + # However, people have been getting errors because their gateway + # disconnects while waiting for the interaction, causing the + # response to be delayed until the gateway is reconnected + # 12 seconds should be enough to account for this + i = await state.client.wait_for( + 'interaction_finish', + check=lambda d: d.nonce == nonce, + timeout=12, + ) + except (asyncio.TimeoutError, asyncio.CancelledError) as exc: + raise InvalidData('Did not receive a response from Discord') from exc + finally: + # Cleanup even if we failed + state._interaction_cache.pop(nonce, None) + + return i diff --git a/discord/modal.py b/discord/modal.py index e6855189d..5899fb5c4 100644 --- a/discord/modal.py +++ b/discord/modal.py @@ -23,11 +23,11 @@ DEALINGS IN THE SOFTWARE. """ from __future__ import annotations -from typing import List, Optional, TYPE_CHECKING, Union +from typing import TYPE_CHECKING, List, Optional, Union from .components import _component_factory from .enums import InteractionType -from .errors import InvalidData +from .interactions import _wrapped_interaction from .mixins import Hashable from .utils import _generate_nonce @@ -127,22 +127,12 @@ class Modal(Hashable): The interaction that was created. """ interaction = self.interaction - state = self._state - nonce = _generate_nonce() - type = InteractionType.modal_submit - - state._interaction_cache[nonce] = (int(type), None, interaction.channel) - try: - await state.http.interact( - type, self.to_dict(), interaction.channel, nonce=nonce, application_id=self.application.id - ) - i = await state.client.wait_for( - 'interaction_finish', - check=lambda d: d.nonce == nonce, - timeout=6, - ) - except TimeoutError as exc: - raise InvalidData('Did not receive a response from Discord') from exc - finally: # Cleanup even if we failed - state._interaction_cache.pop(nonce, None) - return i + return await _wrapped_interaction( + self._state, + _generate_nonce(), + InteractionType.modal_submit, + None, + interaction.channel, + self.to_dict(), + application_id=self.application.id, + ) diff --git a/discord/state.py b/discord/state.py index fdab5b7b0..d6b240364 100644 --- a/discord/state.py +++ b/discord/state.py @@ -2232,7 +2232,9 @@ class ConnectionState: id = int(data['id']) i = self._interactions.get(id, None) if i is None: - i = Interaction(id, nonce=data['nonce'], user=self.user) # type: ignore # self.user is always present here + _log.warning('INTERACTION_SUCCESS referencing an unknown interaction ID: %s. Discarding.', id) + return + i.successful = True self.dispatch('interaction_finish', i) @@ -2240,7 +2242,9 @@ class ConnectionState: id = int(data['id']) i = self._interactions.pop(id, None) if i is None: - i = Interaction(id, nonce=data['nonce'], user=self.user) # type: ignore # self.user is always present here + _log.warning('INTERACTION_FAILED referencing an unknown interaction ID: %s. Discarding.', id) + return + i.successful = False self.dispatch('interaction_finish', i)