diff --git a/CHANGELOG.md b/CHANGELOG.md index 9515f21e80..64742b21df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ These changes are available on the `master` branch, but have not yet been releas ### Added +- Add support for Entry Point Commands. + ([#3276](https://github.com/Pycord-Development/pycord/pull/3276)) +- Add support for launching activities as an interaction response. + ([#3276](https://github.com/Pycord-Development/pycord/pull/3276)) + ### Changed ### Fixed diff --git a/discord/bot.py b/discord/bot.py index d50c5cf2ff..588e6b8503 100644 --- a/discord/bot.py +++ b/discord/bot.py @@ -51,13 +51,20 @@ ApplicationCommand, ApplicationContext, AutocompleteContext, + EntryPointCommand, MessageCommand, SlashCommand, SlashCommandGroup, UserCommand, command, ) -from .enums import IntegrationType, InteractionContextType, InteractionType, TeamRole +from .enums import ( + EntryPointHandler, + IntegrationType, + InteractionContextType, + InteractionType, + TeamRole, +) from .errors import CheckFailure, DiscordException from .interactions import Interaction from .shard import AutoShardedClient @@ -74,7 +81,7 @@ from .member import Member from .permissions import Permissions -C = TypeVar("C", bound=MessageCommand | SlashCommand | UserCommand) +C = TypeVar("C", bound=MessageCommand | SlashCommand | UserCommand | EntryPointCommand) CoroFunc = Callable[..., Coroutine[Any, Any, Any]] CFT = TypeVar("CFT", bound=CoroFunc) @@ -1056,6 +1063,52 @@ def message_command( **kwargs, ) + def entry_point_command( + self, + *, + checks: list[Callable[[ApplicationContext], bool]] | None = MISSING, + cog: Cog | None = MISSING, + contexts: set[InteractionContextType] | None = MISSING, + cooldown: Cooldown | None = MISSING, + default_member_permissions: Permissions | None = MISSING, + description: str | None = MISSING, + description_localizations: dict[str, str] | None = MISSING, + integration_types: set[IntegrationType] | None = MISSING, + name: str | None = MISSING, + name_localizations: dict[str, str] | None = MISSING, + nsfw: bool | None = MISSING, + handler: EntryPointHandler = MISSING, + **kwargs: Never, + ) -> Callable[..., EntryPointCommand]: + """A shortcut decorator for adding an entry point command to the bot. + This is equivalent to using :meth:`application_command`, providing + the :class:`EntryPointCommand` class. + + .. versionadded:: 2.9.0 + + Returns + ------- + Callable[..., :class:`EntryPointCommand`] + A decorator that converts the provided function into a :class:`.EntryPointCommand`, + adds it to the bot, and returns it. + """ + return self.application_command( + cls=EntryPointCommand, + checks=checks, + cog=cog, + contexts=contexts, + cooldown=cooldown, + default_member_permissions=default_member_permissions, + description=description, + description_localizations=description_localizations, + integration_types=integration_types, + name=name, + name_localizations=name_localizations, + nsfw=nsfw, + handler=handler, + **kwargs, + ) + def application_command( self, *, diff --git a/discord/commands/core.py b/discord/commands/core.py index 88cbbbb1c6..ab71f0a0ef 100644 --- a/discord/commands/core.py +++ b/discord/commands/core.py @@ -46,6 +46,9 @@ ) from ..channel import PartialMessageable, _threaded_guild_channel_factory +from ..enums import ( + EntryPointHandler, +) from ..enums import Enum as DiscordEnum from ..enums import ( IntegrationType, @@ -82,11 +85,13 @@ "application_command", "user_command", "message_command", + "entry_point_command", "command", "SlashCommandGroup", "ContextMenuCommand", "UserCommand", "MessageCommand", + "EntryPointCommand", ) if TYPE_CHECKING: @@ -1975,6 +1980,185 @@ def _update_copy(self, kwargs: dict[str, Any]): return self.copy() +class EntryPointCommand(ApplicationCommand): + r"""A class that implements the protocol for an entry point command. + + These are not created manually, instead they are created via the + decorator or functional interface. + + .. versionadded:: 2.9.0 + + Attributes + ----------- + name: :class:`str` + The name of the command. + callback: :ref:`coroutine ` + The coroutine that is executed when the command is run. + This is only called if the handler is not :attr:`EntryPointHandler.discord_launch_activity`. + description: Optional[:class:`str`] + The description for the command. + mention: :class:`str` + Returns a string that allows you to mention the slash command. + nsfw: :class:`bool` + Whether the command should be restricted to 18+ channels and users. + Apps intending to be listed in the App Directory cannot have NSFW commands. + default_member_permissions: :class:`~discord.Permissions` + The default permissions a member needs to be able to run the command. + cog: Optional[:class:`Cog`] + The cog that this command belongs to. ``None`` if there isn't one. + checks: List[Callable[[:class:`.ApplicationContext`], :class:`bool`]] + A list of predicates that verifies if the command could be executed + with the given :class:`.ApplicationContext` as the sole parameter. If an exception + is necessary to be thrown to signal failure, then one inherited from + :exc:`.ApplicationCommandError` should be used. Note that if the checks fail then + :exc:`.CheckFailure` exception is raised to the :func:`.on_application_command_error` + event. + cooldown: Optional[:class:`~discord.ext.commands.Cooldown`] + The cooldown applied when the command is invoked. ``None`` if the command + doesn't have a cooldown. + name_localizations: Dict[:class:`str`, :class:`str`] + The name localizations for this command. The values of this should be ``"locale": "name"``. See + `here `_ for a list of valid locales. + description_localizations: Dict[:class:`str`, :class:`str`] + The description localizations for this command. The values of this should be ``"locale": "description"``. + See `here `_ for a list of valid locales. + integration_types: Set[:class:`IntegrationType`] + The type of installation this command should be available to. For instance, if set to + :attr:`IntegrationType.user_install`, the command will only be available to users with + the application installed on their account. Unapplicable for guild commands. + contexts: Set[:class:`InteractionContextType`] + The location where this command can be used. Cannot be set if this is a guild command. + handler: :class:`EntryPointHandler` + The action to take when the user executes this command. + """ + + type = 4 + + def __new__(cls, *args, **kwargs) -> EntryPointCommand: + self = super().__new__(cls) + + self.__original_kwargs__ = kwargs.copy() + return self + + def __init__(self, func: Callable, *args, **kwargs) -> None: + super().__init__(func, **kwargs) + if not asyncio.iscoroutinefunction(func): + raise TypeError("Callback must be a coroutine.") + self.callback = func + + self.name_localizations: dict[str, str] = kwargs.get( + "name_localizations", MISSING + ) + _validate_names(self) + + description = kwargs.get("description") or ( + inspect.cleandoc(func.__doc__).splitlines()[0] + if func.__doc__ is not None + else "No description provided" + ) + + self.description: str = description + self.description_localizations: dict[str, str] = kwargs.get( + "description_localizations", MISSING + ) + _validate_descriptions(self) + + self.handler: EntryPointHandler = kwargs.get("handler", MISSING) + + self.attached_to_group: bool = False + + try: + checks = func.__commands_checks__ + checks.reverse() + except AttributeError: + checks = kwargs.get("checks", []) + + self.checks = checks + + self._before_invoke = None + self._after_invoke = None + + def _validate_parameters(self): + params = self._get_signature_parameters() + if list(params.items())[0][0] == "self": + temp = list(params.items()) + temp.pop(0) + params = dict(temp) + params = iter(params) + + # next we have the 'ctx' as the next parameter + try: + next(params) + except StopIteration: + raise ClientException( + f'Callback for {self.name} command is missing "ctx" parameter.' + ) + + # next there should be no more parameters + try: + next(params) + raise ClientException( + f"Callback for {self.name} command has too many parameters." + ) + except StopIteration: + pass + + @property + def cog(self): + return getattr(self, "_cog", None) + + @cog.setter + def cog(self, value): + old_cog = self.cog + self._cog = value + + if ( + old_cog is None + and value is not None + or value is None + and old_cog is not None + ): + self._validate_parameters() + + @property + def mention(self) -> str: + return f"" + + def to_dict(self) -> dict: + as_dict = { + "name": self.name, + "description": self.description, + "type": self.type, + } + if self.name_localizations is not MISSING: + as_dict["name_localizations"] = self.name_localizations + if self.description_localizations is not MISSING: + as_dict["description_localizations"] = self.description_localizations + + if self.nsfw is not None: + as_dict["nsfw"] = self.nsfw + + if self.default_member_permissions is not None: + as_dict["default_member_permissions"] = ( + self.default_member_permissions.value + ) + + if self.handler is not MISSING: + as_dict["handler"] = self.handler.value + + if not self.guild_ids: + as_dict["integration_types"] = [it.value for it in self.integration_types] + as_dict["contexts"] = [ctx.value for ctx in self.contexts] + + return as_dict + + async def _invoke(self, ctx: ApplicationContext) -> None: + if self.cog is not None: + await self.callback(self.cog, ctx) + else: + await self.callback(ctx) + + def slash_command( *, checks: list[Callable[[ApplicationContext], bool]] | None = MISSING, @@ -2106,6 +2290,49 @@ def message_command( ) +def entry_point_command( + *, + checks: list[Callable[[ApplicationContext], bool]] | None = MISSING, + cog: Cog | None = MISSING, + contexts: set[InteractionContextType] | None = MISSING, + cooldown: Cooldown | None = MISSING, + default_member_permissions: Permissions | None = MISSING, + description: str | None = MISSING, + description_localizations: dict[str, str] | None = MISSING, + integration_types: set[IntegrationType] | None = MISSING, + name: str | None = MISSING, + name_localizations: dict[str, str] | None = MISSING, + nsfw: bool | None = MISSING, + handler: EntryPointHandler = MISSING, + **kwargs: Never, +) -> Callable[..., EntryPointCommand]: + """Decorator for entry point commands that invokes :func:`application_command`. + + .. versionadded:: 2.9.0 + + Returns + ------- + Callable[..., :class:`.EntryPointCommand`] + A decorator that converts the provided method into a :class:`.EntryPointCommand`. + """ + return application_command( + cls=EntryPointCommand, + checks=checks, + cog=cog, + contexts=contexts, + cooldown=cooldown, + default_member_permissions=default_member_permissions, + description=description, + description_localizations=description_localizations, + integration_types=integration_types, + name=name, + name_localizations=name_localizations, + nsfw=nsfw, + handler=handler, + **kwargs, + ) + + def application_command( *, cls: type[C] = SlashCommand, diff --git a/discord/enums.py b/discord/enums.py index 802bb41535..3f105949a1 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -88,6 +88,7 @@ "SelectDefaultValueType", "ApplicationEventWebhookStatus", "InviteTargetUsersJobStatusCode", + "EntryPointHandler", ) @@ -714,6 +715,7 @@ class InteractionResponseType(Enum): auto_complete_result = 8 # for autocomplete interactions modal = 9 # for modal dialogs premium_required = 10 + launch_activity = 12 class VideoQualityMode(Enum): @@ -1212,6 +1214,13 @@ class InviteTargetUsersJobStatusCode(Enum): failed = 3 +class EntryPointHandler(Enum): + """Represents the handler for entry point commands (type 4).""" + + app_handler = 1 + discord_launch_activity = 2 + + T = TypeVar("T") diff --git a/discord/interactions.py b/discord/interactions.py index 7d076b3636..183d952b5d 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -88,7 +88,13 @@ from .threads import Thread from .types.interactions import Interaction as InteractionPayload from .types.interactions import InteractionCallback as InteractionCallbackPayload - from .types.interactions import InteractionCallbackResponse, InteractionData + from .types.interactions import ( + InteractionCallbackActivityInstance as InteractionCallbackActivityInstancePayload, + ) + from .types.interactions import ( + InteractionCallbackResponse, + InteractionData, + ) from .types.interactions import InteractionMetadata as InteractionMetadataPayload from .types.interactions import MessageInteraction as MessageInteractionPayload from .ui.modal import BaseModal @@ -200,6 +206,7 @@ class Interaction: "context", "authorizing_integration_owners", "callback", + "activity_instance", "command", "view", "modal", @@ -225,6 +232,9 @@ def __init__(self, *, data: InteractionPayload, state: ConnectionState): self._session: ClientSession = state.http._HTTPClient__session self._original_response: InteractionMessage | None = None self.callback: InteractionCallback | None = None + self.activity_instance: InteractionCallbackActivityInstanceResource | None = ( + None + ) self._cs_channel: InteractionChannel | None = MISSING self._from_data(data) @@ -1034,7 +1044,9 @@ async def pong(self) -> None: async def _process_callback_response( self, callback_response: InteractionCallbackResponse ): - if callback_response.get("resource", {}).get("message"): + resource = callback_response.get("resource", {}) + + if resource.get("message"): # TODO: fix later to not raise? channel = self._parent.channel if channel is None: @@ -1049,6 +1061,11 @@ async def _process_callback_response( ) # type: ignore self._parent._original_response = message + if act_inst := resource.get("activity_instance"): + self._parent.activity_instance = ( + InteractionCallbackActivityInstanceResource(act_inst) + ) + self._parent.callback = InteractionCallback(callback_response["interaction"]) async def send_message( @@ -1542,6 +1559,39 @@ async def premium_required(self) -> Interaction: await self._process_callback_response(callback_response) return self._parent + async def launch_activity(self) -> Interaction: + """|coro| + + Responds to this interaction by launching the activity associated with the bot. + + Raises + ------ + HTTPException + Sending the message failed. + InteractionResponded + This interaction has already been responded to before. + """ + if self._responded: + raise InteractionResponded(self._parent) + + parent = self._parent + + adapter = async_context.get() + http = parent._state.http + callback_response: InteractionCallbackResponse = await self._locked_response( + adapter.create_interaction_response( + parent.id, + parent.token, + session=parent._session, + proxy=http.proxy, + proxy_auth=http.proxy_auth, + type=InteractionResponseType.launch_activity.value, + ) + ) + self._responded = True + await self._process_callback_response(callback_response) + return self._parent + async def _locked_response(self, coro: Coroutine[Any, Any, Any]) -> Any: """|coro| @@ -1916,6 +1966,7 @@ class InteractionCallback: """ def __init__(self, data: InteractionCallbackPayload): + self._activity_instance_id: str | None = data.get("activity_instance_id", None) self._response_message_loading: bool = data.get( "response_message_loading", False ) @@ -1926,6 +1977,7 @@ def __init__(self, data: InteractionCallbackPayload): def __repr__(self): return ( f"" ) @@ -1940,3 +1992,23 @@ def is_ephemeral(self) -> bool: This might be useful for determining if the message was forced to be ephemeral. """ return self._response_message_ephemeral + + def activity_instance_id(self) -> str | None: + """Instance ID of the Activity if one was launched or joined. + If no activity was launched or joined this will be None. + """ + return self._activity_instance_id + + +class InteractionCallbackActivityInstanceResource: + """Information about the Activity that was launched or joined as a response to this interaction. + .. versionadded:: 2.9.0 + + Attributes + ---------- + id: :class:`str` + Instance ID of the Activity if one was launched or joined. + """ + + def __init__(self, data: InteractionCallbackActivityInstancePayload): + self.id = data["id"] diff --git a/discord/types/interactions.py b/discord/types/interactions.py index 3f45e35c19..ff1b90ada4 100644 --- a/discord/types/interactions.py +++ b/discord/types/interactions.py @@ -61,7 +61,10 @@ class ApplicationCommand(TypedDict): dm_permission: NotRequired[bool] default_permission: NotRequired[bool | None] nsfw: NotRequired[bool] + integration_types: NotRequired[list[int]] + contexts: NotRequired[list[int] | None] version: Snowflake + handler: NotRequired[int] ApplicationCommandOptionType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] @@ -291,6 +294,9 @@ class InteractionCallback(TypedDict): class InteractionCallbackResource(TypedDict): type: InteractionResponseType - # This is not fully typed as activities are out of scope - activity_instance: NotRequired[dict] + activity_instance: NotRequired[InteractionCallbackActivityInstance] message: NotRequired[Message] + + +class InteractionCallbackActivityInstance(TypedDict): + id: str