From 0948b730b215fab26f49c629fa6f2d7bb942fbe6 Mon Sep 17 00:00:00 2001 From: Vincent Jose Date: Fri, 20 Feb 2026 08:56:29 +0800 Subject: [PATCH] refactor!: non-working HTTP and Gateway Apps There are some missing attributes and properties on the GatewayApp and probably the HTTPApp too, which I need to fix and thoroughly test. This is just a rough idea. --- discord/app/__init_.py | 9 + discord/app/base.py | 235 ++++++++ discord/app/commands.py | 1064 +++++++++++++++++++++++++++++++++++++ discord/app/gateway.py | 726 +++++++++++++++++++++++++ discord/app/http.py | 913 +++++++++++++++++++++++++++++++ discord/app/state.py | 26 +- discord/bot.py | 1020 +---------------------------------- discord/client.py | 9 +- discord/events/gateway.py | 2 + 9 files changed, 2956 insertions(+), 1048 deletions(-) create mode 100644 discord/app/__init_.py create mode 100644 discord/app/base.py create mode 100644 discord/app/commands.py create mode 100644 discord/app/gateway.py create mode 100644 discord/app/http.py diff --git a/discord/app/__init_.py b/discord/app/__init_.py new file mode 100644 index 0000000000..968ba101a2 --- /dev/null +++ b/discord/app/__init_.py @@ -0,0 +1,9 @@ +""" +discord.app +~~~~~~~~~~~ + +Tools and Utilities for creating and managing Discord Apps. + +:copyright: 2021-present Pycord Development +:license: MIT, see LICENSE for more details. +""" diff --git a/discord/app/base.py b/discord/app/base.py new file mode 100644 index 0000000000..2d3b5a0c27 --- /dev/null +++ b/discord/app/base.py @@ -0,0 +1,235 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-2021 Rapptz +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +import asyncio +import logging +import sys +import traceback +from typing import Any, Awaitable, Callable, Coroutine, Sequence + +from ..gears import Gear +from ..interactions import Interaction +from ..ui import Item, View +from ..utils.private import copy_doc +from ..utils.public import MISSING, Undefined +from .event_emitter import Event +from .state import ConnectionState + + +class BaseApp: + def __init__( + self, + *, + loop: asyncio.AbstractEventLoop | None = None, + logging_flavor: int = logging.INFO, + debug: bool = False, + logging_banner_module: str | None = None, + **cs_params, + ): + self.loop: asyncio.AbstractEventLoop = asyncio.get_event_loop() if loop is None else loop + self._state = ConnectionState(**cs_params, loop=self.loop) + self.logging_flavor = logging_flavor + self.debug = debug + self.logging_banner_module = logging_banner_module + self._main_gear: Gear = Gear() + self._state.emitter.add_receiver(self._handle_event) + + async def _handle_event(self, event: Event) -> None: + await asyncio.gather(*self._main_gear._handle_event(event)) + + @copy_doc(Gear.attach_gear) + def attach_gear(self, gear: Gear) -> None: + return self._main_gear.attach_gear(gear) + + @copy_doc(Gear.detach_gear) + def detach_gear(self, gear: Gear) -> None: + return self._main_gear.detach_gear(gear) + + @copy_doc(Gear.add_listener) + def add_listener( + self, + callback: Callable[[Event], Awaitable[None]], + *, + event: type[Event] | Undefined = MISSING, + is_instance_function: bool = False, + once: bool = False, + ) -> None: + return self._main_gear.add_listener(callback, event=event, is_instance_function=is_instance_function, once=once) + + @copy_doc(Gear.remove_listener) + def remove_listener( + self, + callback: Callable[[Event], Awaitable[None]], + event: type[Event] | Undefined = MISSING, + is_instance_function: bool = False, + ) -> None: + return self._main_gear.remove_listener(callback, event=event, is_instance_function=is_instance_function) + + @copy_doc(Gear.listen) + def listen( + self, event: type[Event] | Undefined = MISSING, once: bool = False + ) -> Callable[[Callable[[Event], Awaitable[None]]], Callable[[Event], Awaitable[None]]]: + return self._main_gear.listen(event=event, once=once) + + # Error handlers + + async def _run_event( + self, + coro: Callable[..., Coroutine[Any, Any, Any]], + event_name: str, + *args: Any, + **kwargs: Any, + ) -> None: + try: + await coro(*args, **kwargs) + except asyncio.CancelledError: + pass + except Exception: + try: + await self.on_error(event_name, *args, **kwargs) + except asyncio.CancelledError: + pass + + def _schedule_event( + self, + coro: Callable[..., Coroutine[Any, Any, Any]], + event_name: str, + *args: Any, + **kwargs: Any, + ) -> asyncio.Task: + wrapped = self._run_event(coro, event_name, *args, **kwargs) + + # Schedule task and store in set to avoid task garbage collection + task = asyncio.create_task(wrapped, name=f"pycord: {event_name}") + self._tasks.add(task) + task.add_done_callback(self._tasks.discard) + return task + + async def on_error(self, event_method: str, *args: Any, **kwargs: Any) -> None: + """|coro| + + The default error handler provided by the client. + + By default, this prints to :data:`sys.stderr` however it could be + overridden to have a different implementation. + Check :func:`~discord.on_error` for more details. + """ + print(f"Ignoring exception in {event_method}", file=sys.stderr) + traceback.print_exc() + + async def on_view_error(self, error: Exception, item: Item, interaction: Interaction) -> None: + """|coro| + + The default view error handler provided by the client. + + This only fires for a view if you did not define its :func:`~discord.ui.View.on_error`. + + Parameters + ---------- + error: :class:`Exception` + The exception that was raised. + item: :class:`Item` + The item that the user interacted with. + interaction: :class:`Interaction` + The interaction that was received. + """ + + print( + f"Ignoring exception in view {interaction.view} for item {item}:", + file=sys.stderr, + ) + traceback.print_exception(error.__class__, error, error.__traceback__, file=sys.stderr) + + async def on_modal_error(self, error: Exception, interaction: Interaction) -> None: + """|coro| + + The default modal error handler provided by the client. + The default implementation prints the traceback to stderr. + + This only fires for a modal if you did not define its :func:`~discord.ui.Modal.on_error`. + + Parameters + ---------- + error: :class:`Exception` + The exception that was raised. + interaction: :class:`Interaction` + The interaction that was received. + """ + + print(f"Ignoring exception in modal {interaction.modal}:", file=sys.stderr) + traceback.print_exception(error.__class__, error, error.__traceback__, file=sys.stderr) + + def clear(self) -> None: + """Clears the internal state of the bot. + + After this, the bot can be considered "re-opened", i.e. :meth:`is_closed` + and :meth:`is_ready` both return ``False`` along with the bot's internal + cache cleared. + """ + self._closed = False + self._ready.clear() + self._connection.clear() + self.http.recreate() + + async def add_view(self, view: View, *, message_id: int | None = None) -> None: + """Registers a :class:`~discord.ui.View` for persistent listening. + + This method should be used for when a view is comprised of components + that last longer than the lifecycle of the program. + + .. versionadded:: 2.0 + + Parameters + ---------- + view: :class:`discord.ui.View` + The view to register for dispatching. + message_id: Optional[:class:`int`] + The message ID that the view is attached to. This is currently used to + refresh the view's state during message update events. If not given + then message update events are not propagated for the view. + + Raises + ------ + TypeError + A view was not passed. + ValueError + The view is not persistent. A persistent view has no timeout + and all their components have an explicitly provided ``custom_id``. + """ + + if not isinstance(view, View): + raise TypeError(f"expected an instance of View not {view.__class__!r}") + + if not view.is_persistent(): + raise ValueError("View is not persistent. Items need to have a custom_id set and View must have no timeout") + + await self._connection.store_view(view, message_id) + + async def get_persistent_views(self) -> Sequence[View]: + """A sequence of persistent views added to the client. + + .. versionadded:: 2.0 + """ + return await self._connection.get_persistent_views() diff --git a/discord/app/commands.py b/discord/app/commands.py new file mode 100644 index 0000000000..3a9b4edaf5 --- /dev/null +++ b/discord/app/commands.py @@ -0,0 +1,1064 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +import asyncio +import collections +import copy +import inspect +import logging +from abc import ABC, abstractmethod +from typing import (TYPE_CHECKING, Any, Callable, Coroutine, Generator, + Literal, Mapping, TypeVar) + +from ..commands import (ApplicationCommand, ApplicationContext, + AutocompleteContext, MessageCommand, SlashCommand, + SlashCommandGroup, UserCommand, command) +from ..enums import InteractionType +from ..errors import CheckFailure, DiscordException +from ..interactions import Interaction +from ..types import interactions +from ..utils import MISSING, find + +CoroFunc = Callable[..., Coroutine[Any, Any, Any]] +CFT = TypeVar("CFT", bound=CoroFunc) + +_log = logging.getLogger(__name__) + +__all__ = ("ApplicationCommandMixin",) + + +class ApplicationCommandMixin(ABC): + """A mixin that implements common functionality for classes that need + application command compatibility. + + Attributes + ---------- + application_commands: :class:`dict` + A mapping of command id string to :class:`.ApplicationCommand` objects. + pending_application_commands: :class:`list` + A list of commands that have been added but not yet registered. This is read-only and is modified via other + methods. + """ + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self._pending_application_commands = [] + self._application_commands = {} + + @property + def all_commands(self): + return self._application_commands + + @property + def pending_application_commands(self): + return self._pending_application_commands + + @property + def commands(self) -> list[ApplicationCommand | Any]: + commands = self.application_commands + if self._bot._supports_prefixed_commands and hasattr(self._bot, "prefixed_commands"): + commands += self._bot.prefixed_commands + return commands + + @property + def application_commands(self) -> list[ApplicationCommand]: + return list(self._application_commands.values()) + + def add_application_command(self, command: ApplicationCommand) -> None: + """Adds an :class:`.ApplicationCommand` into the internal list of commands. + + This is usually not called, instead the :meth:`command` or + other shortcut decorators are used instead. + + .. versionadded:: 2.0 + + Parameters + ---------- + command: :class:`.ApplicationCommand` + The command to add. + """ + if isinstance(command, SlashCommand) and command.is_subcommand: + raise TypeError("The provided command is a sub-command of group") + + if self._bot.debug_guilds and command.guild_ids is None: + command.guild_ids = self._bot.debug_guilds + if self._bot.default_command_contexts and command.contexts is None: + command.contexts = self._bot.default_command_contexts + if self._bot.default_command_integration_types and command.integration_types is None: + command.integration_types = self._bot.default_command_integration_types + + for cmd in self.pending_application_commands: + if cmd == command: + command.id = cmd.id + self._application_commands[command.id] = command + break + self._pending_application_commands.append(command) + + def remove_application_command(self, command: ApplicationCommand) -> ApplicationCommand | None: + """Remove an :class:`.ApplicationCommand` from the internal list + of commands. + + .. versionadded:: 2.0 + + Parameters + ---------- + command: :class:`.ApplicationCommand` + The command to remove. + + Returns + ------- + Optional[:class:`.ApplicationCommand`] + The command that was removed. If the command has not been added, + ``None`` is returned instead. + """ + if command.id: + self._application_commands.pop(command.id, None) + + if command in self._pending_application_commands: + self._pending_application_commands.remove(command) + return command + + @property + def get_command(self): + """Shortcut for :meth:`.get_application_command`. + + .. note:: + Overridden in :class:`ext.commands.Bot`. + + .. versionadded:: 2.0 + """ + # TODO: Do something like we did in self.commands for this + return self.get_application_command + + def get_application_command( + self, + name: str, + guild_ids: list[int] | None = None, + type: type[ApplicationCommand] = ApplicationCommand, + ) -> ApplicationCommand | None: + """Get an :class:`.ApplicationCommand` from the internal list + of commands. + + .. versionadded:: 2.0 + + Parameters + ---------- + name: :class:`str` + The qualified name of the command to get. + guild_ids: List[:class:`int`] + The guild ids associated to the command to get. + type: Type[:class:`.ApplicationCommand`] + The type of the command to get. Defaults to :class:`.ApplicationCommand`. + + Returns + ------- + Optional[:class:`.ApplicationCommand`] + The command that was requested. If not found, returns ``None``. + """ + commands = self._application_commands.values() + for command in commands: + if command.name == name and isinstance(command, type): + if guild_ids is not None and command.guild_ids != guild_ids: + return + return command + elif (names := name.split())[0] == command.name and isinstance(command, SlashCommandGroup): + while len(names) > 1: + command = find(lambda c: c.name == names.pop(0), commands) + if not isinstance(command, SlashCommandGroup) or ( + guild_ids is not None and command.guild_ids != guild_ids + ): + return + commands = command.subcommands + command = find(lambda c: c.name == names.pop(), commands) + if not isinstance(command, type) or (guild_ids is not None and command.guild_ids != guild_ids): + return + return command + + async def get_desynced_commands( + self, + guild_id: int | None = None, + prefetched: list[interactions.ApplicationCommand] | None = None, + ) -> list[dict[str, Any]]: + """|coro| + + Gets the list of commands that are desynced from discord. If ``guild_id`` is specified, it will only return + guild commands that are desynced from said guild, else it will return global commands. + + .. note:: + This function is meant to be used internally, and should only be used if you want to override the default + command registration behavior. + + .. versionadded:: 2.0 + + Parameters + ---------- + guild_id: Optional[:class:`int`] + The guild id to get the desynced commands for, else global commands if unspecified. + prefetched: Optional[List[:class:`.ApplicationCommand`]] + If you already fetched the commands, you can pass them here to be used. Not recommended for typical usage. + + Returns + ------- + List[Dict[:class:`str`, Any]] + A list of the desynced commands. Each will come with at least the ``cmd`` and ``action`` keys, which + respectively contain the command and the action to perform. Other keys may also be present depending on + the action, including ``id``. + """ + + # We can suggest the user to upsert, edit, delete, or bulk upsert the commands + + def _check_command(cmd: ApplicationCommand, match: Mapping[str, Any]) -> bool: + if isinstance(cmd, SlashCommandGroup): + if len(cmd.subcommands) != len(match.get("options", [])): + return True + for subcommand in cmd.subcommands: + match_ = next( + (data for data in match["options"] if data["name"] == subcommand.name), + MISSING, + ) + if match_ is not MISSING and _check_command(subcommand, match_): + return True + else: + as_dict = cmd.to_dict() + to_check = { + "nsfw": None, + "default_member_permissions": None, + "name": None, + "description": None, + "name_localizations": None, + "description_localizations": None, + "options": [ + "type", + "name", + "description", + "autocomplete", + "choices", + "name_localizations", + "description_localizations", + ], + "contexts": None, + "integration_types": None, + } + for check, value in to_check.items(): + if type(value) == list: + # We need to do some falsy conversion here + # The API considers False (autocomplete) and [] (choices) to be falsy values + falsy_vals = (False, []) + for opt in value: + cmd_vals = [val.get(opt, MISSING) for val in as_dict[check]] if check in as_dict else [] + for i, val in enumerate(cmd_vals): + if val in falsy_vals: + cmd_vals[i] = MISSING + if match.get(check, MISSING) is not MISSING and cmd_vals != [ + val.get(opt, MISSING) for val in match[check] + ]: + # We have a difference + return True + elif (attr := getattr(cmd, check, None)) != (found := match.get(check)): + # We might have a difference + if "localizations" in check and bool(attr) == bool(found): + # unlike other attrs, localizations are MISSING by default + continue + elif check == "default_permission" and attr is True and found is None: + # This is a special case + # TODO: Remove for perms v2 + continue + return True + return False + + return_value = [] + cmds = self.pending_application_commands.copy() + + if guild_id is None: + pending = [cmd for cmd in cmds if cmd.guild_ids is None] + else: + pending = [cmd for cmd in cmds if cmd.guild_ids is not None and guild_id in cmd.guild_ids] + + registered_commands: list[interactions.ApplicationCommand] = [] + if prefetched is not None: + registered_commands = prefetched + elif self._bot.user: + if guild_id is None: + registered_commands = await self._bot.http.get_global_commands(self._bot.user.id) + else: + registered_commands = await self._bot.http.get_guild_commands(self._bot.user.id, guild_id) + + registered_commands_dict = {cmd["name"]: cmd for cmd in registered_commands} + # First let's check if the commands we have locally are the same as the ones on discord + for cmd in pending: + match = registered_commands_dict.get(cmd.name) + if match is None: + # We don't have this command registered + return_value.append({"command": cmd, "action": "upsert"}) + elif _check_command(cmd, match): + return_value.append( + { + "command": cmd, + "action": "edit", + "id": int(registered_commands_dict[cmd.name]["id"]), + } + ) + else: + # We have this command registered but it's the same + return_value.append({"command": cmd, "action": None, "id": int(match["id"])}) + + # Now let's see if there are any commands on discord that we need to delete + for _, value_ in registered_commands_dict.items(): + # name default arg is used because loop variables leak in surrounding scope + match = find(lambda c, name=value_["name"]: c.name == name, pending) + if match is None: + # We have this command registered but not in our list + return_value.append( + { + "command": value_["name"], + "id": int(value_["id"]), + "action": "delete", + } + ) + + continue + + return return_value + + async def register_command( + self, + command: ApplicationCommand, + force: bool = True, + guild_ids: list[int] | None = None, + ) -> None: + """|coro| + + Registers a command. If the command has ``guild_ids`` set, or if the ``guild_ids`` parameter is passed, + the command will be registered as a guild command for those guilds. + + Parameters + ---------- + command: :class:`~.ApplicationCommand` + The command to register. + force: :class:`bool` + Whether to force the command to be registered. If this is set to False, the command will only be registered + if it seems to already be registered and up to date with our internal cache. Defaults to True. + guild_ids: :class:`list` + A list of guild ids to register the command for. If this is not set, the command's + :attr:`ApplicationCommand.guild_ids` attribute will be used. + + Returns + ------- + :class:`~.ApplicationCommand` + The command that was registered + """ + # TODO: Write this + raise NotImplementedError + + async def register_commands( + self, + commands: list[ApplicationCommand] | None = None, + guild_id: int | None = None, + method: Literal["individual", "bulk", "auto"] = "bulk", + force: bool = False, + delete_existing: bool = True, + ) -> list[interactions.ApplicationCommand]: + """|coro| + + Register a list of commands. + + .. versionadded:: 2.0 + + Parameters + ---------- + commands: Optional[List[:class:`~.ApplicationCommand`]] + A list of commands to register. If this is not set (``None``), then all commands will be registered. + guild_id: Optional[int] + If this is set, the commands will be registered as a guild command for the respective guild. If it is not + set, the commands will be registered according to their :attr:`ApplicationCommand.guild_ids` attribute. + method: Literal['individual', 'bulk', 'auto'] + The method to use when registering the commands. If this is set to "individual", then each command will be + registered individually. If this is set to "bulk", then all commands will be registered in bulk. If this is + set to "auto", then the method will be determined automatically. Defaults to "bulk". + force: :class:`bool` + Registers the commands regardless of the state of the command on Discord. This uses one less API call, but + can result in hitting rate limits more often. Defaults to False. + delete_existing: :class:`bool` + Whether to delete existing commands that are not in the list of commands to register. Defaults to True. + """ + if commands is None: + commands = self.pending_application_commands + + commands = [copy.copy(cmd) for cmd in commands] + + if guild_id is not None: + for cmd in commands: + to_rep_with = [guild_id] + cmd.guild_ids = to_rep_with + + is_global = guild_id is None + + registered = [] + + if is_global: + pending = list(filter(lambda c: c.guild_ids is None, commands)) + registration_methods = { + "bulk": self._bot.http.bulk_upsert_global_commands, + "upsert": self._bot.http.upsert_global_command, + "delete": self._bot.http.delete_global_command, + "edit": self._bot.http.edit_global_command, + } + + def _register(method: Literal["bulk", "upsert", "delete", "edit"], *args, **kwargs): + return registration_methods[method](self._bot.user and self._bot.user.id, *args, **kwargs) + + else: + pending = list( + filter( + lambda c: c.guild_ids is not None and guild_id in c.guild_ids, + commands, + ) + ) + registration_methods = { + "bulk": self._bot.http.bulk_upsert_guild_commands, + "upsert": self._bot.http.upsert_guild_command, + "delete": self._bot.http.delete_guild_command, + "edit": self._bot.http.edit_guild_command, + } + + def _register(method: Literal["bulk", "upsert", "delete", "edit"], *args, **kwargs): + return registration_methods[method](self._bot.user and self._bot.user.id, guild_id, *args, **kwargs) + + def register( + method: Literal["bulk", "upsert", "delete", "edit"], + *args, + cmd_name: str = None, + guild_id: int | None = None, + **kwargs, + ): + if kwargs.pop("_log", True): + if method == "bulk": + _log.debug(f"Bulk updating commands {[c['name'] for c in args[0]]} for guild {guild_id}") + elif method == "upsert": + _log.debug(f"Creating command {cmd_name} for guild {guild_id}") # type: ignore + elif method == "edit": + _log.debug(f"Editing command {cmd_name} for guild {guild_id}") # type: ignore + elif method == "delete": + _log.debug(f"Deleting command {cmd_name} for guild {guild_id}") # type: ignore + return _register(method, *args, **kwargs) + + pending_actions = [] + + if not force: + prefetched_commands: list[interactions.ApplicationCommand] = [] + if self._bot.user: + if guild_id is None: + prefetched_commands = await self._bot.http.get_global_commands(self._bot.user.id) + else: + prefetched_commands = await self._bot.http.get_guild_commands(self._bot.user.id, guild_id) + desynced = await self.get_desynced_commands(guild_id=guild_id, prefetched=prefetched_commands) + + for cmd in desynced: + if cmd["action"] == "delete": + pending_actions.append( + { + "action": "delete" if delete_existing else None, + "command": collections.namedtuple("Command", ["name"])(name=cmd["command"]), + "id": cmd["id"], + } + ) + continue + # We can assume the command item is a command, since it's only a string if action is delete + wanted = cmd["command"] + name = wanted.name + type_ = wanted.type + + match = next((c for c in pending if c.name == name and c.type == type_), None) + if match is None: + continue + if cmd["action"] == "edit": + pending_actions.append( + { + "action": "edit", + "command": match, + "id": cmd["id"], + } + ) + elif cmd["action"] == "upsert": + pending_actions.append( + { + "action": "upsert", + "command": match, + } + ) + elif cmd["action"] is None: + pending_actions.append( + { + "action": None, + "command": match, + } + ) + else: + raise ValueError(f"Unknown action: {cmd['action']}") + filtered_no_action = list(filter(lambda c: c["action"] is not None, pending_actions)) + filtered_deleted = list(filter(lambda a: a["action"] != "delete", pending_actions)) + if method == "bulk" or (method == "auto" and len(filtered_deleted) == len(pending)): + # Either the method is bulk or all the commands need to be modified, so we can just do a bulk upsert + data = [cmd["command"].to_dict() for cmd in filtered_deleted] + # If there's nothing to update, don't bother + if len(filtered_no_action) == 0: + _log.debug("Skipping bulk command update: Commands are up to date") + registered = prefetched_commands + else: + _log.debug( + "Bulk updating commands %s for guild %s", + {c["command"].name: c["action"] for c in pending_actions}, + guild_id, + ) + registered = await register("bulk", data, _log=False) + else: + if not filtered_no_action: + registered = [] + for cmd in filtered_no_action: + if cmd["action"] == "delete": + await register( + "delete", + cmd["id"], + cmd_name=cmd["command"].name, + guild_id=guild_id, + ) + continue + if cmd["action"] == "edit": + registered.append( + await register( + "edit", + cmd["id"], + cmd["command"].to_dict(), + cmd_name=cmd["command"].name, + guild_id=guild_id, + ) + ) + elif cmd["action"] == "upsert": + registered.append( + await register( + "upsert", + cmd["command"].to_dict(), + cmd_name=cmd["command"].name, + guild_id=guild_id, + ) + ) + else: + raise ValueError(f"Unknown action: {cmd['action']}") + + # TODO: Our lists dont work sometimes, see if that can be fixed so we can avoid this second API call + if method != "bulk": + if self._bot.user: + if guild_id is None: + registered = await self._bot.http.get_global_commands(self._bot.user.id) + else: + registered = await self._bot.http.get_guild_commands(self._bot.user.id, guild_id) + else: + data = [cmd.to_dict() for cmd in pending] + registered = await register("bulk", data, guild_id=guild_id) + + for i in registered: + type_ = i.get("type") + # name, type_ default args are used because loop variables leak in surrounding scope + cmd = find( + lambda c, name=i["name"], type_=type_: c.name == name and c.type == type_, + self.pending_application_commands, + ) + if not cmd: + raise ValueError(f"Registered command {i['name']}, type {i.get('type')} not found in pending commands") + cmd.id = i["id"] + self._application_commands[cmd.id] = cmd + + return registered + + async def sync_commands( + self, + commands: list[ApplicationCommand] | None = None, + method: Literal["individual", "bulk", "auto"] = "bulk", + force: bool = False, + guild_ids: list[int] | None = None, + register_guild_commands: bool = True, + check_guilds: list[int] | None = None, + delete_existing: bool = True, + ) -> None: + """|coro| + + Registers all commands that have been added through :meth:`.add_application_command`. This method cleans up all + commands over the API and should sync them with the internal cache of commands. It attempts to register the + commands in the most efficient way possible, unless ``force`` is set to ``True``, in which case it will always + register all commands. + + By default, this coroutine is called inside the :func:`.on_connect` event. If you choose to override the + :func:`.on_connect` event, then you should invoke this coroutine as well such as the following: + + .. code-block:: python + + @bot.event + async def on_connect(): + if bot.auto_sync_commands: + await bot.sync_commands() + print(f"{bot.user.name} connected.") + + .. note:: + If you remove all guild commands from a particular guild, the library may not be able to detect and update + the commands accordingly, as it would have to individually check for each guild. To force the library to + unregister a guild's commands, call this function with ``commands=[]`` and ``guild_ids=[guild_id]``. + + .. versionadded:: 2.0 + + Parameters + ---------- + commands: Optional[List[:class:`~.ApplicationCommand`]] + A list of commands to register. If this is not set (None), then all commands will be registered. + method: Literal['individual', 'bulk', 'auto'] + The method to use when registering the commands. If this is set to "individual", then each command will be + registered individually. If this is set to "bulk", then all commands will be registered in bulk. If this is + set to "auto", then the method will be determined automatically. Defaults to "bulk". + force: :class:`bool` + Registers the commands regardless of the state of the command on Discord. This uses one less API call, but + can result in hitting rate limits more often. Defaults to False. + guild_ids: Optional[List[:class:`int`]] + A list of guild ids to register the commands for. If this is not set, the commands' + :attr:`~.ApplicationCommand.guild_ids` attribute will be used. + register_guild_commands: :class:`bool` + Whether to register guild commands. Defaults to True. + check_guilds: Optional[List[:class:`int`]] + A list of guilds ids to check for commands to unregister, since the bot would otherwise have to check all + guilds. Unlike ``guild_ids``, this does not alter the commands' :attr:`~.ApplicationCommand.guild_ids` + attribute, instead it adds the guild ids to a list of guilds to sync commands for. If + ``register_guild_commands`` is set to False, then this parameter is ignored. + delete_existing: :class:`bool` + Whether to delete existing commands that are not in the list of commands to register. Defaults to True. + """ + + check_guilds = list(set((check_guilds or []) + (self._bot.debug_guilds or []))) + + if commands is None: + commands = self.pending_application_commands + + if guild_ids is not None: + for cmd in commands: + cmd.guild_ids = guild_ids + + global_commands = [cmd for cmd in commands if cmd.guild_ids is None] + registered_commands = await self.register_commands( + global_commands, method=method, force=force, delete_existing=delete_existing + ) + + registered_guild_commands: dict[int, list[interactions.ApplicationCommand]] = {} + + if register_guild_commands: + cmd_guild_ids: list[int] = [] + for cmd in commands: + if cmd.guild_ids is not None: + cmd_guild_ids.extend(cmd.guild_ids) + if check_guilds is not None: + cmd_guild_ids.extend(check_guilds) + for guild_id in set(cmd_guild_ids): + guild_commands = [cmd for cmd in commands if cmd.guild_ids is not None and guild_id in cmd.guild_ids] + app_cmds = await self.register_commands( + guild_commands, + guild_id=guild_id, + method=method, + force=force, + delete_existing=delete_existing, + ) + registered_guild_commands[guild_id] = app_cmds + + for item in registered_commands: + type_ = item.get("type") + # name, type_ default args are used because loop variables leak in surrounding scope + cmd = find( + lambda c, name=item["name"], type_=type_: (c.name == name and c.guild_ids is None and c.type == type_), + self.pending_application_commands, + ) + if cmd: + cmd.id = item["id"] + self._application_commands[cmd.id] = cmd + + if register_guild_commands and registered_guild_commands: + for guild_id, guild_cmds in registered_guild_commands.items(): + for i in guild_cmds: + name = i["name"] + type_ = i.get("type") + target_gid = i.get("guild_id") + if target_gid is None: + continue + + cmd = next( + ( + c + for c in self.pending_application_commands + if c.name == name + and c.type == type_ + and c.guild_ids is not None + and target_gid == guild_id + and target_gid in c.guild_ids + ), + None, + ) + if not cmd: + # command has not been added yet + continue + cmd.id = i["id"] + self._application_commands[cmd.id] = cmd + + async def process_application_commands(self, interaction: Interaction, auto_sync: bool | None = None) -> None: + """|coro| + + This function processes the commands that have been registered + to the bot and other groups. Without this coroutine, none of the + commands will be triggered. + + By default, this coroutine is called inside the :func:`.on_interaction` + event. If you choose to override the :func:`.on_interaction` event, then + you should invoke this coroutine as well. + + This function finds a registered command matching the interaction id from + application commands and invokes it. If no matching command was + found, it replies to the interaction with a default message. + + .. versionadded:: 2.0 + + Parameters + ---------- + interaction: :class:`discord.Interaction` + The interaction to process + auto_sync: Optional[:class:`bool`] + Whether to automatically sync and unregister the command if it is not found in the internal cache. This will + invoke the :meth:`~.Bot.sync_commands` method on the context of the command, either globally or per-guild, + based on the type of the command, respectively. Defaults to :attr:`.Bot.auto_sync_commands`. + """ + if auto_sync is None: + auto_sync = self._bot.auto_sync_commands + # TODO: find out why the isinstance check below doesn't stop the type errors below + if interaction.type not in ( + InteractionType.application_command, + InteractionType.auto_complete, + ): + return + + command: ApplicationCommand | None = None + try: + if interaction.data: + command = self._application_commands[interaction.data["id"]] # type: ignore + except KeyError: + for cmd in self.application_commands + self.pending_application_commands: + if interaction.data: + guild_id = interaction.data.get("guild_id") + if guild_id: + guild_id = int(guild_id) + if cmd.name == interaction.data["name"] and ( # type: ignore + guild_id == cmd.guild_ids or (isinstance(cmd.guild_ids, list) and guild_id in cmd.guild_ids) + ): + command = cmd + break + else: + if auto_sync and interaction.data: + guild_id = interaction.data.get("guild_id") + if guild_id is None: + await self.sync_commands() + else: + await self.sync_commands(check_guilds=[guild_id]) + return self._bot.dispatch("unknown_application_command", interaction) + + if interaction.type is InteractionType.auto_complete: + return self._bot.dispatch("application_command_auto_complete", interaction, command) + + ctx = await self.get_application_context(interaction) + if command: + interaction.command = command + await self.invoke_application_command(ctx) + + async def on_application_command_auto_complete(self, interaction: Interaction, command: ApplicationCommand) -> None: + async def callback() -> None: + ctx = await self.get_autocomplete_context(interaction) + interaction.command = command + return await command.invoke_autocomplete_callback(ctx) + + autocomplete_task = self._bot.loop.create_task(callback()) + try: + await self._bot.wait_for( + "application_command_auto_complete", + check=lambda i, c: c == command, + timeout=3, + ) + except asyncio.TimeoutError: + return + else: + if not autocomplete_task.done(): + autocomplete_task.cancel() + + def slash_command(self, **kwargs): + """A shortcut decorator that invokes :func:`command` and adds it to + the internal command list via :meth:`add_application_command`. + This shortcut is made specifically for :class:`.SlashCommand`. + + .. versionadded:: 2.0 + + Returns + ------- + Callable[..., :class:`SlashCommand`] + A decorator that converts the provided method into a :class:`.SlashCommand`, adds it to the bot, + then returns it. + """ + return self.application_command(cls=SlashCommand, **kwargs) + + def user_command(self, **kwargs): + """A shortcut decorator that invokes :func:`command` and adds it to + the internal command list via :meth:`add_application_command`. + This shortcut is made specifically for :class:`.UserCommand`. + + .. versionadded:: 2.0 + + Returns + ------- + Callable[..., :class:`UserCommand`] + A decorator that converts the provided method into a :class:`.UserCommand`, adds it to the bot, + then returns it. + """ + return self.application_command(cls=UserCommand, **kwargs) + + def message_command(self, **kwargs): + """A shortcut decorator that invokes :func:`command` and adds it to + the internal command list via :meth:`add_application_command`. + This shortcut is made specifically for :class:`.MessageCommand`. + + .. versionadded:: 2.0 + + Returns + ------- + Callable[..., :class:`MessageCommand`] + A decorator that converts the provided method into a :class:`.MessageCommand`, adds it to the bot, + then returns it. + """ + return self.application_command(cls=MessageCommand, **kwargs) + + def application_command(self, **kwargs): + """A shortcut decorator that invokes :func:`command` and adds it to + the internal command list via :meth:`~.Bot.add_application_command`. + + .. versionadded:: 2.0 + + Returns + ------- + Callable[..., :class:`ApplicationCommand`] + A decorator that converts the provided method into an :class:`.ApplicationCommand`, adds it to the bot, + then returns it. + """ + + def decorator(func) -> ApplicationCommand: + result = command(**kwargs)(func) + self.add_application_command(result) + return result + + return decorator + + def command(self, **kwargs): + """An alias for :meth:`application_command`. + + .. note:: + + This decorator is overridden by :class:`discord.ext.commands.Bot`. + + .. versionadded:: 2.0 + + Returns + ------- + Callable[..., :class:`ApplicationCommand`] + A decorator that converts the provided method into an :class:`.ApplicationCommand`, adds it to the bot, + then returns it. + """ + return self.application_command(**kwargs) + + def create_group( + self, + name: str, + description: str | None = None, + guild_ids: list[int] | None = None, + **kwargs, + ) -> SlashCommandGroup: + """A shortcut method that creates a slash command group with no subcommands and adds it to the internal + command list via :meth:`add_application_command`. + + .. versionadded:: 2.0 + + Parameters + ---------- + name: :class:`str` + The name of the group to create. + description: Optional[:class:`str`] + The description of the group to create. + guild_ids: Optional[List[:class:`int`]] + A list of the IDs of each guild this group should be added to, making it a guild command. + This will be a global command if ``None`` is passed. + kwargs: + Any additional keyword arguments to pass to :class:`.SlashCommandGroup`. + + Returns + ------- + SlashCommandGroup + The slash command group that was created. + """ + description = description or "No description provided." + group = SlashCommandGroup(name, description, guild_ids, **kwargs) + self.add_application_command(group) + return group + + def group( + self, + name: str | None = None, + description: str | None = None, + guild_ids: list[int] | None = None, + ) -> Callable[[type[SlashCommandGroup]], SlashCommandGroup]: + """A shortcut decorator that initializes the provided subclass of :class:`.SlashCommandGroup` + and adds it to the internal command list via :meth:`add_application_command`. + + .. versionadded:: 2.0 + + Parameters + ---------- + name: Optional[:class:`str`] + The name of the group to create. This will resolve to the name of the decorated class if ``None`` is passed. + description: Optional[:class:`str`] + The description of the group to create. + guild_ids: Optional[List[:class:`int`]] + A list of the IDs of each guild this group should be added to, making it a guild command. + This will be a global command if ``None`` is passed. + + Returns + ------- + Callable[[Type[SlashCommandGroup]], SlashCommandGroup] + The slash command group that was created. + """ + + def inner(cls: type[SlashCommandGroup]) -> SlashCommandGroup: + group = cls( + name or cls.__name__, + ( + description or inspect.cleandoc(cls.__doc__).splitlines()[0] + if cls.__doc__ is not None + else "No description provided" + ), + guild_ids=guild_ids, + ) + self.add_application_command(group) + return group + + return inner + + slash_group = group + + def walk_application_commands(self) -> Generator[ApplicationCommand]: + """An iterator that recursively walks through all application commands and subcommands. + + Yields + ------ + :class:`.ApplicationCommand` + An application command from the internal list of application commands. + """ + for command in self.application_commands: + if isinstance(command, SlashCommandGroup): + yield from command.walk_commands() + yield command + + async def get_application_context( + self, interaction: Interaction, cls: Any = ApplicationContext + ) -> ApplicationContext: + r"""|coro| + + Returns the invocation context from the interaction. + + This is a more low-level counter-part for :meth:`.process_application_commands` + to allow users more fine-grained control over the processing. + + Parameters + ----------- + interaction: :class:`discord.Interaction` + The interaction to get the invocation context from. + cls + The factory class that will be used to create the context. + By default, this is :class:`.ApplicationContext`. Should a custom + class be provided, it must be similar enough to + :class:`.ApplicationContext`\'s interface. + + Returns + -------- + :class:`.ApplicationContext` + The invocation context. The type of this can change via the + ``cls`` parameter. + """ + return cls(self, interaction) + + async def get_autocomplete_context( + self, interaction: Interaction, cls: Any = AutocompleteContext + ) -> AutocompleteContext: + r"""|coro| + + Returns the autocomplete context from the interaction. + + This is a more low-level counter-part for :meth:`.process_application_commands` + to allow users more fine-grained control over the processing. + + Parameters + ----------- + interaction: :class:`discord.Interaction` + The interaction to get the invocation context from. + cls + The factory class that will be used to create the context. + By default, this is :class:`.AutocompleteContext`. Should a custom + class be provided, it must be similar enough to + :class:`.AutocompleteContext`\'s interface. + + Returns + -------- + :class:`.AutocompleteContext` + The autocomplete context. The type of this can change via the + ``cls`` parameter. + """ + return cls(self, interaction) + + async def invoke_application_command(self, ctx: ApplicationContext) -> None: + """|coro| + + Invokes the application command given under the invocation + context and handles all the internal event dispatch mechanisms. + + Parameters + ---------- + ctx: :class:`.ApplicationCommand` + The invocation context to invoke. + """ + # self._bot.dispatch("application_command", ctx) # TODO: Remove when moving away from ApplicationContext + try: + if await self._bot.can_run(ctx, call_once=True): + await ctx.command.invoke(ctx) + else: + raise CheckFailure("The global check once functions failed.") + except DiscordException as exc: + await ctx.command.dispatch_error(ctx, exc) + else: + # self._bot.dispatch("application_command_completion", ctx) # TODO: Remove when moving away from ApplicationContext + pass diff --git a/discord/app/gateway.py b/discord/app/gateway.py new file mode 100644 index 0000000000..c0e250847c --- /dev/null +++ b/discord/app/gateway.py @@ -0,0 +1,726 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-2021 Rapptz +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +import asyncio +import logging +import signal +from typing import Any, AsyncGenerator, Sequence + +import aiohttp + +from ..abc import PartialMessageable, PrivateChannel +from ..activity import ActivityTypes, BaseActivity, create_activity +from ..backoff import ExponentialBackoff +from ..channel import GuildChannel, Thread +from ..emoji import AppEmoji, GuildEmoji +from ..enums import ChannelType, Status +from ..errors import (ConnectionClosed, GatewayNotFound, HTTPException, + PrivilegedIntentsRequired) +from ..flags import Intents +from ..gateway import DiscordWebSocket, ReconnectWebSocket +from ..guild import Guild +from ..member import Member +from ..mentions import AllowedMentions +from ..message import Message +from ..poll import Poll +from ..soundboard import SoundboardSound +from ..stage_instance import StageInstance +from ..sticker import GuildSticker +from ..user import User +from ..utils.private import SequenceProxy +from .http import HTTPApp + +_log = logging.getLogger(__name__) + + +def _cancel_tasks(loop: asyncio.AbstractEventLoop) -> None: + tasks = {t for t in asyncio.all_tasks(loop=loop) if not t.done()} + + if not tasks: + return + + _log.info("Cleaning up after %d tasks.", len(tasks)) + for task in tasks: + task.cancel() + + loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True)) + _log.info("All tasks finished cancelling.") + + for task in tasks: + if task.cancelled(): + continue + if task.exception() is not None: + loop.call_exception_handler( + { + "message": "Unhandled exception during Client.run shutdown.", + "exception": task.exception(), + "task": task, + } + ) + + +def _cleanup_loop(loop: asyncio.AbstractEventLoop) -> None: + try: + _cancel_tasks(loop) + loop.run_until_complete(loop.shutdown_asyncgens()) + finally: + _log.info("Closing the event loop.") + loop.close() + + +class GatewayApp(HTTPApp): + async def get_guilds(self) -> list[Guild]: + """The guilds that the connected client is a member of.""" + return await self._connection.get_guilds() + + async def get_emojis(self) -> list[GuildEmoji | AppEmoji]: + """The emojis that the connected client has. + + .. note:: + + This only includes the application's emojis if `cache_app_emojis` is ``True``. + """ + return await self._connection.get_emojis() + + async def get_guild_emojis(self) -> list[GuildEmoji]: + """The :class:`~discord.GuildEmoji` that the connected client has.""" + return [e for e in await self.get_emojis() if isinstance(e, GuildEmoji)] + + async def get_app_emojis(self) -> list[AppEmoji]: + """The :class:`~discord.AppEmoji` that the connected client has. + + .. note:: + + This is only available if `cache_app_emojis` is ``True``. + """ + return [e for e in await self.get_emojis() if isinstance(e, AppEmoji)] + + async def get_stickers(self) -> list[GuildSticker]: + """The stickers that the connected client has. + + .. versionadded:: 2.0 + """ + return await self._connection.get_stickers() + + async def get_polls(self) -> list[Poll]: + """The polls that the connected client has. + + .. versionadded:: 2.6 + """ + return await self._connection.get_polls() + + async def get_cached_messages(self) -> Sequence[Message]: + """Read-only list of messages the connected client has cached. + + .. versionadded:: 1.1 + """ + return SequenceProxy(await self._connection.cache.get_all_messages()) + + async def get_private_channels(self) -> list[PrivateChannel]: + """The private channels that the connected client is participating on. + + .. note:: + + This returns only up to 128 most recent private channels due to an internal working + on how Discord deals with private channels. + """ + return await self._connection.get_private_channels() + + # hooks + + async def _call_before_identify_hook(self, shard_id: int | None, *, initial: bool = False) -> None: + # This hook is an internal hook that actually calls the public one. + # It allows the library to have its own hook without stepping on the + # toes of those who need to override their own hook. + await self.before_identify_hook(shard_id, initial=initial) + + async def before_identify_hook(self, shard_id: int | None, *, initial: bool = False) -> None: + """|coro| + + A hook that is called before IDENTIFYing a session. This is useful + if you wish to have more control over the synchronization of multiple + IDENTIFYing clients. + + The default implementation sleeps for 5 seconds. + + .. versionadded:: 1.4 + + Parameters + ---------- + shard_id: :class:`int` + The shard ID that requested being IDENTIFY'd + initial: :class:`bool` + Whether this IDENTIFY is the first initial IDENTIFY. + """ + + if not initial: + await asyncio.sleep(5.0) + + async def connect(self, *, reconnect: bool = True) -> None: + """|coro| + + Creates a WebSocket connection and lets the WebSocket listen + to messages from Discord. This is a loop that runs the entire + event system and miscellaneous aspects of the library. Control + is not resumed until the WebSocket connection is terminated. + + Parameters + ---------- + reconnect: :class:`bool` + If we should attempt reconnecting, either due to internet + failure or a specific failure on Discord's part. Certain + disconnects that lead to bad state will not be handled (such as + invalid sharding payloads or bad tokens). + + Raises + ------ + :exc:`GatewayNotFound` + The gateway to connect to Discord is not found. Usually if this + is thrown then there is a Discord API outage. + :exc:`ConnectionClosed` + The WebSocket connection has been terminated. + """ + + backoff = ExponentialBackoff() + ws_params = { + "initial": True, + "shard_id": self.shard_id, + } + while not self.is_closed(): + try: + coro = DiscordWebSocket.from_client(self, **ws_params) + self.ws = await asyncio.wait_for(coro, timeout=60.0) + ws_params["initial"] = False + while True: + await self.ws.poll_event() + except ReconnectWebSocket as e: + _log.info("Got a request to %s the websocket.", e.op) + # self.dispatch("disconnect") # TODO: dispatch event + ws_params.update( + sequence=self.ws.sequence, + resume=e.resume, + session=self.ws.session_id, + ) + continue + except ( + OSError, + HTTPException, + GatewayNotFound, + ConnectionClosed, + aiohttp.ClientError, + asyncio.TimeoutError, + ) as exc: + self.dispatch("disconnect") + if not reconnect: + await self.close() + if isinstance(exc, ConnectionClosed) and exc.code == 1000: + # clean close, don't re-raise this + return + raise + + if self.is_closed(): + return + + # If we get connection reset by peer then try to RESUME + if isinstance(exc, OSError) and exc.errno in (54, 10054): + ws_params.update( + sequence=self.ws.sequence, + initial=False, + resume=True, + session=self.ws.session_id, + ) + continue + + # We should only get this when an unhandled close code happens, + # such as a clean disconnect (1000) or a bad state (bad token, no sharding, etc) + # sometimes, discord sends us 1000 for unknown reasons, so we should reconnect + # regardless and rely on is_closed instead + if isinstance(exc, ConnectionClosed): + if exc.code == 4014: + raise PrivilegedIntentsRequired(exc.shard_id) from None + if exc.code != 1000: + await self.close() + raise + + retry = backoff.delay() + _log.exception("Attempting a reconnect in %.2fs", retry) + await asyncio.sleep(retry) + # Always try to RESUME the connection + # If the connection is not RESUME-able then the gateway will invalidate the session. + # This is apparently what the official Discord client does. + if self.ws is None: + continue + ws_params.update(sequence=self.ws.sequence, resume=True, session=self.ws.session_id) + + async def close(self) -> None: + """|coro| + + Closes the connection to Discord. + """ + if self._closed: + return + + await self.http.close() + self._closed = True + + for voice in self.voice_clients: + try: + await voice.disconnect(force=True) + except Exception: + # if an error happens during disconnects, disregard it. + pass + + if self.ws is not None and self.ws.open: + await self.ws.close(code=1000) + + self._ready.clear() + + async def start(self, token: str, *, reconnect: bool = True) -> None: + """|coro| + + A shorthand coroutine for :meth:`login` + :meth:`connect`. + + Raises + ------ + TypeError + An unexpected keyword argument was received. + """ + await self.login(token) + await self.connect(reconnect=reconnect) + + def run(self, *args: Any, **kwargs: Any) -> None: + """A blocking call that abstracts away the event loop + initialization from you. + + If you want more control over the event loop then this + function should not be used. Use :meth:`start` coroutine + or :meth:`connect` + :meth:`login`. + + Roughly Equivalent to: :: + + try: + loop.run_until_complete(start(*args, **kwargs)) + except KeyboardInterrupt: + loop.run_until_complete(close()) + # cancel all tasks lingering + finally: + loop.close() + + .. warning:: + + This function must be the last function to call due to the fact that it + is blocking. That means that registration of events or anything being + called after this function call will not execute until it returns. + """ + loop = self.loop + + try: + loop.add_signal_handler(signal.SIGINT, loop.stop) + loop.add_signal_handler(signal.SIGTERM, loop.stop) + except (NotImplementedError, RuntimeError): + pass + + async def runner(): + try: + await self.start(*args, **kwargs) + finally: + if not self.is_closed(): + await self.close() + + def stop_loop_on_completion(f): + loop.stop() + + future = asyncio.ensure_future(runner(), loop=loop) + future.add_done_callback(stop_loop_on_completion) + try: + loop.run_forever() + except KeyboardInterrupt: + _log.info("Received signal to terminate bot and event loop.") + finally: + future.remove_done_callback(stop_loop_on_completion) + _log.info("Cleaning up tasks.") + _cleanup_loop(loop) + + if not future.cancelled(): + try: + return future.result() + except KeyboardInterrupt: + # I am unsure why this gets raised here but suppress it anyway + return None + + # properties + + def is_closed(self) -> bool: + """Indicates if the WebSocket connection is closed.""" + return self._closed + + @property + def activity(self) -> ActivityTypes | None: + """The activity being used upon logging in. + + Returns + ------- + Optional[:class:`.BaseActivity`] + """ + return create_activity(self._connection._activity) + + @activity.setter + def activity(self, value: ActivityTypes | None) -> None: + if value is None: + self._connection._activity = None + elif isinstance(value, BaseActivity): + # ConnectionState._activity is typehinted as ActivityPayload, we're passing Dict[str, Any] + self._connection._activity = value.to_dict() # type: ignore + else: + raise TypeError("activity must derive from BaseActivity.") + + @property + def status(self) -> Status: + """The status being used upon logging on to Discord. + + .. versionadded: 2.0 + """ + if self._connection._status in {state.value for state in Status}: + return Status(self._connection._status) + return Status.online + + @status.setter + def status(self, value: Status) -> None: + if value is Status.offline: + self._connection._status = "invisible" + elif isinstance(value, Status): + self._connection._status = str(value) + else: + raise TypeError("status must derive from Status.") + + @property + def allowed_mentions(self) -> AllowedMentions | None: + """The allowed mention configuration. + + .. versionadded:: 1.4 + """ + return self._connection.allowed_mentions + + @allowed_mentions.setter + def allowed_mentions(self, value: AllowedMentions | None) -> None: + if value is None or isinstance(value, AllowedMentions): + self._connection.allowed_mentions = value + else: + raise TypeError(f"allowed_mentions must be AllowedMentions not {value.__class__!r}") + + @property + def intents(self) -> Intents: + """The intents configured for this connection. + + .. versionadded:: 1.5 + """ + return self._connection.intents + + async def get_users(self) -> list[User]: + """Returns a list of all the users the bot can see.""" + return await self._connection.cache.get_all_users() + + async def get_channel(self, id: int, /) -> GuildChannel | Thread | PrivateChannel | None: + """Returns a channel or thread with the given ID. + + Parameters + ---------- + id: :class:`int` + The ID to search for. + + Returns + ------- + Optional[Union[:class:`.abc.GuildChannel`, :class:`.Thread`, :class:`.abc.PrivateChannel`]] + The returned channel or ``None`` if not found. + """ + return await self._connection.get_channel(id) + + async def get_message(self, id: int, /) -> Message | None: + """Returns a message the given ID. + + This is useful if you have a message_id but don't want to do an API call + to access the message. + + Parameters + ---------- + id: :class:`int` + The ID to search for. + + Returns + ------- + Optional[:class:`.Message`] + The returned message or ``None`` if not found. + """ + return await self._connection._get_message(id) + + def get_partial_messageable(self, id: int, *, type: ChannelType | None = None) -> PartialMessageable: + """Returns a partial messageable with the given channel ID. + + This is useful if you have a channel_id but don't want to do an API call + to send messages to it. + + .. versionadded:: 2.0 + + Parameters + ---------- + id: :class:`int` + The channel ID to create a partial messageable for. + type: Optional[:class:`.ChannelType`] + The underlying channel type for the partial messageable. + + Returns + ------- + :class:`.PartialMessageable` + The partial messageable + """ + return PartialMessageable(state=self._connection, id=id, type=type) + + async def get_stage_instance(self, id: int, /) -> StageInstance | None: + """Returns a stage instance with the given stage channel ID. + + .. versionadded:: 2.0 + + Parameters + ---------- + id: :class:`int` + The ID to search for. + + Returns + ------- + Optional[:class:`.StageInstance`] + The stage instance or ``None`` if not found. + """ + from ..channel import StageChannel + + channel = await self._connection.get_channel(id) + + if isinstance(channel, StageChannel): + return channel.instance + + async def get_guild(self, id: int, /) -> Guild | None: + """Returns a guild with the given ID. + + Parameters + ---------- + id: :class:`int` + The ID to search for. + + Returns + ------- + Optional[:class:`.Guild`] + The guild or ``None`` if not found. + """ + return await self._connection._get_guild(id) + + async def get_user(self, id: int, /) -> User | None: + """Returns a user with the given ID. + + Parameters + ---------- + id: :class:`int` + The ID to search for. + + Returns + ------- + Optional[:class:`~discord.User`] + The user or ``None`` if not found. + """ + return await self._connection.get_user(id) + + async def get_emoji(self, id: int, /) -> GuildEmoji | AppEmoji | None: + """Returns an emoji with the given ID. + + Parameters + ---------- + id: :class:`int` + The ID to search for. + + Returns + ------- + Optional[:class:`.GuildEmoji` | :class:`.AppEmoji`] + The custom emoji or ``None`` if not found. + """ + return await self._connection.get_emoji(id) + + async def get_sticker(self, id: int, /) -> GuildSticker | None: + """Returns a guild sticker with the given ID. + + .. versionadded:: 2.0 + + .. note:: + + To retrieve standard stickers, use :meth:`.fetch_sticker`. + or :meth:`.fetch_premium_sticker_packs`. + + Returns + ------- + Optional[:class:`.GuildSticker`] + The sticker or ``None`` if not found. + """ + return await self._connection.get_sticker(id) + + async def get_poll(self, id: int, /) -> Poll | None: + """Returns a poll attached to the given message ID. + + Parameters + ---------- + id: :class:`int` + The message ID of the poll to search for. + + Returns + ------- + Optional[:class:`.Poll`] + The poll or ``None`` if not found. + """ + return await self._connection.get_poll(id) + + async def get_all_channels(self) -> AsyncGenerator[GuildChannel]: + """A generator that retrieves every :class:`.abc.GuildChannel` the client can 'access'. + + This is equivalent to: :: + + for guild in await client.get_guilds(): + for channel in guild.channels: + yield channel + + .. note:: + + Just because you receive a :class:`.abc.GuildChannel` does not mean that + you can communicate in said channel. :meth:`.abc.GuildChannel.permissions_for` should + be used for that. + + Yields + ------ + :class:`.abc.GuildChannel` + A channel the client can 'access'. + """ + + for guild in await self.get_guilds(): + for channel in guild.channels: + yield channel + + async def get_all_members(self) -> AsyncGenerator[Member]: + """Returns a generator with every :class:`.Member` the client can see. + + This is equivalent to: :: + + for guild in await client.get_guilds(): + for member in guild.members: + yield member + + Yields + ------ + :class:`.Member` + A member the client can see. + """ + for guild in await self.get_guilds(): + for member in guild.members: + yield member + + async def wait_until_ready(self) -> None: + """|coro| + + Waits until the client's internal cache is all ready. + """ + await self._ready.wait() + + async def change_presence( + self, + *, + activity: BaseActivity | None = None, + status: Status | None = None, + ): + """|coro| + + Changes the client's presence. + + Parameters + ---------- + activity: Optional[:class:`.BaseActivity`] + The activity being done. ``None`` if no currently active activity is done. + status: Optional[:class:`.Status`] + Indicates what status to change to. If ``None``, then + :attr:`.Status.online` is used. + + Raises + ------ + :exc:`InvalidArgument` + If the ``activity`` parameter is not the proper type. + + Example + ------- + + .. code-block:: python3 + + game = discord.Game("with the API") + await client.change_presence(status=discord.Status.idle, activity=game) + + .. versionchanged:: 2.0 + Removed the ``afk`` keyword-only parameter. + """ + + if status is None: + status_str = "online" + status = Status.online + elif status is Status.offline: + status_str = "invisible" + status = Status.offline + else: + status_str = str(status) + + await self.ws.change_presence(activity=activity, status=status_str) + + for guild in await self._connection.get_guilds(): + me = guild.me + if me is None: + continue + + me.activities = (activity,) if activity is not None else () + me.status = status + + def get_sound(self, sound_id: int) -> SoundboardSound | None: + """Gets a :class:`.Sound` from the bot's sound cache. + + .. versionadded:: 2.7 + + Parameters + ---------- + sound_id: :class:`int` + The ID of the sound to get. + + Returns + ------- + Optional[:class:`.SoundboardSound`] + The sound with the given ID. + """ + return self._connection._get_sound(sound_id) + + @property + def sounds(self) -> list[SoundboardSound]: + """A list of all the sounds the bot can see. + + .. versionadded:: 2.7 + """ + return self._connection.sounds diff --git a/discord/app/http.py b/discord/app/http.py new file mode 100644 index 0000000000..db60003e35 --- /dev/null +++ b/discord/app/http.py @@ -0,0 +1,913 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-2021 Rapptz +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from ..appinfo import AppInfo, PartialAppInfo +from ..application_role_connection import ApplicationRoleConnectionMetadata +from ..banners import print_banner, start_logging +from ..channel import _threaded_channel_factory +from ..channel.thread import Thread +from ..emoji import AppEmoji +from ..enums import ChannelType +from ..errors import * +from ..flags import ApplicationFlags +from ..gateway import * +from ..guild import Guild +from ..invite import Invite +from ..iterators import EntitlementIterator, GuildIterator +from ..monetization import SKU +from ..object import Object +from ..soundboard import SoundboardSound +from ..stage_instance import StageInstance +from ..sticker import (GuildSticker, StandardSticker, StickerPack, + _sticker_factory) +from ..template import Template +from ..user import ClientUser, User +from ..utils.private import (bytes_to_base64_data, resolve_invite, + resolve_template) +from ..webhook import Webhook +from ..widget import Widget +from .base import BaseApp + +if TYPE_CHECKING: + from ..abc import PrivateChannel, Snowflake, SnowflakeTime + from ..channel import DMChannel, GuildChannel + from ..member import Member + from ..soundboard import SoundboardSound + from ..voice_client import VoiceProtocol + +_log = logging.getLogger(__name__) + + +class HTTPApp(BaseApp): + @property + def voice_clients(self) -> list[VoiceProtocol]: + """Represents a list of voice connections. + + These are usually :class:`.VoiceClient` instances. + """ + return self._connection.voice_clients + + @property + def application_id(self) -> int | None: + """The client's application ID. + + If this is not passed via ``__init__`` then this is retrieved + through the gateway when an event contains the data. Usually + after :func:`~discord.on_connect` is called. + + .. versionadded:: 2.0 + """ + return self._connection.application_id + + @property + def application_flags(self) -> ApplicationFlags: + """The client's application flags. + + .. versionadded:: 2.0 + """ + return self._connection.application_flags # type: ignore + + async def login(self, token: str) -> None: + """|coro| + + Logs in the client with the specified credentials. + + Parameters + ---------- + token: :class:`str` + The authentication token. Do not prefix this token with + anything as the library will do it for you. + + Raises + ------ + TypeError + The token was in invalid type. + :exc:`LoginFailure` + The wrong credentials are passed. + :exc:`HTTPException` + An unknown HTTP related error occurred, + usually when it isn't 200 or the known incorrect credentials + passing status code. + """ + if not isinstance(token, str): + raise TypeError(f"token must be of type str, not {token.__class__.__name__}") + + _log.info("logging in using static token") + + data = await self.http.static_login(token.strip()) + self._connection.user = ClientUser(state=self._connection, data=data) + + print_banner( + bot_name=self._connection.user.display_name, + module=self._banner_module or "discord", + ) + start_logging(self._flavor, debug=self._debug) + + async def is_owner(self, user: User | Member) -> bool: + """|coro| + + Checks if a :class:`~discord.User` or :class:`~discord.Member` is the owner of + this bot. + + If an :attr:`owner_id` is not set, it is fetched automatically + through the use of :meth:`~.Bot.application_info`. + + .. versionchanged:: 1.3 + The function also checks if the application is team-owned if + :attr:`owner_ids` is not set. + + Parameters + ---------- + user: Union[:class:`.abc.User`, :class:`.member.Member`] + The user to check for. + + Returns + ------- + :class:`bool` + Whether the user is the owner. + """ + + if self.owner_id: + return user.id == self.owner_id + elif self.owner_ids: + return user.id in self.owner_ids + else: + app = await self.application_info() # type: ignore + if app.team: + self.owner_ids = ids = {m.id for m in app.team.members} + return user.id in ids + else: + self.owner_id = owner_id = app.owner.id + return user.id == owner_id + + async def fetch_application(self, application_id: int, /) -> PartialAppInfo: + """|coro| + Retrieves a :class:`.PartialAppInfo` from an application ID. + + Parameters + ---------- + application_id: :class:`int` + The application ID to retrieve information from. + + Returns + ------- + :class:`.PartialAppInfo` + The application information. + + Raises + ------ + NotFound + An application with this ID does not exist. + HTTPException + Retrieving the application failed. + """ + data = await self.http.get_application(application_id) + return PartialAppInfo(state=self._connection, data=data) + + def fetch_guilds( + self, + *, + limit: int | None = 100, + before: SnowflakeTime = None, + after: SnowflakeTime = None, + with_counts: bool = True, + ) -> GuildIterator: + """Retrieves an :class:`.AsyncIterator` that enables receiving your guilds. + + .. note:: + + Using this, you will only receive :attr:`.Guild.owner`, :attr:`.Guild.icon`, + :attr:`.Guild.id`, and :attr:`.Guild.name` per :class:`.Guild`. + + .. note:: + + This method is an API call. For general usage, consider :attr:`guilds` instead. + + Parameters + ---------- + limit: Optional[:class:`int`] + The number of guilds to retrieve. + If ``None``, it retrieves every guild you have access to. Note, however, + that this would make it a slow operation. + Defaults to ``100``. + before: Union[:class:`.abc.Snowflake`, :class:`datetime.datetime`] + Retrieves guilds before this date or object. + If a datetime is provided, it is recommended to use a UTC aware datetime. + If the datetime is naive, it is assumed to be local time. + after: Union[:class:`.abc.Snowflake`, :class:`datetime.datetime`] + Retrieve guilds after this date or object. + If a datetime is provided, it is recommended to use a UTC aware datetime. + If the datetime is naive, it is assumed to be local time. + with_counts: :class:`bool` + Whether to include member count information in guilds. This fills the + :attr:`.Guild.approximate_member_count` and :attr:`.Guild.approximate_presence_count` + fields. + Defaults to ``True``. + + Yields + ------ + :class:`.Guild` + The guild with the guild data parsed. + + Raises + ------ + :exc:`HTTPException` + Getting the guilds failed. + + Examples + -------- + + Usage :: + + async for guild in client.fetch_guilds(limit=150): + print(guild.name) + + Flattening into a list :: + + guilds = await client.fetch_guilds(limit=150).flatten() + # guilds is now a list of Guild... + + All parameters are optional. + """ + return GuildIterator(self, limit=limit, before=before, after=after, with_counts=with_counts) + + async def fetch_template(self, code: Template | str) -> Template: + """|coro| + + Gets a :class:`.Template` from a discord.new URL or code. + + Parameters + ---------- + code: Union[:class:`.Template`, :class:`str`] + The Discord Template Code or URL (must be a discord.new URL). + + Returns + ------- + :class:`.Template` + The template from the URL/code. + + Raises + ------ + :exc:`NotFound` + The template is invalid. + :exc:`HTTPException` + Getting the template failed. + """ + code = resolve_template(code) + data = await self.http.get_template(code) + return await Template.from_data(data=data, state=self._connection) # type: ignore + + async def fetch_guild(self, guild_id: int, /, *, with_counts=True) -> Guild: + """|coro| + + Retrieves a :class:`.Guild` from an ID. + + .. note:: + + Using this, you will **not** receive :attr:`.Guild.channels`, :attr:`.Guild.members`, + :attr:`.Member.activity` and :attr:`.Member.voice` per :class:`.Member`. + + .. note:: + + This method is an API call. For general usage, consider :meth:`get_guild` instead. + + Parameters + ---------- + guild_id: :class:`int` + The guild's ID to fetch from. + + with_counts: :class:`bool` + Whether to include count information in the guild. This fills the + :attr:`.Guild.approximate_member_count` and :attr:`.Guild.approximate_presence_count` + fields. + + .. versionadded:: 2.0 + + Returns + ------- + :class:`.Guild` + The guild from the ID. + + Raises + ------ + :exc:`Forbidden` + You do not have access to the guild. + :exc:`HTTPException` + Getting the guild failed. + """ + data = await self.http.get_guild(guild_id, with_counts=with_counts) + return await Guild._from_data(guild=data, state=self._connection) + + async def fetch_stage_instance(self, channel_id: int, /) -> StageInstance: + """|coro| + + Gets a :class:`.StageInstance` for a stage channel id. + + .. versionadded:: 2.0 + + Parameters + ---------- + channel_id: :class:`int` + The stage channel ID. + + Returns + ------- + :class:`.StageInstance` + The stage instance from the stage channel ID. + + Raises + ------ + :exc:`NotFound` + The stage instance or channel could not be found. + :exc:`HTTPException` + Getting the stage instance failed. + """ + data = await self.http.get_stage_instance(channel_id) + guild = self.get_guild(int(data["guild_id"])) + return StageInstance(guild=guild, state=self._connection, data=data) # type: ignore + + # Invite management + + async def fetch_invite( + self, + url: Invite | str, + *, + with_counts: bool = True, + with_expiration: bool = True, + event_id: int | None = None, + ) -> Invite: + """|coro| + + Gets an :class:`.Invite` from a discord.gg URL or ID. + + .. note:: + + If the invite is for a guild you have not joined, the guild and channel + attributes of the returned :class:`.Invite` will be :class:`.PartialInviteGuild` and + :class:`.PartialInviteChannel` respectively. + + Parameters + ---------- + url: Union[:class:`.Invite`, :class:`str`] + The Discord invite ID or URL (must be a discord.gg URL). + with_counts: :class:`bool` + Whether to include count information in the invite. This fills the + :attr:`.Invite.approximate_member_count` and :attr:`.Invite.approximate_presence_count` + fields. + with_expiration: :class:`bool` + Whether to include the expiration date of the invite. This fills the + :attr:`.Invite.expires_at` field. + + .. versionadded:: 2.0 + event_id: Optional[:class:`int`] + The ID of the scheduled event to be associated with the event. + + See :meth:`Invite.set_scheduled_event` for more + info on event invite linking. + + .. versionadded:: 2.0 + + Returns + ------- + :class:`.Invite` + The invite from the URL/ID. + + Raises + ------ + :exc:`NotFound` + The invite has expired or is invalid. + :exc:`HTTPException` + Getting the invite failed. + """ + + invite_id = resolve_invite(url) + data = await self.http.get_invite( + invite_id, + with_counts=with_counts, + with_expiration=with_expiration, + guild_scheduled_event_id=event_id, + ) + return await Invite.from_incomplete(state=self._connection, data=data) + + async def delete_invite(self, invite: Invite | str) -> None: + """|coro| + + Revokes an :class:`.Invite`, URL, or ID to an invite. + + You must have the :attr:`~.Permissions.manage_channels` permission in + the associated guild to do this. + + Parameters + ---------- + invite: Union[:class:`.Invite`, :class:`str`] + The invite to revoke. + + Raises + ------ + :exc:`Forbidden` + You do not have permissions to revoke invites. + :exc:`NotFound` + The invite is invalid or expired. + :exc:`HTTPException` + Revoking the invite failed. + """ + + invite_id = resolve_invite(invite) + await self.http.delete_invite(invite_id) + + # Miscellaneous stuff + + async def fetch_widget(self, guild_id: int, /) -> Widget: + """|coro| + + Gets a :class:`.Widget` from a guild ID. + + .. note:: + + The guild must have the widget enabled to get this information. + + Parameters + ---------- + guild_id: :class:`int` + The ID of the guild. + + Returns + ------- + :class:`.Widget` + The guild's widget. + + Raises + ------ + :exc:`Forbidden` + The widget for this guild is disabled. + :exc:`HTTPException` + Retrieving the widget failed. + """ + data = await self.http.get_widget(guild_id) + + return Widget(state=self._connection, data=data) + + async def application_info(self) -> AppInfo: + """|coro| + + Retrieves the bot's application information. + + Returns + ------- + :class:`.AppInfo` + The bot's application information. + + Raises + ------ + :exc:`HTTPException` + Retrieving the information failed somehow. + """ + data = await self.http.application_info() + if "rpc_origins" not in data: + data["rpc_origins"] = None + return AppInfo(self._connection, data) + + async def fetch_user(self, user_id: int, /) -> User: + """|coro| + + Retrieves a :class:`~discord.User` based on their ID. + You do not have to share any guilds with the user to get this information, + however many operations do require that you do. + + .. note:: + + This method is an API call. If you have :attr:`discord.Intents.members` and member cache enabled, + consider :meth:`get_user` instead. + + Parameters + ---------- + user_id: :class:`int` + The user's ID to fetch from. + + Returns + ------- + :class:`~discord.User` + The user you requested. + + Raises + ------ + :exc:`NotFound` + A user with this ID does not exist. + :exc:`HTTPException` + Fetching the user failed. + """ + data = await self.http.get_user(user_id) + return User(state=self._connection, data=data) + + async def fetch_channel(self, channel_id: int, /) -> GuildChannel | PrivateChannel | Thread: + """|coro| + + Retrieves a :class:`.abc.GuildChannel`, :class:`.abc.PrivateChannel`, or :class:`.Thread` with the specified ID. + + .. note:: + + This method is an API call. For general usage, consider :meth:`get_channel` instead. + + .. versionadded:: 1.2 + + Returns + ------- + Union[:class:`.abc.GuildChannel`, :class:`.abc.PrivateChannel`, :class:`.Thread`] + The channel from the ID. + + Raises + ------ + :exc:`InvalidData` + An unknown channel type was received from Discord. + :exc:`HTTPException` + Retrieving the channel failed. + :exc:`NotFound` + Invalid Channel ID. + :exc:`Forbidden` + You do not have permission to fetch this channel. + """ + data = await self.http.get_channel(channel_id) + + factory, ch_type = _threaded_channel_factory(data["type"]) + if factory is None: + raise InvalidData("Unknown channel type {type} for channel ID {id}.".format_map(data)) + + if ch_type in (ChannelType.group, ChannelType.private): + # the factory will be a DMChannel or GroupChannel here + return factory(me=self.user, data=data, state=self._connection) + # the factory can't be a DMChannel or GroupChannel here + guild_id = int(data["guild_id"]) # type: ignore + guild = self.get_guild(guild_id) or Object(id=guild_id) + # GuildChannels expect a Guild, we may be passing an Object + return factory(guild=guild, state=self._connection, data=data) + + async def fetch_webhook(self, webhook_id: int, /) -> Webhook: + """|coro| + + Retrieves a :class:`.Webhook` with the specified ID. + + Returns + ------- + :class:`.Webhook` + The webhook you requested. + + Raises + ------ + :exc:`HTTPException` + Retrieving the webhook failed. + :exc:`NotFound` + Invalid webhook ID. + :exc:`Forbidden` + You do not have permission to fetch this webhook. + """ + data = await self.http.get_webhook(webhook_id) + return Webhook.from_state(data, state=self._connection) + + async def fetch_sticker(self, sticker_id: int, /) -> StandardSticker | GuildSticker: + """|coro| + + Retrieves a :class:`.Sticker` with the specified ID. + + .. versionadded:: 2.0 + + Returns + ------- + Union[:class:`.StandardSticker`, :class:`.GuildSticker`] + The sticker you requested. + + Raises + ------ + :exc:`HTTPException` + Retrieving the sticker failed. + :exc:`NotFound` + Invalid sticker ID. + """ + data = await self.http.get_sticker(sticker_id) + cls, _ = _sticker_factory(data["type"]) # type: ignore + return cls(state=self._connection, data=data) # type: ignore + + async def fetch_premium_sticker_packs(self) -> list[StickerPack]: + """|coro| + + Retrieves all available premium sticker packs. + + .. versionadded:: 2.0 + + Returns + ------- + List[:class:`.StickerPack`] + All available premium sticker packs. + + Raises + ------ + :exc:`HTTPException` + Retrieving the sticker packs failed. + """ + data = await self.http.list_premium_sticker_packs() + return [StickerPack(state=self._connection, data=pack) for pack in data["sticker_packs"]] + + async def create_dm(self, user: Snowflake) -> DMChannel: + """|coro| + + Creates a :class:`.DMChannel` with this user. + + This should be rarely called, as this is done transparently for most + people. + + .. versionadded:: 2.0 + + Parameters + ---------- + user: :class:`~discord.abc.Snowflake` + The user to create a DM with. + + Returns + ------- + :class:`.DMChannel` + The channel that was created. + """ + state = self._connection + found = await state._get_private_channel_by_user(user.id) + if found: + return found + + data = await state.http.start_private_message(user.id) + return await state.add_dm_channel(data) + + async def fetch_role_connection_metadata_records( + self, + ) -> list[ApplicationRoleConnectionMetadata]: + """|coro| + + Fetches the bot's role connection metadata records. + + .. versionadded:: 2.4 + + Returns + ------- + List[:class:`.ApplicationRoleConnectionMetadata`] + The bot's role connection metadata records. + """ + data = await self._connection.http.get_application_role_connection_metadata_records(self.application_id) + return [ApplicationRoleConnectionMetadata.from_dict(r) for r in data] + + async def update_role_connection_metadata_records( + self, *role_connection_metadata + ) -> list[ApplicationRoleConnectionMetadata]: + """|coro| + + Updates the bot's role connection metadata records. + + .. versionadded:: 2.4 + + Parameters + ---------- + *role_connection_metadata: :class:`ApplicationRoleConnectionMetadata` + The new metadata records to send to Discord. + + Returns + ------- + List[:class:`.ApplicationRoleConnectionMetadata`] + The updated role connection metadata records. + """ + payload = [r.to_dict() for r in role_connection_metadata] + data = await self._connection.http.update_application_role_connection_metadata_records( + self.application_id, payload + ) + return [ApplicationRoleConnectionMetadata.from_dict(r) for r in data] + + async def fetch_skus(self) -> list[SKU]: + """|coro| + + Fetches the bot's SKUs. + + .. versionadded:: 2.5 + + Returns + ------- + List[:class:`.SKU`] + The bot's SKUs. + """ + data = await self._connection.http.list_skus(self.application_id) + return [SKU(state=self._connection, data=s) for s in data] + + def entitlements( + self, + user: Snowflake | None = None, + skus: list[Snowflake] | None = None, + before: SnowflakeTime | None = None, + after: SnowflakeTime | None = None, + limit: int | None = 100, + guild: Snowflake | None = None, + exclude_ended: bool = False, + ) -> EntitlementIterator: + """Returns an :class:`.AsyncIterator` that enables fetching the application's entitlements. + + .. versionadded:: 2.6 + + Parameters + ---------- + user: :class:`.abc.Snowflake` | None + Limit the fetched entitlements to entitlements owned by this user. + skus: list[:class:`.abc.Snowflake`] | None + Limit the fetched entitlements to entitlements that are for these SKUs. + before: :class:`.abc.Snowflake` | :class:`datetime.datetime` | None + Retrieves guilds before this date or object. + If a datetime is provided, it is recommended to use a UTC-aware datetime. + If the datetime is naive, it is assumed to be local time. + after: :class:`.abc.Snowflake` | :class:`datetime.datetime` | None + Retrieve guilds after this date or object. + If a datetime is provided, it is recommended to use a UTC-aware datetime. + If the datetime is naive, it is assumed to be local time. + limit: Optional[:class:`int`] + The number of entitlements to retrieve. + If ``None``, retrieves every entitlement, which may be slow. + Defaults to ``100``. + guild: :class:`.abc.Snowflake` | None + Limit the fetched entitlements to entitlements owned by this guild. + exclude_ended: :class:`bool` + Whether to limit the fetched entitlements to those that have not ended. + Defaults to ``False``. + + Yields + ------ + :class:`.Entitlement` + The application's entitlements. + + Raises + ------ + :exc:`HTTPException` + Retrieving the entitlements failed. + + Examples + -------- + + Usage :: + + async for entitlement in client.entitlements(): + print(entitlement.user_id) + + Flattening into a list :: + + entitlements = await user.entitlements().flatten() + + All parameters are optional. + """ + return EntitlementIterator( + self._connection, + user_id=user.id if user else None, + sku_ids=[sku.id for sku in skus] if skus else None, + before=before, + after=after, + limit=limit, + guild_id=guild.id if guild else None, + exclude_ended=exclude_ended, + ) + + @property + def store_url(self) -> str: + """:class:`str`: The URL that leads to the application's store page for monetization. + + .. versionadded:: 2.6 + """ + return f"https://discord.com/application-directory/{self.application_id}/store" + + async def fetch_emojis(self) -> list[AppEmoji]: + r"""|coro| + + Retrieves all custom :class:`AppEmoji`\s from the application. + + Raises + --------- + HTTPException + An error occurred fetching the emojis. + + Returns + -------- + List[:class:`AppEmoji`] + The retrieved emojis. + """ + data = await self._connection.http.get_all_application_emojis(self.application_id) + return [await self._connection.maybe_store_app_emoji(self.application_id, d) for d in data["items"]] + + async def fetch_emoji(self, emoji_id: int, /) -> AppEmoji: + """|coro| + + Retrieves a custom :class:`AppEmoji` from the application. + + Parameters + ---------- + emoji_id: :class:`int` + The emoji's ID. + + Returns + ------- + :class:`AppEmoji` + The retrieved emoji. + + Raises + ------ + NotFound + The emoji requested could not be found. + HTTPException + An error occurred fetching the emoji. + """ + data = await self._connection.http.get_application_emoji(self.application_id, emoji_id) + return await self._connection.maybe_store_app_emoji(self.application_id, data) + + async def create_emoji( + self, + *, + name: str, + image: bytes, + ) -> AppEmoji: + r"""|coro| + + Creates a custom :class:`AppEmoji` for the application. + + There is currently a limit of 2000 emojis per application. + + Parameters + ----------- + name: :class:`str` + The emoji name. Must be at least 2 characters. + image: :class:`bytes` + The :term:`py:bytes-like object` representing the image data to use. + Only JPG, PNG and GIF images are supported. + + Raises + ------- + HTTPException + An error occurred creating an emoji. + + Returns + -------- + :class:`AppEmoji` + The created emoji. + """ + + img = bytes_to_base64_data(image) + data = await self._connection.http.create_application_emoji(self.application_id, name, img) + return await self._connection.maybe_store_app_emoji(self.application_id, data) + + async def delete_emoji(self, emoji: Snowflake) -> None: + """|coro| + + Deletes the custom :class:`AppEmoji` from the application. + + Parameters + ---------- + emoji: :class:`abc.Snowflake` + The emoji you are deleting. + + Raises + ------ + HTTPException + An error occurred deleting the emoji. + """ + + await self._connection.http.delete_application_emoji(self.application_id, emoji.id) + if self._connection.cache_app_emojis and await self._connection.get_emoji(emoji.id): + await self._connection._remove_emoji(emoji) + + async def fetch_default_sounds(self) -> list[SoundboardSound]: + """|coro| + + Fetches the bot's default sounds. + + .. versionadded:: 2.7 + + Returns + ------- + List[:class:`.SoundboardSound`] + The bot's default sounds. + """ + data = await self._connection.http.get_default_sounds() + return [SoundboardSound(http=self.http, state=self._connection, data=s) for s in data] diff --git a/discord/app/state.py b/discord/app/state.py index 0d10dd7cec..32af827ec9 100644 --- a/discord/app/state.py +++ b/discord/app/state.py @@ -32,17 +32,8 @@ import logging import os from collections import OrderedDict, deque -from typing import ( - TYPE_CHECKING, - Any, - Callable, - Coroutine, - Deque, - Sequence, - TypeVar, - Union, - cast, -) +from typing import (TYPE_CHECKING, Any, Callable, Coroutine, Deque, Sequence, + TypeVar, Union, cast) from discord.soundboard import SoundboardSound @@ -167,7 +158,6 @@ def __init__( self, *, cache: Cache, - handlers: dict[str, Callable], hooks: dict[str, Callable], http: HTTPClient, loop: asyncio.AbstractEventLoop, @@ -179,7 +169,6 @@ def __init__( if self.max_messages is not None and self.max_messages <= 0: self.max_messages = 1000 - self.handlers: dict[str, Callable] = handlers self.hooks: dict[str, Callable] = hooks self.shard_count: int | None = None self._ready_task: asyncio.Task | None = None @@ -249,6 +238,8 @@ def __init__( self.cache: Cache = cache self.cache._state = self + self.ready = asyncio.Event() + async def clear(self, *, views: bool = True) -> None: self.user: ClientUser | None = None await self.cache.clear() @@ -268,14 +259,6 @@ async def process_chunk_requests( for key in removed: del self._chunk_requests[key] - def call_handlers(self, key: str, *args: Any, **kwargs: Any) -> None: - try: - func = self.handlers[key] - except KeyError: - pass - else: - func(*args, **kwargs) - async def call_hooks(self, key: str, *args: Any, **kwargs: Any) -> None: try: coro = self.hooks[key] @@ -723,7 +706,6 @@ async def _delay_ready(self) -> None: self._ready_task = None # dispatch the event - self.call_handlers("ready") self.dispatch("ready") def parse_ready(self, data) -> None: diff --git a/discord/bot.py b/discord/bot.py index bc9c683a43..e56326b89f 100644 --- a/discord/bot.py +++ b/discord/bot.py @@ -81,1025 +81,7 @@ _log = logging.getLogger(__name__) -class ApplicationCommandMixin(ABC): - """A mixin that implements common functionality for classes that need - application command compatibility. - - Attributes - ---------- - application_commands: :class:`dict` - A mapping of command id string to :class:`.ApplicationCommand` objects. - pending_application_commands: :class:`list` - A list of commands that have been added but not yet registered. This is read-only and is modified via other - methods. - """ - - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - self._pending_application_commands = [] - self._application_commands = {} - - @property - def all_commands(self): - return self._application_commands - - @property - def pending_application_commands(self): - return self._pending_application_commands - - @property - def commands(self) -> list[ApplicationCommand | Any]: - commands = self.application_commands - if self._bot._supports_prefixed_commands and hasattr(self._bot, "prefixed_commands"): - commands += self._bot.prefixed_commands - return commands - - @property - def application_commands(self) -> list[ApplicationCommand]: - return list(self._application_commands.values()) - - def add_application_command(self, command: ApplicationCommand) -> None: - """Adds an :class:`.ApplicationCommand` into the internal list of commands. - - This is usually not called, instead the :meth:`command` or - other shortcut decorators are used instead. - - .. versionadded:: 2.0 - - Parameters - ---------- - command: :class:`.ApplicationCommand` - The command to add. - """ - if isinstance(command, SlashCommand) and command.is_subcommand: - raise TypeError("The provided command is a sub-command of group") - - if self._bot.debug_guilds and command.guild_ids is None: - command.guild_ids = self._bot.debug_guilds - if self._bot.default_command_contexts and command.contexts is None: - command.contexts = self._bot.default_command_contexts - if self._bot.default_command_integration_types and command.integration_types is None: - command.integration_types = self._bot.default_command_integration_types - - for cmd in self.pending_application_commands: - if cmd == command: - command.id = cmd.id - self._application_commands[command.id] = command - break - self._pending_application_commands.append(command) - - def remove_application_command(self, command: ApplicationCommand) -> ApplicationCommand | None: - """Remove an :class:`.ApplicationCommand` from the internal list - of commands. - - .. versionadded:: 2.0 - - Parameters - ---------- - command: :class:`.ApplicationCommand` - The command to remove. - - Returns - ------- - Optional[:class:`.ApplicationCommand`] - The command that was removed. If the command has not been added, - ``None`` is returned instead. - """ - if command.id: - self._application_commands.pop(command.id, None) - - if command in self._pending_application_commands: - self._pending_application_commands.remove(command) - return command - - @property - def get_command(self): - """Shortcut for :meth:`.get_application_command`. - - .. note:: - Overridden in :class:`ext.commands.Bot`. - - .. versionadded:: 2.0 - """ - # TODO: Do something like we did in self.commands for this - return self.get_application_command - - def get_application_command( - self, - name: str, - guild_ids: list[int] | None = None, - type: type[ApplicationCommand] = ApplicationCommand, - ) -> ApplicationCommand | None: - """Get an :class:`.ApplicationCommand` from the internal list - of commands. - - .. versionadded:: 2.0 - - Parameters - ---------- - name: :class:`str` - The qualified name of the command to get. - guild_ids: List[:class:`int`] - The guild ids associated to the command to get. - type: Type[:class:`.ApplicationCommand`] - The type of the command to get. Defaults to :class:`.ApplicationCommand`. - - Returns - ------- - Optional[:class:`.ApplicationCommand`] - The command that was requested. If not found, returns ``None``. - """ - commands = self._application_commands.values() - for command in commands: - if command.name == name and isinstance(command, type): - if guild_ids is not None and command.guild_ids != guild_ids: - return - return command - elif (names := name.split())[0] == command.name and isinstance(command, SlashCommandGroup): - while len(names) > 1: - command = find(lambda c: c.name == names.pop(0), commands) - if not isinstance(command, SlashCommandGroup) or ( - guild_ids is not None and command.guild_ids != guild_ids - ): - return - commands = command.subcommands - command = find(lambda c: c.name == names.pop(), commands) - if not isinstance(command, type) or (guild_ids is not None and command.guild_ids != guild_ids): - return - return command - - async def get_desynced_commands( - self, - guild_id: int | None = None, - prefetched: list[interactions.ApplicationCommand] | None = None, - ) -> list[dict[str, Any]]: - """|coro| - - Gets the list of commands that are desynced from discord. If ``guild_id`` is specified, it will only return - guild commands that are desynced from said guild, else it will return global commands. - - .. note:: - This function is meant to be used internally, and should only be used if you want to override the default - command registration behavior. - - .. versionadded:: 2.0 - - Parameters - ---------- - guild_id: Optional[:class:`int`] - The guild id to get the desynced commands for, else global commands if unspecified. - prefetched: Optional[List[:class:`.ApplicationCommand`]] - If you already fetched the commands, you can pass them here to be used. Not recommended for typical usage. - - Returns - ------- - List[Dict[:class:`str`, Any]] - A list of the desynced commands. Each will come with at least the ``cmd`` and ``action`` keys, which - respectively contain the command and the action to perform. Other keys may also be present depending on - the action, including ``id``. - """ - - # We can suggest the user to upsert, edit, delete, or bulk upsert the commands - - def _check_command(cmd: ApplicationCommand, match: Mapping[str, Any]) -> bool: - if isinstance(cmd, SlashCommandGroup): - if len(cmd.subcommands) != len(match.get("options", [])): - return True - for subcommand in cmd.subcommands: - match_ = next( - (data for data in match["options"] if data["name"] == subcommand.name), - MISSING, - ) - if match_ is not MISSING and _check_command(subcommand, match_): - return True - else: - as_dict = cmd.to_dict() - to_check = { - "nsfw": None, - "default_member_permissions": None, - "name": None, - "description": None, - "name_localizations": None, - "description_localizations": None, - "options": [ - "type", - "name", - "description", - "autocomplete", - "choices", - "name_localizations", - "description_localizations", - ], - "contexts": None, - "integration_types": None, - } - for check, value in to_check.items(): - if type(value) == list: - # We need to do some falsy conversion here - # The API considers False (autocomplete) and [] (choices) to be falsy values - falsy_vals = (False, []) - for opt in value: - cmd_vals = [val.get(opt, MISSING) for val in as_dict[check]] if check in as_dict else [] - for i, val in enumerate(cmd_vals): - if val in falsy_vals: - cmd_vals[i] = MISSING - if match.get(check, MISSING) is not MISSING and cmd_vals != [ - val.get(opt, MISSING) for val in match[check] - ]: - # We have a difference - return True - elif (attr := getattr(cmd, check, None)) != (found := match.get(check)): - # We might have a difference - if "localizations" in check and bool(attr) == bool(found): - # unlike other attrs, localizations are MISSING by default - continue - elif check == "default_permission" and attr is True and found is None: - # This is a special case - # TODO: Remove for perms v2 - continue - return True - return False - - return_value = [] - cmds = self.pending_application_commands.copy() - - if guild_id is None: - pending = [cmd for cmd in cmds if cmd.guild_ids is None] - else: - pending = [cmd for cmd in cmds if cmd.guild_ids is not None and guild_id in cmd.guild_ids] - - registered_commands: list[interactions.ApplicationCommand] = [] - if prefetched is not None: - registered_commands = prefetched - elif self._bot.user: - if guild_id is None: - registered_commands = await self._bot.http.get_global_commands(self._bot.user.id) - else: - registered_commands = await self._bot.http.get_guild_commands(self._bot.user.id, guild_id) - - registered_commands_dict = {cmd["name"]: cmd for cmd in registered_commands} - # First let's check if the commands we have locally are the same as the ones on discord - for cmd in pending: - match = registered_commands_dict.get(cmd.name) - if match is None: - # We don't have this command registered - return_value.append({"command": cmd, "action": "upsert"}) - elif _check_command(cmd, match): - return_value.append( - { - "command": cmd, - "action": "edit", - "id": int(registered_commands_dict[cmd.name]["id"]), - } - ) - else: - # We have this command registered but it's the same - return_value.append({"command": cmd, "action": None, "id": int(match["id"])}) - - # Now let's see if there are any commands on discord that we need to delete - for _, value_ in registered_commands_dict.items(): - # name default arg is used because loop variables leak in surrounding scope - match = find(lambda c, name=value_["name"]: c.name == name, pending) - if match is None: - # We have this command registered but not in our list - return_value.append( - { - "command": value_["name"], - "id": int(value_["id"]), - "action": "delete", - } - ) - - continue - - return return_value - - async def register_command( - self, - command: ApplicationCommand, - force: bool = True, - guild_ids: list[int] | None = None, - ) -> None: - """|coro| - - Registers a command. If the command has ``guild_ids`` set, or if the ``guild_ids`` parameter is passed, - the command will be registered as a guild command for those guilds. - - Parameters - ---------- - command: :class:`~.ApplicationCommand` - The command to register. - force: :class:`bool` - Whether to force the command to be registered. If this is set to False, the command will only be registered - if it seems to already be registered and up to date with our internal cache. Defaults to True. - guild_ids: :class:`list` - A list of guild ids to register the command for. If this is not set, the command's - :attr:`ApplicationCommand.guild_ids` attribute will be used. - - Returns - ------- - :class:`~.ApplicationCommand` - The command that was registered - """ - # TODO: Write this - raise NotImplementedError - - async def register_commands( - self, - commands: list[ApplicationCommand] | None = None, - guild_id: int | None = None, - method: Literal["individual", "bulk", "auto"] = "bulk", - force: bool = False, - delete_existing: bool = True, - ) -> list[interactions.ApplicationCommand]: - """|coro| - - Register a list of commands. - - .. versionadded:: 2.0 - - Parameters - ---------- - commands: Optional[List[:class:`~.ApplicationCommand`]] - A list of commands to register. If this is not set (``None``), then all commands will be registered. - guild_id: Optional[int] - If this is set, the commands will be registered as a guild command for the respective guild. If it is not - set, the commands will be registered according to their :attr:`ApplicationCommand.guild_ids` attribute. - method: Literal['individual', 'bulk', 'auto'] - The method to use when registering the commands. If this is set to "individual", then each command will be - registered individually. If this is set to "bulk", then all commands will be registered in bulk. If this is - set to "auto", then the method will be determined automatically. Defaults to "bulk". - force: :class:`bool` - Registers the commands regardless of the state of the command on Discord. This uses one less API call, but - can result in hitting rate limits more often. Defaults to False. - delete_existing: :class:`bool` - Whether to delete existing commands that are not in the list of commands to register. Defaults to True. - """ - if commands is None: - commands = self.pending_application_commands - - commands = [copy.copy(cmd) for cmd in commands] - - if guild_id is not None: - for cmd in commands: - to_rep_with = [guild_id] - cmd.guild_ids = to_rep_with - - is_global = guild_id is None - - registered = [] - - if is_global: - pending = list(filter(lambda c: c.guild_ids is None, commands)) - registration_methods = { - "bulk": self._bot.http.bulk_upsert_global_commands, - "upsert": self._bot.http.upsert_global_command, - "delete": self._bot.http.delete_global_command, - "edit": self._bot.http.edit_global_command, - } - - def _register(method: Literal["bulk", "upsert", "delete", "edit"], *args, **kwargs): - return registration_methods[method](self._bot.user and self._bot.user.id, *args, **kwargs) - - else: - pending = list( - filter( - lambda c: c.guild_ids is not None and guild_id in c.guild_ids, - commands, - ) - ) - registration_methods = { - "bulk": self._bot.http.bulk_upsert_guild_commands, - "upsert": self._bot.http.upsert_guild_command, - "delete": self._bot.http.delete_guild_command, - "edit": self._bot.http.edit_guild_command, - } - - def _register(method: Literal["bulk", "upsert", "delete", "edit"], *args, **kwargs): - return registration_methods[method](self._bot.user and self._bot.user.id, guild_id, *args, **kwargs) - - def register( - method: Literal["bulk", "upsert", "delete", "edit"], - *args, - cmd_name: str = None, - guild_id: int | None = None, - **kwargs, - ): - if kwargs.pop("_log", True): - if method == "bulk": - _log.debug(f"Bulk updating commands {[c['name'] for c in args[0]]} for guild {guild_id}") - elif method == "upsert": - _log.debug(f"Creating command {cmd_name} for guild {guild_id}") # type: ignore - elif method == "edit": - _log.debug(f"Editing command {cmd_name} for guild {guild_id}") # type: ignore - elif method == "delete": - _log.debug(f"Deleting command {cmd_name} for guild {guild_id}") # type: ignore - return _register(method, *args, **kwargs) - - pending_actions = [] - - if not force: - prefetched_commands: list[interactions.ApplicationCommand] = [] - if self._bot.user: - if guild_id is None: - prefetched_commands = await self._bot.http.get_global_commands(self._bot.user.id) - else: - prefetched_commands = await self._bot.http.get_guild_commands(self._bot.user.id, guild_id) - desynced = await self.get_desynced_commands(guild_id=guild_id, prefetched=prefetched_commands) - - for cmd in desynced: - if cmd["action"] == "delete": - pending_actions.append( - { - "action": "delete" if delete_existing else None, - "command": collections.namedtuple("Command", ["name"])(name=cmd["command"]), - "id": cmd["id"], - } - ) - continue - # We can assume the command item is a command, since it's only a string if action is delete - wanted = cmd["command"] - name = wanted.name - type_ = wanted.type - - match = next((c for c in pending if c.name == name and c.type == type_), None) - if match is None: - continue - if cmd["action"] == "edit": - pending_actions.append( - { - "action": "edit", - "command": match, - "id": cmd["id"], - } - ) - elif cmd["action"] == "upsert": - pending_actions.append( - { - "action": "upsert", - "command": match, - } - ) - elif cmd["action"] is None: - pending_actions.append( - { - "action": None, - "command": match, - } - ) - else: - raise ValueError(f"Unknown action: {cmd['action']}") - filtered_no_action = list(filter(lambda c: c["action"] is not None, pending_actions)) - filtered_deleted = list(filter(lambda a: a["action"] != "delete", pending_actions)) - if method == "bulk" or (method == "auto" and len(filtered_deleted) == len(pending)): - # Either the method is bulk or all the commands need to be modified, so we can just do a bulk upsert - data = [cmd["command"].to_dict() for cmd in filtered_deleted] - # If there's nothing to update, don't bother - if len(filtered_no_action) == 0: - _log.debug("Skipping bulk command update: Commands are up to date") - registered = prefetched_commands - else: - _log.debug( - "Bulk updating commands %s for guild %s", - {c["command"].name: c["action"] for c in pending_actions}, - guild_id, - ) - registered = await register("bulk", data, _log=False) - else: - if not filtered_no_action: - registered = [] - for cmd in filtered_no_action: - if cmd["action"] == "delete": - await register( - "delete", - cmd["id"], - cmd_name=cmd["command"].name, - guild_id=guild_id, - ) - continue - if cmd["action"] == "edit": - registered.append( - await register( - "edit", - cmd["id"], - cmd["command"].to_dict(), - cmd_name=cmd["command"].name, - guild_id=guild_id, - ) - ) - elif cmd["action"] == "upsert": - registered.append( - await register( - "upsert", - cmd["command"].to_dict(), - cmd_name=cmd["command"].name, - guild_id=guild_id, - ) - ) - else: - raise ValueError(f"Unknown action: {cmd['action']}") - - # TODO: Our lists dont work sometimes, see if that can be fixed so we can avoid this second API call - if method != "bulk": - if self._bot.user: - if guild_id is None: - registered = await self._bot.http.get_global_commands(self._bot.user.id) - else: - registered = await self._bot.http.get_guild_commands(self._bot.user.id, guild_id) - else: - data = [cmd.to_dict() for cmd in pending] - registered = await register("bulk", data, guild_id=guild_id) - - for i in registered: - type_ = i.get("type") - # name, type_ default args are used because loop variables leak in surrounding scope - cmd = find( - lambda c, name=i["name"], type_=type_: c.name == name and c.type == type_, - self.pending_application_commands, - ) - if not cmd: - raise ValueError(f"Registered command {i['name']}, type {i.get('type')} not found in pending commands") - cmd.id = i["id"] - self._application_commands[cmd.id] = cmd - - return registered - - async def sync_commands( - self, - commands: list[ApplicationCommand] | None = None, - method: Literal["individual", "bulk", "auto"] = "bulk", - force: bool = False, - guild_ids: list[int] | None = None, - register_guild_commands: bool = True, - check_guilds: list[int] | None = None, - delete_existing: bool = True, - ) -> None: - """|coro| - - Registers all commands that have been added through :meth:`.add_application_command`. This method cleans up all - commands over the API and should sync them with the internal cache of commands. It attempts to register the - commands in the most efficient way possible, unless ``force`` is set to ``True``, in which case it will always - register all commands. - - By default, this coroutine is called inside the :func:`.on_connect` event. If you choose to override the - :func:`.on_connect` event, then you should invoke this coroutine as well such as the following: - - .. code-block:: python - - @bot.event - async def on_connect(): - if bot.auto_sync_commands: - await bot.sync_commands() - print(f"{bot.user.name} connected.") - - .. note:: - If you remove all guild commands from a particular guild, the library may not be able to detect and update - the commands accordingly, as it would have to individually check for each guild. To force the library to - unregister a guild's commands, call this function with ``commands=[]`` and ``guild_ids=[guild_id]``. - - .. versionadded:: 2.0 - - Parameters - ---------- - commands: Optional[List[:class:`~.ApplicationCommand`]] - A list of commands to register. If this is not set (None), then all commands will be registered. - method: Literal['individual', 'bulk', 'auto'] - The method to use when registering the commands. If this is set to "individual", then each command will be - registered individually. If this is set to "bulk", then all commands will be registered in bulk. If this is - set to "auto", then the method will be determined automatically. Defaults to "bulk". - force: :class:`bool` - Registers the commands regardless of the state of the command on Discord. This uses one less API call, but - can result in hitting rate limits more often. Defaults to False. - guild_ids: Optional[List[:class:`int`]] - A list of guild ids to register the commands for. If this is not set, the commands' - :attr:`~.ApplicationCommand.guild_ids` attribute will be used. - register_guild_commands: :class:`bool` - Whether to register guild commands. Defaults to True. - check_guilds: Optional[List[:class:`int`]] - A list of guilds ids to check for commands to unregister, since the bot would otherwise have to check all - guilds. Unlike ``guild_ids``, this does not alter the commands' :attr:`~.ApplicationCommand.guild_ids` - attribute, instead it adds the guild ids to a list of guilds to sync commands for. If - ``register_guild_commands`` is set to False, then this parameter is ignored. - delete_existing: :class:`bool` - Whether to delete existing commands that are not in the list of commands to register. Defaults to True. - """ - - check_guilds = list(set((check_guilds or []) + (self._bot.debug_guilds or []))) - - if commands is None: - commands = self.pending_application_commands - - if guild_ids is not None: - for cmd in commands: - cmd.guild_ids = guild_ids - - global_commands = [cmd for cmd in commands if cmd.guild_ids is None] - registered_commands = await self.register_commands( - global_commands, method=method, force=force, delete_existing=delete_existing - ) - - registered_guild_commands: dict[int, list[interactions.ApplicationCommand]] = {} - - if register_guild_commands: - cmd_guild_ids: list[int] = [] - for cmd in commands: - if cmd.guild_ids is not None: - cmd_guild_ids.extend(cmd.guild_ids) - if check_guilds is not None: - cmd_guild_ids.extend(check_guilds) - for guild_id in set(cmd_guild_ids): - guild_commands = [cmd for cmd in commands if cmd.guild_ids is not None and guild_id in cmd.guild_ids] - app_cmds = await self.register_commands( - guild_commands, - guild_id=guild_id, - method=method, - force=force, - delete_existing=delete_existing, - ) - registered_guild_commands[guild_id] = app_cmds - - for item in registered_commands: - type_ = item.get("type") - # name, type_ default args are used because loop variables leak in surrounding scope - cmd = find( - lambda c, name=item["name"], type_=type_: (c.name == name and c.guild_ids is None and c.type == type_), - self.pending_application_commands, - ) - if cmd: - cmd.id = item["id"] - self._application_commands[cmd.id] = cmd - - if register_guild_commands and registered_guild_commands: - for guild_id, guild_cmds in registered_guild_commands.items(): - for i in guild_cmds: - name = i["name"] - type_ = i.get("type") - target_gid = i.get("guild_id") - if target_gid is None: - continue - - cmd = next( - ( - c - for c in self.pending_application_commands - if c.name == name - and c.type == type_ - and c.guild_ids is not None - and target_gid == guild_id - and target_gid in c.guild_ids - ), - None, - ) - if not cmd: - # command has not been added yet - continue - cmd.id = i["id"] - self._application_commands[cmd.id] = cmd - - async def process_application_commands(self, interaction: Interaction, auto_sync: bool | None = None) -> None: - """|coro| - - This function processes the commands that have been registered - to the bot and other groups. Without this coroutine, none of the - commands will be triggered. - - By default, this coroutine is called inside the :func:`.on_interaction` - event. If you choose to override the :func:`.on_interaction` event, then - you should invoke this coroutine as well. - - This function finds a registered command matching the interaction id from - application commands and invokes it. If no matching command was - found, it replies to the interaction with a default message. - - .. versionadded:: 2.0 - - Parameters - ---------- - interaction: :class:`discord.Interaction` - The interaction to process - auto_sync: Optional[:class:`bool`] - Whether to automatically sync and unregister the command if it is not found in the internal cache. This will - invoke the :meth:`~.Bot.sync_commands` method on the context of the command, either globally or per-guild, - based on the type of the command, respectively. Defaults to :attr:`.Bot.auto_sync_commands`. - """ - if auto_sync is None: - auto_sync = self._bot.auto_sync_commands - # TODO: find out why the isinstance check below doesn't stop the type errors below - if interaction.type not in ( - InteractionType.application_command, - InteractionType.auto_complete, - ): - return - - command: ApplicationCommand | None = None - try: - if interaction.data: - command = self._application_commands[interaction.data["id"]] # type: ignore - except KeyError: - for cmd in self.application_commands + self.pending_application_commands: - if interaction.data: - guild_id = interaction.data.get("guild_id") - if guild_id: - guild_id = int(guild_id) - if cmd.name == interaction.data["name"] and ( # type: ignore - guild_id == cmd.guild_ids or (isinstance(cmd.guild_ids, list) and guild_id in cmd.guild_ids) - ): - command = cmd - break - else: - if auto_sync and interaction.data: - guild_id = interaction.data.get("guild_id") - if guild_id is None: - await self.sync_commands() - else: - await self.sync_commands(check_guilds=[guild_id]) - return self._bot.dispatch("unknown_application_command", interaction) - - if interaction.type is InteractionType.auto_complete: - return self._bot.dispatch("application_command_auto_complete", interaction, command) - - ctx = await self.get_application_context(interaction) - if command: - interaction.command = command - await self.invoke_application_command(ctx) - - async def on_application_command_auto_complete(self, interaction: Interaction, command: ApplicationCommand) -> None: - async def callback() -> None: - ctx = await self.get_autocomplete_context(interaction) - interaction.command = command - return await command.invoke_autocomplete_callback(ctx) - - autocomplete_task = self._bot.loop.create_task(callback()) - try: - await self._bot.wait_for( - "application_command_auto_complete", - check=lambda i, c: c == command, - timeout=3, - ) - except asyncio.TimeoutError: - return - else: - if not autocomplete_task.done(): - autocomplete_task.cancel() - - def slash_command(self, **kwargs): - """A shortcut decorator that invokes :func:`command` and adds it to - the internal command list via :meth:`add_application_command`. - This shortcut is made specifically for :class:`.SlashCommand`. - - .. versionadded:: 2.0 - - Returns - ------- - Callable[..., :class:`SlashCommand`] - A decorator that converts the provided method into a :class:`.SlashCommand`, adds it to the bot, - then returns it. - """ - return self.application_command(cls=SlashCommand, **kwargs) - - def user_command(self, **kwargs): - """A shortcut decorator that invokes :func:`command` and adds it to - the internal command list via :meth:`add_application_command`. - This shortcut is made specifically for :class:`.UserCommand`. - - .. versionadded:: 2.0 - - Returns - ------- - Callable[..., :class:`UserCommand`] - A decorator that converts the provided method into a :class:`.UserCommand`, adds it to the bot, - then returns it. - """ - return self.application_command(cls=UserCommand, **kwargs) - - def message_command(self, **kwargs): - """A shortcut decorator that invokes :func:`command` and adds it to - the internal command list via :meth:`add_application_command`. - This shortcut is made specifically for :class:`.MessageCommand`. - - .. versionadded:: 2.0 - - Returns - ------- - Callable[..., :class:`MessageCommand`] - A decorator that converts the provided method into a :class:`.MessageCommand`, adds it to the bot, - then returns it. - """ - return self.application_command(cls=MessageCommand, **kwargs) - - def application_command(self, **kwargs): - """A shortcut decorator that invokes :func:`command` and adds it to - the internal command list via :meth:`~.Bot.add_application_command`. - - .. versionadded:: 2.0 - - Returns - ------- - Callable[..., :class:`ApplicationCommand`] - A decorator that converts the provided method into an :class:`.ApplicationCommand`, adds it to the bot, - then returns it. - """ - - def decorator(func) -> ApplicationCommand: - result = command(**kwargs)(func) - self.add_application_command(result) - return result - - return decorator - - def command(self, **kwargs): - """An alias for :meth:`application_command`. - - .. note:: - - This decorator is overridden by :class:`discord.ext.commands.Bot`. - - .. versionadded:: 2.0 - - Returns - ------- - Callable[..., :class:`ApplicationCommand`] - A decorator that converts the provided method into an :class:`.ApplicationCommand`, adds it to the bot, - then returns it. - """ - return self.application_command(**kwargs) - - def create_group( - self, - name: str, - description: str | None = None, - guild_ids: list[int] | None = None, - **kwargs, - ) -> SlashCommandGroup: - """A shortcut method that creates a slash command group with no subcommands and adds it to the internal - command list via :meth:`add_application_command`. - - .. versionadded:: 2.0 - - Parameters - ---------- - name: :class:`str` - The name of the group to create. - description: Optional[:class:`str`] - The description of the group to create. - guild_ids: Optional[List[:class:`int`]] - A list of the IDs of each guild this group should be added to, making it a guild command. - This will be a global command if ``None`` is passed. - kwargs: - Any additional keyword arguments to pass to :class:`.SlashCommandGroup`. - - Returns - ------- - SlashCommandGroup - The slash command group that was created. - """ - description = description or "No description provided." - group = SlashCommandGroup(name, description, guild_ids, **kwargs) - self.add_application_command(group) - return group - - def group( - self, - name: str | None = None, - description: str | None = None, - guild_ids: list[int] | None = None, - ) -> Callable[[type[SlashCommandGroup]], SlashCommandGroup]: - """A shortcut decorator that initializes the provided subclass of :class:`.SlashCommandGroup` - and adds it to the internal command list via :meth:`add_application_command`. - - .. versionadded:: 2.0 - - Parameters - ---------- - name: Optional[:class:`str`] - The name of the group to create. This will resolve to the name of the decorated class if ``None`` is passed. - description: Optional[:class:`str`] - The description of the group to create. - guild_ids: Optional[List[:class:`int`]] - A list of the IDs of each guild this group should be added to, making it a guild command. - This will be a global command if ``None`` is passed. - - Returns - ------- - Callable[[Type[SlashCommandGroup]], SlashCommandGroup] - The slash command group that was created. - """ - - def inner(cls: type[SlashCommandGroup]) -> SlashCommandGroup: - group = cls( - name or cls.__name__, - ( - description or inspect.cleandoc(cls.__doc__).splitlines()[0] - if cls.__doc__ is not None - else "No description provided" - ), - guild_ids=guild_ids, - ) - self.add_application_command(group) - return group - - return inner - - slash_group = group - - def walk_application_commands(self) -> Generator[ApplicationCommand]: - """An iterator that recursively walks through all application commands and subcommands. - - Yields - ------ - :class:`.ApplicationCommand` - An application command from the internal list of application commands. - """ - for command in self.application_commands: - if isinstance(command, SlashCommandGroup): - yield from command.walk_commands() - yield command - - async def get_application_context( - self, interaction: Interaction, cls: Any = ApplicationContext - ) -> ApplicationContext: - r"""|coro| - - Returns the invocation context from the interaction. - - This is a more low-level counter-part for :meth:`.process_application_commands` - to allow users more fine-grained control over the processing. - - Parameters - ----------- - interaction: :class:`discord.Interaction` - The interaction to get the invocation context from. - cls - The factory class that will be used to create the context. - By default, this is :class:`.ApplicationContext`. Should a custom - class be provided, it must be similar enough to - :class:`.ApplicationContext`\'s interface. - - Returns - -------- - :class:`.ApplicationContext` - The invocation context. The type of this can change via the - ``cls`` parameter. - """ - return cls(self, interaction) - - async def get_autocomplete_context( - self, interaction: Interaction, cls: Any = AutocompleteContext - ) -> AutocompleteContext: - r"""|coro| - - Returns the autocomplete context from the interaction. - - This is a more low-level counter-part for :meth:`.process_application_commands` - to allow users more fine-grained control over the processing. - - Parameters - ----------- - interaction: :class:`discord.Interaction` - The interaction to get the invocation context from. - cls - The factory class that will be used to create the context. - By default, this is :class:`.AutocompleteContext`. Should a custom - class be provided, it must be similar enough to - :class:`.AutocompleteContext`\'s interface. - - Returns - -------- - :class:`.AutocompleteContext` - The autocomplete context. The type of this can change via the - ``cls`` parameter. - """ - return cls(self, interaction) - - async def invoke_application_command(self, ctx: ApplicationContext) -> None: - """|coro| - - Invokes the application command given under the invocation - context and handles all the internal event dispatch mechanisms. - - Parameters - ---------- - ctx: :class:`.ApplicationCommand` - The invocation context to invoke. - """ - # self._bot.dispatch("application_command", ctx) # TODO: Remove when moving away from ApplicationContext - try: - if await self._bot.can_run(ctx, call_once=True): - await ctx.command.invoke(ctx) - else: - raise CheckFailure("The global check once functions failed.") - except DiscordException as exc: - await ctx.command.dispatch_error(ctx, exc) - else: - # self._bot.dispatch("application_command_completion", ctx) # TODO: Remove when moving away from ApplicationContext - pass - - @property - @abstractmethod - def _bot(self) -> Bot | AutoShardedBot: ... - - -class BotBase(ApplicationCommandMixin, ABC): +class BotBase(ABC): _supports_prefixed_commands = False def __init__(self, description=None, *args, **options): diff --git a/discord/client.py b/discord/client.py index 14f810a9d3..3f8586032b 100644 --- a/discord/client.py +++ b/discord/client.py @@ -262,8 +262,6 @@ def __init__( loop=self.loop, ) - self._handlers: dict[str, Callable] = {"ready": self._handle_ready} - self._hooks: dict[str, Callable] = {"before_identify": self._call_before_identify_hook} self._enable_debug_events: bool = options.pop("enable_debug_events", False) @@ -277,7 +275,7 @@ def __init__( ) self._connection.shard_count = self.shard_count self._closed: bool = False - self._ready: asyncio.Event = asyncio.Event() + self._ready: asyncio.Event = self._connection.ready self._connection._get_websocket = self._get_websocket self._connection._get_client = lambda: self self._event_handlers: dict[str, list[Coro]] = {} @@ -356,9 +354,6 @@ def listen( def _get_websocket(self, guild_id: int | None = None, *, shard_id: int | None = None) -> DiscordWebSocket: return self.ws - def _handle_ready(self) -> None: - self._ready.set() - @property def latency(self) -> float: """Measures latency between a HEARTBEAT and a HEARTBEAT_ACK in seconds. If no websocket @@ -775,7 +770,7 @@ async def start(self, token: str, *, reconnect: bool = True) -> None: def run(self, *args: Any, **kwargs: Any) -> None: """A blocking call that abstracts away the event loop - initialisation from you. + initialization from you. If you want more control over the event loop then this function should not be used. Use :meth:`start` coroutine diff --git a/discord/events/gateway.py b/discord/events/gateway.py index f3b9b75a15..24239b54ca 100644 --- a/discord/events/gateway.py +++ b/discord/events/gateway.py @@ -118,6 +118,8 @@ async def __load__(cls, data: dict[str, Any], state: ConnectionState) -> Self: await state.emitter.emit("CACHE_APP_EMOJIS", None) + state.ready.set() + return self