diff --git a/.github/workflows/semantic-pr-title.yml b/.github/workflows/semantic-pr-title.yml new file mode 100644 index 0000000000..6738d13854 --- /dev/null +++ b/.github/workflows/semantic-pr-title.yml @@ -0,0 +1,22 @@ +# SPDX-License-Identifier: MIT + +name: Validate PR Title + +on: + pull_request_target: + types: + - opened + - reopened + - edited + +permissions: + pull-requests: read + +jobs: + validate-pr-title: + name: Validate PR Title + runs-on: ubuntu-latest + steps: + - uses: amannn/action-semantic-pull-request@c3cd5d1ea3580753008872425915e343e351ab54 # v5.2.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 67f1281f4d..30c8dbe211 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ venvs/ coverage.xml __pypackages__/ .pdm.toml +.pdm-python pdm.lock !test_bot/locale/*.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5054de13ca..e1151d59b3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,220 +2,122 @@ # Contributing to disnake -- [Bug Reports](#good-bug-reports) -- [Creating Pull Requests](#creating-a-pull-request) +First off, thanks for taking the time to contribute! It makes the library substantially better. :tada: + +The following is a set of guidelines for contributing to the repository. These are not necessarily hard rules, but they streamline the process for everyone involved. -First off, thanks for taking the time to contribute. It makes the library substantially better. :+1: +### Table of Contents -The following is a set of guidelines for contributing to the repository. These are mostly guidelines, not hard rules. +- [Bug Reports](#good-bug-reports) +- [Creating Pull Requests](#creating-a-pull-request) + - [Overview](#overview) + - [Initial setup](#initial-setup) + - [Commit/PR Naming Guidelines](#commitpr-naming-guidelines) ## This is too much to read! I want to ask a question! -Generally speaking questions are better suited in our resources below. +> [!IMPORTANT] +> Please try your best not to create new issues in the issue tracker just to ask questions, unless they provide value to a larger audience. + +Generally speaking, questions are better suited in our resources below. -- The official support server: https://discord.gg/disnake +- The official Discord server: https://discord.gg/disnake - The [FAQ in the documentation](https://docs.disnake.dev/en/latest/faq.html) - The project's [discussions section](https://github.com/DisnakeDev/disnake/discussions) -Please try your best not to create new issues in the issue tracker just to ask questions. Most of them don't belong there unless they provide value to a larger audience. - --- ## Good Bug Reports -Please be aware of the following things when filing bug reports. +To report bugs (or to suggest new features), visit our [issue tracker](https://github.com/DisnakeDev/disnake/issues). +The issue templates will generally walk you through the steps, but please be aware of the following things: -1. Don't open duplicate issues. Please search your issue to see if it has been asked already. Duplicate issues will be closed. -2. When filing a bug about exceptions or tracebacks, please include the *complete* traceback. Without the complete traceback the issue might be **unsolvable** and you will be asked to provide more information. -3. Make sure to provide enough information to make the issue workable. The issue template will generally walk you through the process but they are enumerated here as well: - - A **summary** of your bug report. This is generally a quick sentence or two to describe the issue in human terms. - - Guidance on **how to reproduce the issue**. Ideally, this should have a small code sample that allows us to run and see the issue for ourselves to debug. **Please make sure that the token is not displayed**. If you cannot provide a code snippet, then let us know what the steps were, how often it happens, etc. - - Tell us **what you expected to happen**. That way we can meet that expectation. - - Tell us **what actually happens**. What ends up happening in reality? It's not helpful to say "it fails" or "it doesn't work". Say *how* it failed, do you get an exception? Does it hang? How are the expectations different from reality? - - Tell us **information about your environment**. What version of disnake are you using? How was it installed? What operating system are you running on? These are valuable questions and information that we use. +1. **Don't open duplicate issues**. Before you submit an issue, search the issue tracker to see if an issue for your problem already exists. If you find a similar issue, you can add a comment with additional information or context to help us understand the problem better. +2. **Include the *complete* traceback** when filing a bug report about exceptions or tracebacks. Without the complete traceback, it will be much more difficult for others to understand (and perhaps fix) your issue. +3. **Add a minimal reproducible code snippet** that results in the behavior you're seeing. This helps us quickly confirm a bug or point out a solution to your problem. We cannot reliably investigate bugs without a way to reproduce them. -If the bug report is missing this information then it'll take us longer to fix the issue. We will probably ask for clarification, and barring that if no response was given then the issue will be closed. +If the bug report is missing this information, it'll take us longer to fix the issue. We may ask for clarification, and if no response was given, the issue will be closed. --- ## Creating a Pull Request -Creating a pull request is fairly simple, just make sure it focuses on a single aspect and doesn't manage to have scope creep and it's probably good to go. - -### Formatting +Creating a pull request is fairly straightforward. Make sure it focuses on a single aspect and avoids scope creep, then it's probably good to go. -We would greatly appreciate the code submitted to be of a consistent style with other code in disnake. This project follows PEP-8 guidelines (mostly) with a column limit of 100 characters. +If you're unsure about some aspect of development, feel free to use existing files as a guide or reach out via the Discord server. +### Overview -We use [`PDM`](https://pdm.fming.dev/) for development. If PDM is not already installed on your system, you can follow their [installation steps here](https://pdm.fming.dev/latest/#installation) to get started. +The general workflow can be summarized as follows: -Once PDM is installed and avaliable, use the following command to initialise a virtual environment, install the necessary development dependencies, and install the [`pre-commit`](https://pre-commit.com/#quick-start) hooks. -. -``` -pdm run setup_env -``` +1. Fork + clone the repository. +2. Initialize the development environment: `pdm run setup_env`. +3. Create a new branch. +4. Commit your changes, update documentation if required. +5. Add a changelog entry (e.g. `changelog/1234.feature.rst`). +6. Push the branch to your fork, and [submit a pull request!](https://github.com/DisnakeDev/disnake/compare) -The installed `pre-commit` hooks will automatically run before every commit, which will format/lint the code -to match the project's style. Note that you will have to stage and commit again if anything was updated! +Specific development aspects are further explained below. -Most of the time, running pre-commit will automatically fix any issues that arise, but this is not always the case. -We have a few hooks that *don't* resolve their issues automatically, and must be fixed manually. -One of these is the license header, which must exist in all files unless comments are not supported in those files, or they -are not text files, in which case exceptions can be made. These headers must exist following the format -documented at [https://spdx.dev/ids/](https://spdx.dev/ids/). +### Initial setup -### Scripts +We use [`PDM`](https://pdm.fming.dev/) as our dependency manager. If it isn't already installed on your system, you can follow the installation steps [here](https://pdm.fming.dev/latest/#installation) to get started. -To run all important checks and tests, use `pdm run nox`: -```sh -pdm run nox -R +Once PDM is installed, use the following command to initialize a virtual environment, install the necessary development dependencies, and install the [`pre-commit`](#pre-commit) hooks. ``` - -You can also choose to only run a single task; run `pdm run --list` to view all available scripts and use `pdm run ` to run them. - -Some notes (all of the mentioned scripts are automatically run by `pdm run nox -R`, see above): -- If `pre-commit` hooks aren't installed, run `pdm run lint` manually to check and fix the formatting in all files. - **Note**: If the code is formatted incorrectly, `pre-commit` will apply fixes and exit without committing the changes - just stage and commit again. -- For type-checking, run `pdm run pyright`. You can use `pdm run pyright -w` to automatically re-check on every file change. - **Note**: If you're using VSCode and pylance, it will use the same type-checking settings, which generally means that you don't necessarily have to run `pyright` separately. However, there can be version differences which may lead to different results when later run in CI on GitHub. -- Tests can be run using `pdm run test`. If you changed some functionality, you may have to adjust a few tests - if you added new features, it would be great if you added new tests for them as well. - -A PR cannot be merged as long as there are any failing checks. - -### Changelogs - -We use [towncrier](https://github.com/twisted/towncrier) for managing our changelogs. Each change is required to have at least one file in the [`changelog/`](changelog/README.rst) directory. There is more documentation in that directory on how to create a changelog entry. - -### Git Commit Guidelines - -- Use present tense (e.g. "Add feature" not "Added feature") -- Reference issues or pull requests outside of the first line. - - Please use the shorthand `#123` and not the full URL. - -If you do not meet any of these guidelines, don't fret. Chances are they will be fixed upon rebasing but please do try to meet them to remove some of the workload. - ---- - -## How do I add a new feature? - -Welcome! If you've made it to this point you are likely a new contributor! This section will go through how to add a new feature to disnake. - -Most attributes and data structures are broken up in to a file for each related class. For example, `disnake.Guild` is defined in [disnake/guild.py](disnake/guild.py), and `disnake.GuildPreview` is defined in [disnake/guild_preview.py](disnake/guild_preview.py). For example, writing a new feature to `disnake.Guild` would go in [disnake/guild.py](disnake/guild.py), as part of the `disnake.Guild` class. - -### Adding a new API Feature - -However, adding a new feature that interfaces with the API requires also updating the [disnake/types](disnake/types) directory to match the relevant [API specifications](https://discord.com/developers/docs). We ask that when making or receiving payloads from the API, they are typed and typehints are used on the functions that are processing said data. For example, take a look at `disnake.abc.Messageable.pins` (defined in [disnake/abc.py](disnake/abc.py)). - - -```py - async def pins(self) -> List[Message]: - channel = await self._get_channel() - state = self._state - data = await state.http.pins_from(channel.id) - return [state.create_message(channel=channel, data=m) for m in data] -``` -*docstring removed for brevity* - -Here we have several things occuring. First, we have annotated the return type of this method to return a list of `Message`s. As disnake supports Python 3.8, we use typing imports instead of subscripting built-ins — hence the capital ``List``. - -The next interesting thing is `self._state`. The library uses a state-centric design, which means the state is passed around to most objects. -Every Discord model that makes requests uses that internal state and its `http` attribute to make requests to the Discord API. Each endpoint is processed and defined in [disnake/http.py](disnake/http.py) — and it's where `http.pins_from` is defined too, which looks like this: - -```py - def pins_from(self, channel_id: Snowflake) -> Response[List[message.Message]]: - return self.request(Route("GET", "/channels/{channel_id}/pins", channel_id=channel_id)) +$ pdm run setup_env ``` -This is the basic model that all API request methods follow. Define the `Route`, provide the major parameters (in this example `channel_id`), then return a call to `self.request()`. - -The `Response[]` part in the typehint is referring to `self.request`, as the important thing here is that `pins_from` is **not** a coroutine. Rather, `pins_from` does preprocessing and `self.request` does the actual work. The result from `pins_from` is awaited by `disnake.abc.Messageable.pins`. - -The Route class is how all routes are processed internally. Along with `self.request`, this makes it possible to properly handle all ratelimits. This is why `channel_id` is provided as a kwarg to `Route`, as it is considered a major parameter for ratelimit handling. - -#### Writing Documentation +Other tools used in this project include [black](https://black.readthedocs.io/en/stable/) + [isort](https://pycqa.github.io/isort/) (formatters), [ruff](https://beta.ruff.rs/docs/) (linter), and [pyright](https://microsoft.github.io/pyright/#/) (type-checker). For the most part, these automatically run on every commit with no additional action required - see below for details. -While a new feature can be useful, it requires documentation to be usable by everyone. When updating a class or method, we ask that you use -[Sphinx directives](https://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#directive-versionadded) in the docstring to note when it was added or updated, and what about it was updated. +All of the following checks also automatically run for every PR on GitHub, so don't worry if you're not sure whether you missed anything. A PR cannot be merged as long as there are any failing checks. -For example, here is the docstring for `pins()`: -```py - """|coro| +### Commit/PR Naming Guidelines - Retrieves all messages that are currently pinned in the channel. +This project uses the commonly known [conventional commit format](https://www.conventionalcommits.org/en/v1.0.0/). +While not necessarily required (but appreciated) for individual commit messages, please make sure to title your PR according to this schema: - .. note:: - - Due to a limitation with the Discord API, the :class:`.Message` - objects returned by this method do not contain complete - :attr:`.Message.reactions` data. - - Raises - ------ - HTTPException - Retrieving the pinned messages failed. - - Returns - ------- - List[:class:`.Message`] - The messages that are currently pinned. - """ +``` +(): + │ │ │ │ + │ │ │ └─⫸ Summary in present tense, not capitalized, no period at the end + │ │ │ + │ │ └─⫸ [optional] `!` indicates a breaking change + │ │ + │ └─⫸ [optional] Commit Scope: The affected area, e.g. gateway, user, ... + │ + └─⫸ Commit Type: feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert ``` -If we were to add a new parameter to this method, a few things would need to be added to this docstring. Lets pretend we're adding a parameter, ``oldest_first``. - -We use NumPy style docstrings parsed with Sphinx's Napoleon extension — the primary documentation for these docstrings can be found [here](https://www.sphinx-doc.org/en/master/usage/extensions/napoleon.html). - -```py - """ - ... - - Parameters - ---------- - oldest_first: bool - Whether to order the result by the oldest or newest pins first. - - .. versionadded:: 2.9 +Examples: `feat: support new avatar format` or `fix(gateway): use correct url for resuming connection`. +Details about the specific commit types can be found [here](https://github.com/commitizen/conventional-commit-types/blob/master/index.json). - ... - """ -``` -It is important that the section header comes **after** any description and admonitions that exist, as it will stop the parsing of the description. +### Formatting -The end result of these changes would be as follows: +This project follows PEP-8 guidelines (mostly) with a column limit of 100 characters, and uses the tools mentioned above to enforce a consistent coding style. -```py - """|coro| +The installed [`pre-commit`](https://pre-commit.com/) hooks will automatically run before every commit, which will format/lint the code +to match the project's style. Note that you will have to stage and commit again if anything was updated! +Most of the time, running pre-commit will automatically fix any issues that arise. - Retrieves all messages that are currently pinned in the channel. - .. note:: +### Pyright - Due to a limitation with the Discord API, the :class:`.Message` - objects returned by this method do not contain complete - :attr:`.Message.reactions` data. +For type-checking, run `pdm run pyright` (append `-w` to have it automatically re-check on every file change). +> [!NOTE] +> If you're using VSCode and pylance, it will use the same type-checking settings, which generally means that you don't necessarily have to run `pyright` separately. +> However, since we use a specific version of `pyright` (which may not match pylance's version), there can be version differences which may lead to different results. - Parameters - ---------- - oldest_first: bool - Whether to order the result by the oldest or newest pins first. - .. versionadded:: 2.9 +### Changelogs - Raises - ------ - HTTPException - Retrieving the pinned messages failed. +We use [towncrier](https://github.com/twisted/towncrier) for managing our changelogs. Each change is required to have at least one file in the [`changelog/`](changelog/README.rst) directory, unless it's a trivial change. There is more documentation in that directory on how to create a changelog entry. - Returns - ------- - List[:class:`.Message`] - The messages that are currently pinned. - """ - ``` -*If you're having trouble with adding or modifying documentation, don't be afraid to reach out! -We understand that the documentation can be intimidating, and there are quite a few quirks and limitations to be aware of.* +### Documentation +We use Sphinx to build the project's documentation, which includes [automatically generating](https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html) the API Reference from docstrings using the [NumPy style](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_numpy.html). +To build the documentation locally, use `pdm run docs` and visit http://127.0.0.1:8009/ once built. diff --git a/changelog/1036.feature.rst b/changelog/1036.feature.rst new file mode 100644 index 0000000000..1294ba4044 --- /dev/null +++ b/changelog/1036.feature.rst @@ -0,0 +1 @@ +Make :class:`Interaction` and subtypes accept the bot type as a generic parameter to denote the type returned by the :attr:`~Interaction.bot` and :attr:`~Interaction.client` properties. diff --git a/changelog/1046.feature.rst b/changelog/1046.feature.rst new file mode 100644 index 0000000000..4335e7e17d --- /dev/null +++ b/changelog/1046.feature.rst @@ -0,0 +1 @@ +|commands| Log errors raised by :meth:`.ext.commands.Cog.cog_unload`. diff --git a/changelog/1078.feature.rst b/changelog/1078.feature.rst new file mode 100644 index 0000000000..2ef5b0142a --- /dev/null +++ b/changelog/1078.feature.rst @@ -0,0 +1 @@ +Add ``default_layout`` parameter to :meth:`Guild.create_forum_channel` and :meth:`ForumChannel.clone`. diff --git a/changelog/1082.deprecate.rst b/changelog/1082.deprecate.rst new file mode 100644 index 0000000000..c6f4137b84 --- /dev/null +++ b/changelog/1082.deprecate.rst @@ -0,0 +1 @@ +:meth:`Client.fetch_premium_sticker_packs` was renamed to :meth:`Client.fetch_sticker_packs`; the old name is deprecated. diff --git a/changelog/1087.feature.rst b/changelog/1087.feature.rst new file mode 100644 index 0000000000..bf6cb161d8 --- /dev/null +++ b/changelog/1087.feature.rst @@ -0,0 +1 @@ +Make :attr:`CustomActivity.state` fall back to the provided :attr:`~CustomActivity.name`, simplifying setting a custom status. diff --git a/changelog/1095.feature.rst b/changelog/1095.feature.rst new file mode 100644 index 0000000000..f2f16f46c5 --- /dev/null +++ b/changelog/1095.feature.rst @@ -0,0 +1 @@ +Add ``created_at`` property to :attr:`AutoModRule `, :attr:`ForumTag `, :attr:`Integration `, :attr:`StageInstance `, and :attr:`Team `. diff --git a/changelog/1096.feature.rst b/changelog/1096.feature.rst new file mode 100644 index 0000000000..5cddd33cc8 --- /dev/null +++ b/changelog/1096.feature.rst @@ -0,0 +1 @@ +Support ``integration_type`` field in :attr:`AuditLogEntry.extra` (for :attr:`~AuditLogAction.kick` and :attr:`~AuditLogAction.member_role_update` actions). diff --git a/changelog/1098.misc.rst b/changelog/1098.misc.rst new file mode 100644 index 0000000000..accde4a92b --- /dev/null +++ b/changelog/1098.misc.rst @@ -0,0 +1 @@ +Overhaul and simplify `contribution guide `__. diff --git a/changelog/1105.doc.rst b/changelog/1105.doc.rst new file mode 100644 index 0000000000..eceb16fc71 --- /dev/null +++ b/changelog/1105.doc.rst @@ -0,0 +1 @@ +Miscellaneous grammar/typo fixes for :doc:`api/audit_logs`. diff --git a/changelog/687.feature.0.rst b/changelog/687.feature.0.rst new file mode 100644 index 0000000000..b9f8ae994d --- /dev/null +++ b/changelog/687.feature.0.rst @@ -0,0 +1 @@ +Support activity assets with ``mp:`` prefix in :attr:`Activity.large_image_url` and :attr:`Activity.small_image_url`, now returning the correct url. diff --git a/changelog/687.feature.1.rst b/changelog/687.feature.1.rst new file mode 100644 index 0000000000..98cdf1b0d4 --- /dev/null +++ b/changelog/687.feature.1.rst @@ -0,0 +1 @@ +Move asset properties from :class:`Activity` to all activity types: :attr:`~Game.large_image_url`, :attr:`~Game.small_image_url`, :attr:`~Game.large_image_text`, :attr:`~Game.small_image_text`. diff --git a/changelog/808.misc.rst b/changelog/808.misc.rst new file mode 100644 index 0000000000..cdfff46770 --- /dev/null +++ b/changelog/808.misc.rst @@ -0,0 +1 @@ +Update the ``python -m disnake newcog`` template to include all missing special methods. diff --git a/changelog/975.feature.rst b/changelog/975.feature.rst new file mode 100644 index 0000000000..b3cd727853 --- /dev/null +++ b/changelog/975.feature.rst @@ -0,0 +1 @@ +Move the event listener system implementation from :class:`.ext.commands.Bot` to :class:`.Client`, making Clients able to have more than one listener per event type. diff --git a/disnake/__main__.py b/disnake/__main__.py index 3f667db873..cf7a586985 100644 --- a/disnake/__main__.py +++ b/disnake/__main__.py @@ -120,35 +120,103 @@ def setup(bot): bot.add_cog({name}(bot)) ''' +# everything that is a _cog_special_method goes here. _cog_extras = """ + async def cog_load(self): + # (async) loading logic goes here + pass + def cog_unload(self): # clean up logic goes here pass + ### Prefix Commands ### + async def cog_check(self, ctx): - # checks that apply to every command in here + # checks that apply to every prefix command in here return True async def bot_check(self, ctx): - # checks that apply to every command to the bot + # checks that apply to every prefix command to the bot return True async def bot_check_once(self, ctx): - # check that apply to every command but is guaranteed to be called only once + # check that apply to every prefix command but is guaranteed to be called only once return True async def cog_command_error(self, ctx, error): - # error handling to every command in here + # error handling to every prefix command in here pass async def cog_before_invoke(self, ctx): - # called before a command is called here + # called before a prefix command is called here pass async def cog_after_invoke(self, ctx): - # called after a command is called here + # called after a prefix command is called here pass + ### Slash Commands ### + + # These are similar to the ones in the previous section, but for slash commands + + async def cog_slash_command_check(self, inter): + return True + + async def bot_slash_command_check(self, inter): + return True + + async def bot_slash_command_check_once(self, inter): + return True + + async def cog_slash_command_error(self, inter, error): + ... + + async def cog_before_slash_command_invoke(self, inter): + ... + + async def cog_after_slash_command_invoke(self, inter): + ... + + ### Message (Context Menu) Commands ### + + async def cog_message_command_check(self, inter): + return True + + async def bot_message_command_check(self, inter): + return True + + async def bot_message_command_check_once(self, inter): + return True + + async def cog_message_command_error(self, inter, error): + ... + + async def cog_before_message_command_invoke(self, inter): + ... + + async def cog_after_message_command_invoke(self, inter): + ... + + ### User (Context Menu) Commands ### + + async def cog_user_command_check(self, inter): + return True + + async def bot_user_command_check(self, inter): + return True + + async def bot_user_command_check_once(self, inter): + return True + + async def cog_user_command_error(self, inter, error): + ... + + async def cog_before_user_command_invoke(self, inter): + ... + + async def cog_after_user_command_invoke(self, inter): + ... """ diff --git a/disnake/activity.py b/disnake/activity.py index e5e4321af2..a213bf5a75 100644 --- a/disnake/activity.py +++ b/disnake/activity.py @@ -135,6 +135,63 @@ def end(self) -> Optional[datetime.datetime]: def to_dict(self) -> ActivityPayload: raise NotImplementedError + def _create_image_url(self, asset: str) -> Optional[str]: + # `asset` can be a simple ID (see `Activity._create_image_url`), + # or a string of the format `:` + prefix, _, asset_id = asset.partition(":") + + if asset_id and (url_fmt := _ACTIVITY_URLS.get(prefix)): + return url_fmt.format(asset_id) + return None + + @property + def large_image_url(self) -> Optional[str]: + """Optional[:class:`str`]: Returns a URL pointing to the large image asset of this activity, if applicable. + + .. versionchanged:: 2.10 + Moved from :class:`Activity` to base type, making this available to all activity types. + Additionally, supports dynamic asset urls using the ``mp:`` prefix now. + """ + try: + large_image = self.assets["large_image"] + except KeyError: + return None + else: + return self._create_image_url(large_image) + + @property + def small_image_url(self) -> Optional[str]: + """Optional[:class:`str`]: Returns a URL pointing to the small image asset of this activity, if applicable. + + .. versionchanged:: 2.10 + Moved from :class:`Activity` to base type, making this available to all activity types. + Additionally, supports dynamic asset urls using the ``mp:`` prefix now. + """ + try: + small_image = self.assets["small_image"] + except KeyError: + return None + else: + return self._create_image_url(small_image) + + @property + def large_image_text(self) -> Optional[str]: + """Optional[:class:`str`]: Returns the large image asset hover text of this activity, if applicable. + + .. versionchanged:: 2.10 + Moved from :class:`Activity` to base type, making this available to all activity types. + """ + return self.assets.get("large_text", None) + + @property + def small_image_text(self) -> Optional[str]: + """Optional[:class:`str`]: Returns the small image asset hover text of this activity, if applicable. + + .. versionchanged:: 2.10 + Moved from :class:`Activity` to base type, making this available to all activity types. + """ + return self.assets.get("small_text", None) + # tag type for user-settable activities class BaseActivity(_BaseActivity): @@ -160,6 +217,15 @@ class BaseActivity(_BaseActivity): __slots__ = () +# There are additional urls for twitch/youtube/spotify, however +# it appears that Discord does not want to document those: +# https://github.com/discord/discord-api-docs/pull/4617 +# They are partially supported by different properties, e.g. `Spotify.album_cover_url`. +_ACTIVITY_URLS = { + "mp": "https://media.discordapp.net/{}", +} + + class Activity(BaseActivity): """Represents an activity in Discord. @@ -320,41 +386,17 @@ def to_dict(self) -> Dict[str, Any]: ret["timestamps"] = self._timestamps return ret - @property - def large_image_url(self) -> Optional[str]: - """Optional[:class:`str`]: Returns a URL pointing to the large image asset of this activity, if applicable.""" - if self.application_id is None: - return None + def _create_image_url(self, asset: str) -> Optional[str]: + # if parent method already returns valid url, use that + if url := super()._create_image_url(asset): + return url - try: - large_image = self.assets["large_image"] - except KeyError: - return None - else: - return f"{Asset.BASE}/app-assets/{self.application_id}/{large_image}.png" - - @property - def small_image_url(self) -> Optional[str]: - """Optional[:class:`str`]: Returns a URL pointing to the small image asset of this activity, if applicable.""" - if self.application_id is None: - return None + # if it's not a `:` asset and we have an application ID, create url + if ":" not in asset and self.application_id: + return f"{Asset.BASE}/app-assets/{self.application_id}/{asset}.png" - try: - small_image = self.assets["small_image"] - except KeyError: - return None - else: - return f"{Asset.BASE}/app-assets/{self.application_id}/{small_image}.png" - - @property - def large_image_text(self) -> Optional[str]: - """Optional[:class:`str`]: Returns the large image asset hover text of this activity, if applicable.""" - return self.assets.get("large_text", None) - - @property - def small_image_text(self) -> Optional[str]: - """Optional[:class:`str`]: Returns the small image asset hover text of this activity, if applicable.""" - return self.assets.get("small_text", None) + # else, it's an unknown asset url + return None class Game(BaseActivity): @@ -754,6 +796,8 @@ class CustomActivity(BaseActivity): The custom activity's name. emoji: Optional[:class:`PartialEmoji`] The emoji to pass to the activity, if any. + + This currently cannot be set by bots. """ __slots__ = ("name", "emoji", "state") @@ -768,7 +812,10 @@ def __init__( ) -> None: super().__init__(**kwargs) self.name: Optional[str] = name - self.state: Optional[str] = state + # Fall back to `name`, since `state` is the relevant field for custom status (`name` is not shown) + self.state: Optional[str] = state or name + + # The official client uses "Custom Status" as the name, the actual name is in `state` if self.name == "Custom Status": self.name = self.state @@ -862,7 +909,9 @@ def create_activity( activity: ActivityTypes game_type = try_enum(ActivityType, data.get("type", -1)) - if game_type is ActivityType.playing and not ("application_id" in data or "session_id" in data): + if game_type is ActivityType.playing and not ( + "application_id" in data or "session_id" in data or "state" in data + ): activity = Game(**data) # type: ignore # pyright bug(?) elif game_type is ActivityType.custom and "name" in data: activity = CustomActivity(**data) # type: ignore diff --git a/disnake/audit_logs.py b/disnake/audit_logs.py index d1e2760bb9..9d45912cd9 100644 --- a/disnake/audit_logs.py +++ b/disnake/audit_logs.py @@ -507,10 +507,15 @@ class _AuditLogProxyAutoModAction: rule_trigger_type: enums.AutoModTriggerType +class _AuditLogProxyKickOrMemberRoleAction: + integration_type: Optional[str] + + class AuditLogEntry(Hashable): """Represents an Audit Log entry. - You retrieve these via :meth:`Guild.audit_logs`. + You can retrieve these via :meth:`Guild.audit_logs`, + or via the :func:`on_audit_log_entry_create` event. .. container:: operations @@ -529,9 +534,6 @@ class AuditLogEntry(Hashable): .. versionchanged:: 1.7 Audit log entries are now comparable and hashable. - .. versionchanged:: 2.8 - :attr:`user` can return :class:`Object` if the user is not found. - Attributes ---------- action: :class:`AuditLogAction` @@ -539,6 +541,9 @@ class AuditLogEntry(Hashable): user: Optional[Union[:class:`Member`, :class:`User`, :class:`Object`]] The user who initiated this action. Usually :class:`Member`\\, unless gone then it's a :class:`User`. + + .. versionchanged:: 2.8 + May now be an :class:`Object` if the user could not be found. id: :class:`int` The entry ID. target: Any @@ -589,7 +594,6 @@ def _from_data(self, data: AuditLogEntryPayload) -> None: if isinstance(self.action, enums.AuditLogAction) and extra: if self.action is enums.AuditLogAction.member_prune: - # member prune has two keys with useful information elems = { "delete_member_days": utils._get_as_snowflake(extra, "delete_member_days"), "members_removed": utils._get_as_snowflake(extra, "members_removed"), @@ -607,13 +611,11 @@ def _from_data(self, data: AuditLogEntryPayload) -> None: } self.extra = type("_AuditLogProxy", (), elems)() elif self.action is enums.AuditLogAction.member_disconnect: - # The member disconnect action has a dict with some information elems = { "count": int(extra["count"]), } self.extra = type("_AuditLogProxy", (), elems)() elif self.action.name.endswith("pin"): - # the pin actions have a dict with some information elems = { "channel": self._get_channel_or_thread( utils._get_as_snowflake(extra, "channel_id") @@ -622,7 +624,6 @@ def _from_data(self, data: AuditLogEntryPayload) -> None: } self.extra = type("_AuditLogProxy", (), elems)() elif self.action.name.startswith("overwrite_"): - # the overwrite_ actions have a dict with some information instance_id = int(extra["id"]) the_type = extra.get("type") if the_type == "1": @@ -662,6 +663,15 @@ def _from_data(self, data: AuditLogEntryPayload) -> None: ), } self.extra = type("_AuditLogProxy", (), elems)() + elif self.action in ( + enums.AuditLogAction.kick, + enums.AuditLogAction.member_role_update, + ): + elems = { + # unlike other extras, this key isn't always provided + "integration_type": extra.get("integration_type"), + } + self.extra = type("_AuditLogProxy", (), elems)() self.extra: Any # actually this but there's no reason to annoy users with this: @@ -672,6 +682,7 @@ def _from_data(self, data: AuditLogEntryPayload) -> None: # _AuditLogProxyPinAction, # _AuditLogProxyStageInstanceAction, # _AuditLogProxyAutoModAction, + # _AuditLogProxyKickOrMemberRoleAction, # Member, User, None, # Role, # ] diff --git a/disnake/automod.py b/disnake/automod.py index 170987364e..f07fe6f95c 100644 --- a/disnake/automod.py +++ b/disnake/automod.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import timedelta +import datetime from typing import ( TYPE_CHECKING, Dict, @@ -25,7 +25,7 @@ try_enum_to_int, ) from .flags import AutoModKeywordPresets -from .utils import MISSING, _get_as_snowflake +from .utils import MISSING, _get_as_snowflake, snowflake_time if TYPE_CHECKING: from typing_extensions import Self @@ -207,10 +207,10 @@ class AutoModTimeoutAction(AutoModAction): _metadata: AutoModTimeoutActionMetadata - def __init__(self, duration: Union[int, timedelta]) -> None: + def __init__(self, duration: Union[int, datetime.timedelta]) -> None: super().__init__(type=AutoModActionType.timeout) - if isinstance(duration, timedelta): + if isinstance(duration, datetime.timedelta): duration = int(duration.total_seconds()) self._metadata["duration_seconds"] = duration @@ -492,6 +492,14 @@ def __init__(self, *, data: AutoModRulePayload, guild: Guild) -> None: else frozenset() ) + @property + def created_at(self) -> datetime.datetime: + """:class:`datetime.datetime`: Returns the rule's creation time in UTC. + + .. versionadded:: 2.10 + """ + return snowflake_time(self.id) + @property def actions(self) -> List[AutoModAction]: """List[Union[:class:`AutoModBlockMessageAction`, :class:`AutoModSendAlertAction`, :class:`AutoModTimeoutAction`, :class:`AutoModAction`]]: diff --git a/disnake/channel.py b/disnake/channel.py index 7b21ebf2a5..d98a48339b 100644 --- a/disnake/channel.py +++ b/disnake/channel.py @@ -3628,6 +3628,7 @@ async def clone( available_tags: Sequence[ForumTag] = MISSING, default_reaction: Optional[Union[str, Emoji, PartialEmoji]] = MISSING, default_sort_order: Optional[ThreadSortOrder] = MISSING, + default_layout: ThreadLayout = MISSING, overwrites: Mapping[Union[Role, Member], PermissionOverwrite] = MISSING, reason: Optional[str] = None, ) -> ForumChannel: @@ -3643,7 +3644,10 @@ async def clone( Added new ``topic``, ``position``, ``nsfw``, ``category``, ``slowmode_delay``, ``default_thread_slowmode_delay``, ``default_auto_archive_duration``, ``available_tags``, ``default_reaction``, ``default_sort_order`` - and ``overwrites`` keyword-only paremters. + and ``overwrites`` keyword-only parameters. + + .. versionchanged:: 2.10 + Added ``default_layout`` parameter. .. note:: The current :attr:`ForumChannel.flags` value won't be cloned. @@ -3673,6 +3677,8 @@ async def clone( The default reaction of the new channel. If not provided, defaults to this channel's default reaction. default_sort_order: Optional[:class:`ThreadSortOrder`] The default sort order of the new channel. If not provided, defaults to this channel's default sort order. + default_layout: :class:`ThreadLayout` + The default layout of threads in the new channel. If not provided, defaults to this channel's default layout. overwrites: :class:`Mapping` A :class:`Mapping` of target (either a role or a member) to :class:`PermissionOverwrite` to apply to the channel. If not provided, defaults to this channel's overwrites. @@ -3704,9 +3710,6 @@ async def clone( else: default_reaction_emoji_payload = None - if default_sort_order is MISSING: - default_sort_order = self.default_sort_order - return await self._clone_impl( { "topic": topic if topic is not MISSING else self.topic, @@ -3732,7 +3735,14 @@ async def clone( ), "default_reaction_emoji": default_reaction_emoji_payload, "default_sort_order": ( - try_enum_to_int(default_sort_order) if default_sort_order is not None else None + try_enum_to_int(default_sort_order) + if default_sort_order is not MISSING + else try_enum_to_int(self.default_sort_order) + ), + "default_forum_layout": ( + try_enum_to_int(default_layout) + if default_layout is not MISSING + else try_enum_to_int(self.default_layout) ), }, name=name, diff --git a/disnake/client.py b/disnake/client.py index f2f1078646..ba8f49ebd7 100644 --- a/disnake/client.py +++ b/disnake/client.py @@ -7,6 +7,7 @@ import signal import sys import traceback +import types import warnings from datetime import datetime, timedelta from errno import ECONNRESET @@ -19,6 +20,7 @@ Generator, List, Literal, + Mapping, NamedTuple, Optional, Sequence, @@ -44,7 +46,7 @@ from .backoff import ExponentialBackoff from .channel import PartialMessageable, _threaded_channel_factory from .emoji import Emoji -from .enums import ApplicationCommandType, ChannelType, Status +from .enums import ApplicationCommandType, ChannelType, Event, Status from .errors import ( ConnectionClosed, GatewayNotFound, @@ -70,7 +72,7 @@ from .threads import Thread, ThreadMember from .ui.view import View from .user import ClientUser, User -from .utils import MISSING, _generated, _overload_with_events +from .utils import MISSING, _generated, _overload_with_events, deprecated from .voice_client import VoiceClient from .voice_region import VoiceRegion from .webhook import Webhook @@ -85,7 +87,6 @@ from .audit_logs import AuditLogEntry from .automod import AutoModActionExecution, AutoModRule from .channel import DMChannel, ForumChannel, GroupChannel - from .enums import Event from .guild_scheduled_event import GuildScheduledEvent from .integrations import Integration from .interactions import ( @@ -132,6 +133,11 @@ "GatewayParams", ) +T = TypeVar("T") + +Coro = Coroutine[Any, Any, T] +CoroFunc = Callable[..., Coro[Any]] + CoroT = TypeVar("CoroT", bound=Callable[..., Coroutine[Any, Any, Any]]) _log = logging.getLogger(__name__) @@ -489,6 +495,8 @@ def __init__( if self.gateway_params.encoding != "json": raise ValueError("Gateway encodings other than `json` are currently not supported.") + self.extra_events: Dict[str, List[CoroFunc]] = {} + # internals def _get_websocket( @@ -689,8 +697,10 @@ async def get_or_fetch_user(self, user_id: int, *, strict: Literal[True]) -> Use async def get_or_fetch_user(self, user_id: int, *, strict: bool = False) -> Optional[User]: """|coro| - Tries to get the user from the cache. If fails, it tries to - fetch the user from the API. + Tries to get the user from the cache. If it fails, + fetches the user from the API. + + This only propagates exceptions when the ``strict`` parameter is enabled. Parameters ---------- @@ -795,6 +805,159 @@ def dispatch(self, event: str, *args: Any, **kwargs: Any) -> None: else: self._schedule_event(coro, method, *args, **kwargs) + for event_ in self.extra_events.get(method, []): + self._schedule_event(event_, method, *args, **kwargs) + + def add_listener(self, func: CoroFunc, name: Union[str, Event] = MISSING) -> None: + """The non decorator alternative to :meth:`.listen`. + + .. versionchanged:: 2.10 + The definition of this method was moved from :class:`.ext.commands.Bot` + to the :class:`.Client` class. + + Parameters + ---------- + func: :ref:`coroutine ` + The function to call. + name: Union[:class:`str`, :class:`.Event`] + The name of the event to listen for. Defaults to ``func.__name__``. + + Example + -------- + + .. code-block:: python + + async def on_ready(): pass + async def my_message(message): pass + async def another_message(message): pass + + client.add_listener(on_ready) + client.add_listener(my_message, 'on_message') + client.add_listener(another_message, Event.message) + + Raises + ------ + TypeError + The function is not a coroutine or a string or an :class:`.Event` was not passed + as the name. + """ + if name is not MISSING and not isinstance(name, (str, Event)): + raise TypeError( + f"add_listener expected str or Enum but received {name.__class__.__name__!r} instead." + ) + + name_ = ( + func.__name__ + if name is MISSING + else (name if isinstance(name, str) else f"on_{name.value}") + ) + + if not asyncio.iscoroutinefunction(func): + raise TypeError("Listeners must be coroutines") + + if name_ in self.extra_events: + self.extra_events[name_].append(func) + else: + self.extra_events[name_] = [func] + + def remove_listener(self, func: CoroFunc, name: Union[str, Event] = MISSING) -> None: + """Removes a listener from the pool of listeners. + + .. versionchanged:: 2.10 + The definition of this method was moved from :class:`.ext.commands.Bot` + to the :class:`.Client` class. + + Parameters + ---------- + func + The function that was used as a listener to remove. + name: Union[:class:`str`, :class:`.Event`] + The name of the event we want to remove. Defaults to + ``func.__name__``. + + Raises + ------ + TypeError + The name passed was not a string or an :class:`.Event`. + """ + if name is not MISSING and not isinstance(name, (str, Event)): + raise TypeError( + f"remove_listener expected str or Enum but received {name.__class__.__name__!r} instead." + ) + name = ( + func.__name__ + if name is MISSING + else (name if isinstance(name, str) else f"on_{name.value}") + ) + + if name in self.extra_events: + try: + self.extra_events[name].remove(func) + except ValueError: + pass + + def listen(self, name: Union[str, Event] = MISSING) -> Callable[[CoroT], CoroT]: + """A decorator that registers another function as an external + event listener. Basically this allows you to listen to multiple + events from different places e.g. such as :func:`.on_ready` + + The functions being listened to must be a :ref:`coroutine `. + + .. versionchanged:: 2.10 + The definition of this method was moved from :class:`.ext.commands.Bot` + to the :class:`.Client` class. + + Example + ------- + .. code-block:: python3 + + @client.listen() + async def on_message(message): + print('one') + + # in some other file... + + @client.listen('on_message') + async def my_message(message): + print('two') + + # in yet another file + @client.listen(Event.message) + async def another_message(message): + print('three') + + Would print one, two and three in an unspecified order. + + Raises + ------ + TypeError + The function being listened to is not a coroutine or a string or an :class:`.Event` was not passed + as the name. + """ + if name is not MISSING and not isinstance(name, (str, Event)): + raise TypeError( + f"listen expected str or Enum but received {name.__class__.__name__!r} instead." + ) + + def decorator(func: CoroT) -> CoroT: + self.add_listener(func, name) + return func + + return decorator + + def get_listeners(self) -> Mapping[str, List[CoroFunc]]: + """Mapping[:class:`str`, List[Callable]]: A read-only mapping of event names to listeners. + + .. note:: + To add or remove a listener you should use :meth:`.add_listener` and + :meth:`.remove_listener`. + + .. versionchanged:: 2.10 + The definition of this method was moved from :class:`.ext.commands.Bot` + to the :class:`.Client` class. + """ + return types.MappingProxyType(self.extra_events) + async def on_error(self, event_method: str, *args: Any, **kwargs: Any) -> None: """|coro| @@ -1350,7 +1513,7 @@ def get_sticker(self, id: int, /) -> Optional[GuildSticker]: .. note:: To retrieve standard stickers, use :meth:`.fetch_sticker`. - or :meth:`.fetch_premium_sticker_packs`. + or :meth:`.fetch_sticker_packs`. Returns ------- @@ -3565,13 +3728,16 @@ async def fetch_sticker(self, sticker_id: int, /) -> Union[StandardSticker, Guil 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]: + async def fetch_sticker_packs(self) -> List[StickerPack]: """|coro| - Retrieves all available premium sticker packs. + Retrieves all available sticker packs. .. versionadded:: 2.0 + .. versionchanged:: 2.10 + Renamed from ``fetch_premium_sticker_packs``. + Raises ------ HTTPException @@ -3580,11 +3746,19 @@ async def fetch_premium_sticker_packs(self) -> List[StickerPack]: Returns ------- List[:class:`.StickerPack`] - All available premium sticker packs. + All available sticker packs. """ - data = await self.http.list_premium_sticker_packs() + data = await self.http.list_sticker_packs() return [StickerPack(state=self._connection, data=pack) for pack in data["sticker_packs"]] + @deprecated("fetch_sticker_packs") + async def fetch_premium_sticker_packs(self) -> List[StickerPack]: + """An alias of :meth:`fetch_sticker_packs`. + + .. deprecated:: 2.10 + """ + return await self.fetch_sticker_packs() + async def create_dm(self, user: Snowflake) -> DMChannel: """|coro| diff --git a/disnake/ext/commands/cog.py b/disnake/ext/commands/cog.py index a0ec377d53..a0305ccea7 100644 --- a/disnake/ext/commands/cog.py +++ b/disnake/ext/commands/cog.py @@ -4,6 +4,7 @@ import asyncio import inspect +import logging from typing import ( TYPE_CHECKING, Any, @@ -48,6 +49,7 @@ FuncT = TypeVar("FuncT", bound=Callable[..., Any]) MISSING: Any = disnake.utils.MISSING +_log = logging.getLogger(__name__) def _cog_special_method(func: FuncT) -> FuncT: @@ -895,6 +897,7 @@ def _eject(self, bot: AnyBot) -> None: pass try: self.cog_unload() - except Exception: - # TODO: Consider calling the bot's on_error handler here - pass + except Exception as e: + _log.error( + "An error occurred while unloading the %s cog.", self.qualified_name, exc_info=e + ) diff --git a/disnake/ext/commands/common_bot_base.py b/disnake/ext/commands/common_bot_base.py index 7f8b22d24b..dc4e81c97c 100644 --- a/disnake/ext/commands/common_bot_base.py +++ b/disnake/ext/commands/common_bot_base.py @@ -10,23 +10,10 @@ import sys import time import types -from typing import ( - TYPE_CHECKING, - Any, - Callable, - Dict, - Generic, - List, - Mapping, - Optional, - Set, - TypeVar, - Union, -) +from typing import TYPE_CHECKING, Any, Dict, Generic, List, Mapping, Optional, Set, TypeVar, Union import disnake import disnake.utils -from disnake.enums import Event from . import errors from .cog import Cog @@ -54,6 +41,9 @@ def _is_submodule(parent: str, child: str) -> bool: class CommonBotBase(Generic[CogT]): + if TYPE_CHECKING: + extra_events: Dict[str, List[CoroFunc]] + def __init__( self, *args: Any, @@ -64,7 +54,6 @@ def __init__( ) -> None: self.__cogs: Dict[str, Cog] = {} self.__extensions: Dict[str, types.ModuleType] = {} - self.extra_events: Dict[str, List[CoroFunc]] = {} self._is_closed: bool = False self.owner_id: Optional[int] = owner_id @@ -82,12 +71,10 @@ def __init__( super().__init__(*args, **kwargs) + # FIXME: make event name pos-only or remove entirely in v3.0 def dispatch(self, event_name: str, *args: Any, **kwargs: Any) -> None: # super() will resolve to Client super().dispatch(event_name, *args, **kwargs) # type: ignore - ev = "on_" + event_name - for event in self.extra_events.get(ev, []): - self._schedule_event(event, ev, *args, **kwargs) # type: ignore async def _fill_owners(self) -> None: if self.owner_id or self.owner_ids: @@ -168,135 +155,6 @@ async def is_owner(self, user: Union[disnake.User, disnake.Member]) -> bool: self.owner_id = owner_id = app.owner.id return user.id == owner_id - # listener registration - - def add_listener(self, func: CoroFunc, name: Union[str, Event] = MISSING) -> None: - """The non decorator alternative to :meth:`.listen`. - - Parameters - ---------- - func: :ref:`coroutine ` - The function to call. - name: Union[:class:`str`, :class:`.Event`] - The name of the event to listen for. Defaults to ``func.__name__``. - - Example - -------- - - .. code-block:: python - - async def on_ready(): pass - async def my_message(message): pass - async def another_message(message): pass - - bot.add_listener(on_ready) - bot.add_listener(my_message, 'on_message') - bot.add_listener(another_message, Event.message) - - Raises - ------ - TypeError - The function is not a coroutine or a string or an :class:`.Event` was not passed - as the name. - """ - if name is not MISSING and not isinstance(name, (str, Event)): - raise TypeError( - f"Bot.add_listener expected str or Enum but received {name.__class__.__name__!r} instead." - ) - - name_ = ( - func.__name__ - if name is MISSING - else (name if isinstance(name, str) else f"on_{name.value}") - ) - - if not asyncio.iscoroutinefunction(func): - raise TypeError("Listeners must be coroutines") - - if name_ in self.extra_events: - self.extra_events[name_].append(func) - else: - self.extra_events[name_] = [func] - - def remove_listener(self, func: CoroFunc, name: Union[str, Event] = MISSING) -> None: - """Removes a listener from the pool of listeners. - - Parameters - ---------- - func - The function that was used as a listener to remove. - name: Union[:class:`str`, :class:`.Event`] - The name of the event we want to remove. Defaults to - ``func.__name__``. - - Raises - ------ - TypeError - The name passed was not a string or an :class:`.Event`. - """ - if name is not MISSING and not isinstance(name, (str, Event)): - raise TypeError( - f"Bot.remove_listener expected str or Enum but received {name.__class__.__name__!r} instead." - ) - name = ( - func.__name__ - if name is MISSING - else (name if isinstance(name, str) else f"on_{name.value}") - ) - - if name in self.extra_events: - try: - self.extra_events[name].remove(func) - except ValueError: - pass - - def listen(self, name: Union[str, Event] = MISSING) -> Callable[[CFT], CFT]: - """A decorator that registers another function as an external - event listener. Basically this allows you to listen to multiple - events from different places e.g. such as :func:`.on_ready` - - The functions being listened to must be a :ref:`coroutine `. - - Example - ------- - .. code-block:: python3 - - @bot.listen() - async def on_message(message): - print('one') - - # in some other file... - - @bot.listen('on_message') - async def my_message(message): - print('two') - - # in yet another file - @bot.listen(Event.message) - async def another_message(message): - print('three') - - Would print one, two and three in an unspecified order. - - Raises - ------ - TypeError - The function being listened to is not a coroutine or a string or an :class:`.Event` was not passed - as the name. - """ - if name is not MISSING and not isinstance(name, (str, Event)): - raise TypeError( - f"Bot.listen expected str or Enum but received {name.__class__.__name__!r} instead." - ) - - def decorator(func: CFT) -> CFT: - self.add_listener(func, name) - return func - - return decorator - - # cogs - def add_cog(self, cog: Cog, *, override: bool = False) -> None: """Adds a "cog" to the bot. @@ -395,17 +253,6 @@ def cogs(self) -> Mapping[str, Cog]: """Mapping[:class:`str`, :class:`Cog`]: A read-only mapping of cog name to cog.""" return types.MappingProxyType(self.__cogs) - def get_listeners(self) -> Mapping[str, List[CoroFunc]]: - """Mapping[:class:`str`, List[Callable]]: A read-only mapping of event names to listeners. - - .. note:: - To add or remove a listener you should use :meth:`.add_listener` and - :meth:`.remove_listener`. - - .. versionadded:: 2.9 - """ - return types.MappingProxyType(self.extra_events) - # extensions def _remove_module_references(self, name: str) -> None: diff --git a/disnake/ext/commands/params.py b/disnake/ext/commands/params.py index 69d9ccb085..2ab93359d2 100644 --- a/disnake/ext/commands/params.py +++ b/disnake/ext/commands/params.py @@ -91,6 +91,7 @@ T = TypeVar("T", bound=Any) TypeT = TypeVar("TypeT", bound=Type[Any]) CallableT = TypeVar("CallableT", bound=Callable[..., Any]) +BotT = TypeVar("BotT", bound="disnake.Client", covariant=True) __all__ = ( "Range", @@ -520,11 +521,11 @@ class ParamInfo: def __init__( self, - default: Union[Any, Callable[[ApplicationCommandInteraction], Any]] = ..., + default: Union[Any, Callable[[ApplicationCommandInteraction[BotT]], Any]] = ..., *, name: LocalizedOptional = None, description: LocalizedOptional = None, - converter: Optional[Callable[[ApplicationCommandInteraction, Any], Any]] = None, + converter: Optional[Callable[[ApplicationCommandInteraction[BotT], Any], Any]] = None, convert_default: bool = False, autocomplete: Optional[AnyAutocompleter] = None, choices: Optional[Choices] = None, @@ -911,6 +912,7 @@ def isolate_self( parametersl.pop(0) if parametersl: annot = parametersl[0].annotation + annot = get_origin(annot) or annot if issubclass_(annot, ApplicationCommandInteraction) or annot is inspect.Parameter.empty: inter_param = parameters.pop(parametersl[0].name) @@ -982,7 +984,9 @@ def collect_params( injections[parameter.name] = default elif parameter.annotation in Injection._registered: injections[parameter.name] = Injection._registered[parameter.annotation] - elif issubclass_(parameter.annotation, ApplicationCommandInteraction): + elif issubclass_( + get_origin(parameter.annotation) or parameter.annotation, ApplicationCommandInteraction + ): if inter_param is None: inter_param = parameter else: @@ -1116,21 +1120,24 @@ def expand_params(command: AnySlashCommand) -> List[Option]: if param.autocomplete: command.autocompleters[param.name] = param.autocomplete - if issubclass_(sig.parameters[inter_param].annotation, disnake.GuildCommandInteraction): + if issubclass_( + get_origin(annot := sig.parameters[inter_param].annotation) or annot, + disnake.GuildCommandInteraction, + ): command._guild_only = True return [param.to_option() for param in params] def Param( - default: Union[Any, Callable[[ApplicationCommandInteraction], Any]] = ..., + default: Union[Any, Callable[[ApplicationCommandInteraction[BotT]], Any]] = ..., *, name: LocalizedOptional = None, description: LocalizedOptional = None, choices: Optional[Choices] = None, - converter: Optional[Callable[[ApplicationCommandInteraction, Any], Any]] = None, + converter: Optional[Callable[[ApplicationCommandInteraction[BotT], Any], Any]] = None, convert_defaults: bool = False, - autocomplete: Optional[Callable[[ApplicationCommandInteraction, str], Any]] = None, + autocomplete: Optional[Callable[[ApplicationCommandInteraction[BotT], str], Any]] = None, channel_types: Optional[List[ChannelType]] = None, lt: Optional[float] = None, le: Optional[float] = None, diff --git a/disnake/guild.py b/disnake/guild.py index d68ff87e0e..888c7518d4 100644 --- a/disnake/guild.py +++ b/disnake/guild.py @@ -50,6 +50,7 @@ Locale, NotificationLevel, NSFWLevel, + ThreadLayout, ThreadSortOrder, VerificationLevel, VideoQualityMode, @@ -1627,6 +1628,7 @@ async def create_forum_channel( available_tags: Optional[Sequence[ForumTag]] = None, default_reaction: Optional[Union[str, Emoji, PartialEmoji]] = None, default_sort_order: Optional[ThreadSortOrder] = None, + default_layout: Optional[ThreadLayout] = None, reason: Optional[str] = None, ) -> ForumChannel: """|coro| @@ -1688,6 +1690,11 @@ async def create_forum_channel( .. versionadded:: 2.6 + default_layout: :class:`ThreadLayout` + The default layout of threads in this channel. + + .. versionadded:: 2.10 + reason: Optional[:class:`str`] The reason for creating this channel. Shows up on the audit log. @@ -1739,6 +1746,9 @@ async def create_forum_channel( if default_sort_order is not None: options["default_sort_order"] = try_enum_to_int(default_sort_order) + if default_layout is not None: + options["default_forum_layout"] = try_enum_to_int(default_layout) + data = await self._create_channel( name, overwrites=overwrites, @@ -2689,6 +2699,8 @@ async def fetch_member(self, member_id: int, /) -> Member: Raises ------ + NotFound + A member with this ID does not exist in the guild. Forbidden You do not have access to the guild. HTTPException @@ -3466,11 +3478,13 @@ async def get_or_fetch_member( ) -> Optional[Member]: """|coro| - Tries to get a member from the cache with the given ID. If fails, it fetches - the member from the API and caches it. + Tries to get the member from the cache. If it fails, + fetches the member from the API and caches it. If you want to make a bulk get-or-fetch call, use :meth:`get_or_fetch_members`. + This only propagates exceptions when the ``strict`` parameter is enabled. + Parameters ---------- member_id: :class:`int` diff --git a/disnake/http.py b/disnake/http.py index 0f02153409..558b0b1ff6 100644 --- a/disnake/http.py +++ b/disnake/http.py @@ -1035,6 +1035,7 @@ def create_channel( "available_tags", "default_reaction_emoji", "default_sort_order", + "default_forum_layout", ) payload.update({k: v for k, v in options.items() if k in valid_keys and v is not None}) @@ -1546,7 +1547,7 @@ def estimate_pruned_members( def get_sticker(self, sticker_id: Snowflake) -> Response[sticker.Sticker]: return self.request(Route("GET", "/stickers/{sticker_id}", sticker_id=sticker_id)) - def list_premium_sticker_packs(self) -> Response[sticker.ListPremiumStickerPacks]: + def list_sticker_packs(self) -> Response[sticker.ListStickerPacks]: return self.request(Route("GET", "/sticker-packs")) def get_all_guild_stickers(self, guild_id: Snowflake) -> Response[List[sticker.GuildSticker]]: diff --git a/disnake/integrations.py b/disnake/integrations.py index e66b7d7162..526da5d49f 100644 --- a/disnake/integrations.py +++ b/disnake/integrations.py @@ -7,7 +7,14 @@ from .enums import ExpireBehaviour, try_enum from .user import User -from .utils import MISSING, _get_as_snowflake, deprecated, parse_time, warn_deprecated +from .utils import ( + MISSING, + _get_as_snowflake, + deprecated, + parse_time, + snowflake_time, + warn_deprecated, +) __all__ = ( "IntegrationAccount", @@ -99,6 +106,15 @@ def _from_data(self, data: PartialIntegrationPayload) -> None: self.account: IntegrationAccount = IntegrationAccount(data["account"]) self.application_id: Optional[int] = _get_as_snowflake(data, "application_id") + @property + def created_at(self) -> datetime.datetime: + """:class:`datetime.datetime`: Returns the integration's + (*not* the associated application's) creation time in UTC. + + .. versionadded:: 2.10 + """ + return snowflake_time(self.id) + class Integration(PartialIntegration): """Represents a guild integration. diff --git a/disnake/interactions/application_command.py b/disnake/interactions/application_command.py index 309f286c74..13c96c02de 100644 --- a/disnake/interactions/application_command.py +++ b/disnake/interactions/application_command.py @@ -10,7 +10,7 @@ from ..member import Member from ..message import Message from ..user import User -from .base import Interaction, InteractionDataResolved +from .base import ClientT, Interaction, InteractionDataResolved __all__ = ( "ApplicationCommandInteraction", @@ -41,7 +41,7 @@ ) -class ApplicationCommandInteraction(Interaction): +class ApplicationCommandInteraction(Interaction[ClientT]): """Represents an interaction with an application command. Current examples are slash commands, user commands and message commands. @@ -119,7 +119,7 @@ def filled_options(self) -> Dict[str, Any]: return kwargs -class GuildCommandInteraction(ApplicationCommandInteraction): +class GuildCommandInteraction(ApplicationCommandInteraction[ClientT]): """An :class:`ApplicationCommandInteraction` subclass, primarily meant for annotations. This prevents the command from being invoked in DMs by automatically setting @@ -137,7 +137,7 @@ class GuildCommandInteraction(ApplicationCommandInteraction): me: Member -class UserCommandInteraction(ApplicationCommandInteraction): +class UserCommandInteraction(ApplicationCommandInteraction[ClientT]): """An :class:`ApplicationCommandInteraction` subclass meant for annotations. No runtime behavior is changed but annotations are modified @@ -147,7 +147,7 @@ class UserCommandInteraction(ApplicationCommandInteraction): target: Union[User, Member] -class MessageCommandInteraction(ApplicationCommandInteraction): +class MessageCommandInteraction(ApplicationCommandInteraction[ClientT]): """An :class:`ApplicationCommandInteraction` subclass meant for annotations. No runtime behavior is changed but annotations are modified diff --git a/disnake/interactions/base.py b/disnake/interactions/base.py index aff49c6a33..bdcbe3cae2 100644 --- a/disnake/interactions/base.py +++ b/disnake/interactions/base.py @@ -8,6 +8,7 @@ TYPE_CHECKING, Any, Dict, + Generic, List, Mapping, Optional, @@ -95,9 +96,10 @@ MISSING: Any = utils.MISSING T = TypeVar("T") +ClientT = TypeVar("ClientT", bound="Client", covariant=True) -class Interaction: +class Interaction(Generic[ClientT]): """A base class representing a user-initiated Discord interaction. An interaction happens when a user performs an action that the client needs to @@ -175,7 +177,7 @@ def __init__(self, *, data: InteractionPayload, state: ConnectionState) -> None: self._state: ConnectionState = state # TODO: Maybe use a unique session self._session: ClientSession = state.http._HTTPClient__session # type: ignore - self.client: Client = state._get_client() + self.client: ClientT = cast(ClientT, state._get_client()) self._original_response: Optional[InteractionMessage] = None self.id: int = int(data["id"]) @@ -208,13 +210,9 @@ def __init__(self, *, data: InteractionPayload, state: ConnectionState) -> None: self.author = self._state.store_user(user) @property - def bot(self) -> AnyBot: - """:class:`~disnake.ext.commands.Bot`: The bot handling the interaction. - - Only applicable when used with :class:`~disnake.ext.commands.Bot`. - This is an alias for :attr:`.client`. - """ - return self.client # type: ignore + def bot(self) -> ClientT: + """:class:`~disnake.ext.commands.Bot`: An alias for :attr:`.client`.""" + return self.client @property def created_at(self) -> datetime: diff --git a/disnake/interactions/message.py b/disnake/interactions/message.py index 21effdd40f..c48213df1c 100644 --- a/disnake/interactions/message.py +++ b/disnake/interactions/message.py @@ -8,7 +8,7 @@ from ..enums import ComponentType, try_enum from ..message import Message from ..utils import cached_slot_property -from .base import Interaction, InteractionDataResolved +from .base import ClientT, Interaction, InteractionDataResolved __all__ = ( "MessageInteraction", @@ -28,7 +28,7 @@ from .base import InteractionChannel -class MessageInteraction(Interaction): +class MessageInteraction(Interaction[ClientT]): """Represents an interaction with a message component. Current examples are buttons and dropdowns. diff --git a/disnake/interactions/modal.py b/disnake/interactions/modal.py index 741130e58f..8c9945ab46 100644 --- a/disnake/interactions/modal.py +++ b/disnake/interactions/modal.py @@ -7,7 +7,7 @@ from ..enums import ComponentType from ..message import Message from ..utils import cached_slot_property -from .base import Interaction +from .base import ClientT, Interaction if TYPE_CHECKING: from ..state import ConnectionState @@ -21,7 +21,7 @@ __all__ = ("ModalInteraction", "ModalInteractionData") -class ModalInteraction(Interaction): +class ModalInteraction(Interaction[ClientT]): """Represents an interaction with a modal. .. versionadded:: 2.4 diff --git a/disnake/message.py b/disnake/message.py index 714069cc50..21f59e269e 100644 --- a/disnake/message.py +++ b/disnake/message.py @@ -603,7 +603,7 @@ def __init__( def with_state(cls, state: ConnectionState, data: MessageReferencePayload) -> Self: self = cls.__new__(cls) self.message_id = utils._get_as_snowflake(data, "message_id") - self.channel_id = int(data.pop("channel_id")) + self.channel_id = int(data["channel_id"]) self.guild_id = utils._get_as_snowflake(data, "guild_id") self.fail_if_not_exists = data.get("fail_if_not_exists", True) self._state = state @@ -658,10 +658,9 @@ def __repr__(self) -> str: return f"" def to_dict(self) -> MessageReferencePayload: - result: MessageReferencePayload = ( - {"message_id": self.message_id} if self.message_id is not None else {} - ) - result["channel_id"] = self.channel_id + result: MessageReferencePayload = {"channel_id": self.channel_id} + if self.message_id is not None: + result["message_id"] = self.message_id if self.guild_id is not None: result["guild_id"] = self.guild_id if self.fail_if_not_exists is not None: diff --git a/disnake/stage_instance.py b/disnake/stage_instance.py index 9b861d4a84..08f50dc3e1 100644 --- a/disnake/stage_instance.py +++ b/disnake/stage_instance.py @@ -2,11 +2,12 @@ from __future__ import annotations +import datetime from typing import TYPE_CHECKING, Optional from .enums import StagePrivacyLevel, try_enum from .mixins import Hashable -from .utils import MISSING, _get_as_snowflake, cached_slot_property, warn_deprecated +from .utils import MISSING, _get_as_snowflake, cached_slot_property, snowflake_time, warn_deprecated __all__ = ("StageInstance",) @@ -84,6 +85,14 @@ def _update(self, data: StageInstancePayload) -> None: def __repr__(self) -> str: return f"" + @property + def created_at(self) -> datetime.datetime: + """:class:`datetime.datetime`: Returns the stage instance's creation time in UTC. + + .. versionadded:: 2.10 + """ + return snowflake_time(self.id) + @cached_slot_property("_cs_channel") def channel(self) -> Optional[StageChannel]: """Optional[:class:`StageChannel`]: The channel that stage instance is running in.""" diff --git a/disnake/sticker.py b/disnake/sticker.py index b8d1fb8e71..01ce53b9d3 100644 --- a/disnake/sticker.py +++ b/disnake/sticker.py @@ -27,7 +27,7 @@ from .types.sticker import ( EditGuildSticker, GuildSticker as GuildStickerPayload, - ListPremiumStickerPacks as ListPremiumStickerPacksPayload, + ListStickerPacks as ListStickerPacksPayload, StandardSticker as StandardStickerPayload, Sticker as StickerPayload, StickerItem as StickerItemPayload, @@ -348,7 +348,7 @@ async def pack(self) -> StickerPack: :class:`StickerPack` The retrieved sticker pack. """ - data: ListPremiumStickerPacksPayload = await self._state.http.list_premium_sticker_packs() + data: ListStickerPacksPayload = await self._state.http.list_sticker_packs() packs = data["sticker_packs"] pack = find(lambda d: int(d["id"]) == self.pack_id, packs) diff --git a/disnake/team.py b/disnake/team.py index 6465128ffe..1034904cd9 100644 --- a/disnake/team.py +++ b/disnake/team.py @@ -2,6 +2,7 @@ from __future__ import annotations +import datetime from typing import TYPE_CHECKING, List, Optional from . import utils @@ -52,6 +53,14 @@ def __init__(self, state: ConnectionState, data: TeamPayload) -> None: def __repr__(self) -> str: return f"<{self.__class__.__name__} id={self.id} name={self.name}>" + @property + def created_at(self) -> datetime.datetime: + """:class:`datetime.datetime`: Returns the team's creation time in UTC. + + .. versionadded:: 2.10 + """ + return utils.snowflake_time(self.id) + @property def icon(self) -> Optional[Asset]: """Optional[:class:`.Asset`]: Retrieves the team's icon asset, if any.""" diff --git a/disnake/threads.py b/disnake/threads.py index 2126e85605..2457c5a879 100644 --- a/disnake/threads.py +++ b/disnake/threads.py @@ -332,7 +332,7 @@ def created_at(self) -> datetime.datetime: """:class:`datetime.datetime`: Returns the thread's creation time in UTC. .. versionchanged:: 2.4 - If create_timestamp is provided by discord, that will be used instead of the time in the ID. + If ``create_timestamp`` is provided by Discord, that will be used instead of the time in the ID. """ return self.create_timestamp or snowflake_time(self.id) @@ -1183,6 +1183,14 @@ def __repr__(self) -> str: f" moderated={self.moderated!r} emoji={self.emoji!r}>" ) + @property + def created_at(self) -> datetime.datetime: + """:class:`datetime.datetime`: Returns the tag's creation time in UTC. + + .. versionadded:: 2.10 + """ + return snowflake_time(self.id) + def to_dict(self) -> PartialForumTagPayload: emoji_name, emoji_id = PartialEmoji._emoji_to_name_id(self.emoji) data: PartialForumTagPayload = { diff --git a/disnake/types/audit_log.py b/disnake/types/audit_log.py index cca0c0fec0..d3b3a5484f 100644 --- a/disnake/types/audit_log.py +++ b/disnake/types/audit_log.py @@ -300,6 +300,8 @@ class _AuditLogChange_AutoModTriggerMetadata(TypedDict): ] +# All of these are technically only required for matching event types, +# but they're typed as required keys for simplicity class AuditEntryInfo(TypedDict): delete_member_days: str members_removed: str @@ -312,6 +314,7 @@ class AuditEntryInfo(TypedDict): application_id: Snowflake auto_moderation_rule_name: str auto_moderation_rule_trigger_type: str + integration_type: str class AuditLogEntry(TypedDict): diff --git a/disnake/types/message.py b/disnake/types/message.py index a7501fbb93..26f691c6bb 100644 --- a/disnake/types/message.py +++ b/disnake/types/message.py @@ -63,11 +63,11 @@ class MessageApplication(TypedDict): cover_image: NotRequired[str] -class MessageReference(TypedDict, total=False): - message_id: Snowflake +class MessageReference(TypedDict): + message_id: NotRequired[Snowflake] channel_id: Snowflake - guild_id: Snowflake - fail_if_not_exists: bool + guild_id: NotRequired[Snowflake] + fail_if_not_exists: NotRequired[bool] class RoleSubscriptionData(TypedDict): diff --git a/disnake/types/sticker.py b/disnake/types/sticker.py index ccd2d764ff..06bc58aa05 100644 --- a/disnake/types/sticker.py +++ b/disnake/types/sticker.py @@ -64,5 +64,5 @@ class EditGuildSticker(TypedDict, total=False): description: Optional[str] -class ListPremiumStickerPacks(TypedDict): +class ListStickerPacks(TypedDict): sticker_packs: List[StickerPack] diff --git a/disnake/ui/item.py b/disnake/ui/item.py index b1723feab0..971ca8dcb3 100644 --- a/disnake/ui/item.py +++ b/disnake/ui/item.py @@ -24,6 +24,7 @@ if TYPE_CHECKING: from typing_extensions import ParamSpec, Self + from ..client import Client from ..components import NestedComponent from ..enums import ComponentType from ..interactions import MessageInteraction @@ -35,6 +36,8 @@ else: ParamSpec = TypeVar +ClientT = TypeVar("ClientT", bound="Client") + class WrappedComponent(ABC): """Represents the base UI component that all UI components inherit from. @@ -142,7 +145,7 @@ def view(self) -> V_co: """Optional[:class:`View`]: The underlying view for this item.""" return self._view - async def callback(self, interaction: MessageInteraction, /) -> None: + async def callback(self, interaction: MessageInteraction[ClientT], /) -> None: """|coro| The callback associated with this UI item. diff --git a/disnake/ui/modal.py b/disnake/ui/modal.py index 02ffb34493..a7a5503a28 100644 --- a/disnake/ui/modal.py +++ b/disnake/ui/modal.py @@ -6,7 +6,7 @@ import os import sys import traceback -from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union +from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, TypeVar, Union from ..enums import TextInputStyle from ..utils import MISSING @@ -14,6 +14,7 @@ from .text_input import TextInput if TYPE_CHECKING: + from ..client import Client from ..interactions.modal import ModalInteraction from ..state import ConnectionState from ..types.components import Modal as ModalPayload @@ -22,6 +23,8 @@ __all__ = ("Modal",) +ClientT = TypeVar("ClientT", bound="Client") + class Modal: """Represents a UI Modal. @@ -156,7 +159,7 @@ def add_text_input( ) ) - async def callback(self, interaction: ModalInteraction, /) -> None: + async def callback(self, interaction: ModalInteraction[ClientT], /) -> None: """|coro| The callback associated with this modal. @@ -170,7 +173,7 @@ async def callback(self, interaction: ModalInteraction, /) -> None: """ pass - async def on_error(self, error: Exception, interaction: ModalInteraction) -> None: + async def on_error(self, error: Exception, interaction: ModalInteraction[ClientT]) -> None: """|coro| A callback that is called when an error occurs. diff --git a/disnake/utils.py b/disnake/utils.py index 352550646d..91bfd41457 100644 --- a/disnake/utils.py +++ b/disnake/utils.py @@ -141,14 +141,14 @@ def __init__(self, name: str, function: Callable[[T], T_co]) -> None: self.__doc__ = function.__doc__ @overload - def __get__(self, instance: None, owner: Type[T]) -> Self: + def __get__(self, instance: None, owner: Type[Any]) -> Self: ... @overload - def __get__(self, instance: T, owner: Type[T]) -> T_co: + def __get__(self, instance: T, owner: Type[Any]) -> T_co: ... - def __get__(self, instance: Optional[T], owner: Type[T]) -> Any: + def __get__(self, instance: Optional[T], owner: Type[Any]) -> Any: if instance is None: return self diff --git a/docs/api/activities.rst b/docs/api/activities.rst index 4ceee9e944..62597dd364 100644 --- a/docs/api/activities.rst +++ b/docs/api/activities.rst @@ -66,6 +66,7 @@ CustomActivity .. autoclass:: CustomActivity :members: :inherited-members: + :exclude-members: large_image_url, large_image_text, small_image_url, small_image_text Enumerations ------------ diff --git a/docs/api/audit_logs.rst b/docs/api/audit_logs.rst index ba1f735587..1052c610b4 100644 --- a/docs/api/audit_logs.rst +++ b/docs/api/audit_logs.rst @@ -42,17 +42,17 @@ AuditLogChanges :attr:`~AuditLogEntry.category`\, the data retrieved by this attribute differs: - +----------------------------------------+---------------------------------------------------+ - | Category | Description | - +----------------------------------------+---------------------------------------------------+ - | :attr:`~AuditLogActionCategory.create` | All attributes are set to ``None``. | - +----------------------------------------+---------------------------------------------------+ - | :attr:`~AuditLogActionCategory.delete` | All attributes are set the value before deletion. | - +----------------------------------------+---------------------------------------------------+ - | :attr:`~AuditLogActionCategory.update` | All attributes are set the value before updating. | - +----------------------------------------+---------------------------------------------------+ - | ``None`` | No attributes are set. | - +----------------------------------------+---------------------------------------------------+ + +----------------------------------------+------------------------------------------------------+ + | Category | Description | + +----------------------------------------+------------------------------------------------------+ + | :attr:`~AuditLogActionCategory.create` | All attributes are set to ``None``. | + +----------------------------------------+------------------------------------------------------+ + | :attr:`~AuditLogActionCategory.delete` | All attributes are set to the value before deletion. | + +----------------------------------------+------------------------------------------------------+ + | :attr:`~AuditLogActionCategory.update` | All attributes are set to the value before updating. | + +----------------------------------------+------------------------------------------------------+ + | ``None`` | No attributes are set. | + +----------------------------------------+------------------------------------------------------+ .. attribute:: after @@ -62,17 +62,17 @@ AuditLogChanges :attr:`~AuditLogEntry.category`\, the data retrieved by this attribute differs: - +----------------------------------------+--------------------------------------------------+ - | Category | Description | - +----------------------------------------+--------------------------------------------------+ - | :attr:`~AuditLogActionCategory.create` | All attributes are set to the created value | - +----------------------------------------+--------------------------------------------------+ - | :attr:`~AuditLogActionCategory.delete` | All attributes are set to ``None`` | - +----------------------------------------+--------------------------------------------------+ - | :attr:`~AuditLogActionCategory.update` | All attributes are set the value after updating. | - +----------------------------------------+--------------------------------------------------+ - | ``None`` | No attributes are set. | - +----------------------------------------+--------------------------------------------------+ + +----------------------------------------+-----------------------------------------------------+ + | Category | Description | + +----------------------------------------+-----------------------------------------------------+ + | :attr:`~AuditLogActionCategory.create` | All attributes are set to the created value. | + +----------------------------------------+-----------------------------------------------------+ + | :attr:`~AuditLogActionCategory.delete` | All attributes are set to ``None``. | + +----------------------------------------+-----------------------------------------------------+ + | :attr:`~AuditLogActionCategory.update` | All attributes are set to the value after updating. | + +----------------------------------------+-----------------------------------------------------+ + | ``None`` | No attributes are set. | + +----------------------------------------+-----------------------------------------------------+ AuditLogDiff ~~~~~~~~~~~~ @@ -97,7 +97,7 @@ AuditLogDiff .. describe:: iter(diff) - Returns an iterator over (attribute, value) tuple of this diff. + Returns an iterator over ``(attribute, value)`` tuples of this diff. .. attribute:: name @@ -919,6 +919,11 @@ AuditLogAction the :class:`User` who got kicked. If the user is not found then it is a :class:`Object` with the user's ID. + When this is the action, the type of :attr:`~AuditLogEntry.extra` may be + set to an unspecified proxy object with one attribute: + + - ``integration_type``: A string representing the type of the integration which performed the action, if any. + When this is the action, :attr:`~AuditLogEntry.changes` is empty. .. attribute:: member_prune @@ -931,7 +936,7 @@ AuditLogAction When this is the action, the type of :attr:`~AuditLogEntry.extra` is set to an unspecified proxy object with two attributes: - - ``delete_members_days``: An integer specifying how far the prune was. + - ``delete_member_days``: An integer specifying how far the prune was. - ``members_removed``: An integer specifying how many members were removed. When this is the action, :attr:`~AuditLogEntry.changes` is empty. @@ -984,6 +989,11 @@ AuditLogAction the :class:`Member` or :class:`User` who got the role. If the user is not found then it is a :class:`Object` with the user's ID. + When this is the action, the type of :attr:`~AuditLogEntry.extra` may be + set to an unspecified proxy object with one attribute: + + - ``integration_type``: A string representing the type of the integration which performed the action, if any. + Possible attributes for :class:`AuditLogDiff`: - :attr:`~AuditLogDiff.roles` diff --git a/docs/api/clients.rst b/docs/api/clients.rst index b608259013..7f00a43e48 100644 --- a/docs/api/clients.rst +++ b/docs/api/clients.rst @@ -20,7 +20,7 @@ Client .. autoclass:: Client :members: - :exclude-members: fetch_guilds, event + :exclude-members: fetch_guilds, event, listen .. automethod:: Client.event() :decorator: @@ -28,6 +28,9 @@ Client .. automethod:: Client.fetch_guilds :async-for: + .. automethod:: Client.listen(name=None) + :decorator: + AutoShardedClient ~~~~~~~~~~~~~~~~~ diff --git a/docs/api/events.rst b/docs/api/events.rst index 7df9545f49..c92762c3b0 100644 --- a/docs/api/events.rst +++ b/docs/api/events.rst @@ -16,9 +16,11 @@ So, what are events anyway? Most of the :class:`Client` application cycle is bas to notify client about certain actions like message deletion, emoji creation, member nickname updates, etc. This library provides a few ways to register an -*event handler* — a special function which will listen for specific types of events — which allows you to take action based on certain events. +*event handler* or *event listener* — a special function which will listen for specific types of events — which allows you to take action based on certain events. -The first way is through the use of the :meth:`Client.event` decorator: :: +The first way to create an *event handler* is through the use of the :meth:`Client.event` decorator. +Note that these are unique, which means you can only have one of +each type (i.e. only one ``on_message``, one ``on_member_ban``, etc.): :: client = disnake.Client(...) @@ -30,8 +32,9 @@ The first way is through the use of the :meth:`Client.event` decorator: :: if message.content.startswith('$hello'): await message.reply(f'Hello, {message.author}!') -The second way is through subclassing :class:`Client` and -overriding the specific events. For example: :: + +Another way is through subclassing :class:`Client` and overriding the specific events, +which has essentially the same effect as the :meth:`Client.event` decorator. For example: :: class MyClient(disnake.Client): async def on_message(self, message): @@ -41,7 +44,28 @@ overriding the specific events. For example: :: if message.content.startswith('$hello'): await message.reply(f'Hello, {message.author}!') -Another way is to use :meth:`Client.wait_for`, which is a single-use event handler to wait for + +A separate way is through the use of an *event listener*. These are similar to the *event handlers* +described above, but allow you to have as many *listeners* of the same type as you want. +You can register listeners using the :meth:`Client.listen` decorator or through the :meth:`Client.add_listener` +method. Similarly you can remove a listener using the :meth:`Client.remove_listener` method. :: + + @client.listen() + async def on_message(message: disnake.Message): + if message.author.bot: + return + + if message.content.startswith('$hello'): + await message.reply(f'Hello, {message.author}') + + + async def my_on_ready(): + print(f'Logged in as {client.user}') + + client.add_listener(my_on_ready, 'on_ready') + + +Lastly, :meth:`Client.wait_for` is a single-use event handler to wait for something to happen in more specific scenarios: :: @client.event @@ -57,20 +81,6 @@ something to happen in more specific scenarios: :: msg = await client.wait_for('message', check=check) await channel.send(f'Hello {msg.author}!') -The above pieces of code are essentially equal, and both respond with ``Hello, {author's username here}!`` message -when a user sends a ``$hello`` message. - -.. warning:: - - Event handlers described here are a bit different from :class:`~ext.commands.Bot`'s *event listeners*. - - :class:`Client`'s event handlers are unique, which means you can only have one of each type (i.e. only one `on_message`, one `on_member_ban`, etc.). With :class:`~ext.commands.Bot` however, you can have as many *listeners* - of the same type as you want. - - Also note that :meth:`Bot.event() ` is the same as :class:`Client`'s - :meth:`~Client.event` (since :class:`~ext.commands.Bot` subclasses :class:`Client`) and does not allow to listen/watch - for multiple events of the same type. Consider using :meth:`Bot.listen() ` instead. - .. note:: Events can be sent not only by Discord. For instance, if you use the :ref:`commands extension `, @@ -126,9 +136,8 @@ This section documents events related to :class:`Client` and its connectivity to ``on_error`` will only be dispatched to :meth:`Client.event`. - It will not be received by :meth:`Client.wait_for`, or, if used, - :ref:`ext_commands_api_bots` listeners such as - :meth:`~ext.commands.Bot.listen` or :meth:`~ext.commands.Cog.listener`. + It will not be received by :meth:`Client.wait_for` and listeners + such as :meth:`Client.listen`, or :meth:`~ext.commands.Cog.listener`. :param event: The name of the event that raised the exception. :type event: :class:`str` @@ -154,9 +163,8 @@ This section documents events related to :class:`Client` and its connectivity to .. note:: ``on_gateway_error`` will only be dispatched to :meth:`Client.event`. - It will not be received by :meth:`Client.wait_for`, or, if used, - :ref:`ext_commands_api_bots` listeners such as - :meth:`~ext.commands.Bot.listen` or :meth:`~ext.commands.Cog.listener`. + It will not be received by :meth:`Client.wait_for` and listeners + such as :meth:`Client.listen`, or :meth:`~ext.commands.Cog.listener`. .. note:: This will not be dispatched for exceptions that occur while parsing ``READY`` and diff --git a/docs/api/stickers.rst b/docs/api/stickers.rst index c1bd5031cc..8719958753 100644 --- a/docs/api/stickers.rst +++ b/docs/api/stickers.rst @@ -68,7 +68,7 @@ StickerType .. attribute:: standard - Represents a standard sticker that all Nitro users can use. + Represents a standard sticker that all users can use. .. attribute:: guild diff --git a/docs/ext/commands/api/bots.rst b/docs/ext/commands/api/bots.rst index fef065fdbb..87976ccbc1 100644 --- a/docs/ext/commands/api/bots.rst +++ b/docs/ext/commands/api/bots.rst @@ -21,7 +21,7 @@ Bot .. autoclass:: Bot :members: :inherited-members: - :exclude-members: after_invoke, before_invoke, check, check_once, command, event, group, listen, slash_command, user_command, message_command, after_slash_command_invoke, after_user_command_invoke, after_message_command_invoke, before_slash_command_invoke, before_user_command_invoke, before_message_command_invoke + :exclude-members: after_invoke, before_invoke, check, check_once, command, event, listen, group, slash_command, user_command, message_command, after_slash_command_invoke, after_user_command_invoke, after_message_command_invoke, before_slash_command_invoke, before_user_command_invoke, before_message_command_invoke .. automethod:: Bot.after_invoke() :decorator: diff --git a/docs/faq.rst b/docs/faq.rst index c56a542b9e..60e6571be9 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -87,8 +87,8 @@ Where can I find usage examples? Example code can be found in the `examples folder `_ in the repository. -How do I set the "Playing" status? -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +How do I set an activity/status? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The ``activity`` keyword argument may be passed in the :class:`Client` constructor or :meth:`Client.change_presence`, given an :class:`Activity` object. @@ -100,16 +100,20 @@ The constructor may be used for static activities, while :meth:`Client.change_pr There is a high chance of disconnecting if presences are changed right after connecting. -The status type (playing, listening, streaming, watching) can be set using the :class:`ActivityType` enum. +The status type (playing, listening, streaming, watching, or custom) can be set using the :class:`ActivityType` enum. For memory optimisation purposes, some activities are offered in slimmed-down versions: - :class:`Game` - :class:`Streaming` +- :class:`CustomActivity` Putting both of these pieces of info together, you get the following: :: client = disnake.Client(activity=disnake.Game(name='my game')) + # alternatively, a plain custom status: + client = disnake.Client(activity=disnake.CustomActivity(name='As seen on TV!')) + # or, for watching: activity = disnake.Activity(name='my activity', type=disnake.ActivityType.watching) client = disnake.Client(activity=activity) diff --git a/examples/interactions/converters.py b/examples/interactions/converters.py index 7ef2f99a09..99c89821da 100644 --- a/examples/interactions/converters.py +++ b/examples/interactions/converters.py @@ -14,7 +14,7 @@ # which can be set using `Param` and the `converter` argument. @bot.slash_command() async def clean_command( - inter: disnake.CommandInteraction, + inter: disnake.CommandInteraction[commands.Bot], text: str = commands.Param(converter=lambda inter, text: text.replace("@", "\\@")), ): ... diff --git a/examples/interactions/param.py b/examples/interactions/param.py index bc12030fcf..cd235f75e5 100644 --- a/examples/interactions/param.py +++ b/examples/interactions/param.py @@ -63,7 +63,7 @@ async def description( # by using `Param` and passing a callable. @bot.slash_command() async def defaults( - inter: disnake.CommandInteraction, + inter: disnake.CommandInteraction[commands.Bot], string: str = "this is a default value", user: disnake.User = commands.Param(lambda inter: inter.author), ): diff --git a/test_bot/__main__.py b/test_bot/__main__.py index d8aef8b680..37c5afa288 100644 --- a/test_bot/__main__.py +++ b/test_bot/__main__.py @@ -5,6 +5,7 @@ import os import sys import traceback +from typing import Union import disnake from disnake.ext import commands @@ -55,9 +56,14 @@ def add_cog(self, cog: commands.Cog, *, override: bool = False) -> None: logger.info("Loading cog %s", cog.qualified_name) return super().add_cog(cog, override=override) - async def on_command_error(self, ctx: commands.Context, error: commands.CommandError) -> None: - msg = f"Command `{ctx.command}` failed due to `{error}`" - logger.error(msg, exc_info=True) + async def _handle_error( + self, ctx: Union[commands.Context, disnake.AppCommandInter], error: Exception, prefix: str + ) -> None: + if isinstance(error, commands.CommandInvokeError): + error = error.original + + msg = f"{prefix} failed due to `{error}`" + logger.error(msg, exc_info=error) embed = disnake.Embed( title=msg, @@ -66,60 +72,26 @@ async def on_command_error(self, ctx: commands.Context, error: commands.CommandE ) await ctx.send(embed=embed) + async def on_command_error(self, ctx: commands.Context, error: commands.CommandError) -> None: + await self._handle_error(ctx, error, f"Prefix command `{ctx.command}`") + async def on_slash_command_error( - self, - inter: disnake.AppCmdInter, - error: commands.CommandError, + self, inter: disnake.AppCommandInter, error: commands.CommandError ) -> None: - msg = f"Slash command `{inter.data.name}` failed due to `{error}`" - logger.error(msg, exc_info=True) - - embed = disnake.Embed( - title=msg, - description=fancy_traceback(error), - color=disnake.Color.red(), - ) - if inter.response.is_done(): - send = inter.channel.send - else: - send = inter.response.send_message - await send(embed=embed) + cmd = inter.application_command + await self._handle_error(inter, error, f"Slash command `/{cmd.qualified_name}`") async def on_user_command_error( - self, - inter: disnake.AppCmdInter, - error: commands.CommandError, + self, inter: disnake.AppCommandInter, error: commands.CommandError ) -> None: - msg = f"User command `{inter.data.name}` failed due to `{error}`" - logger.error(msg, exc_info=True) - embed = disnake.Embed( - title=msg, - description=fancy_traceback(error), - color=disnake.Color.red(), - ) - if inter.response.is_done(): - send = inter.channel.send - else: - send = inter.response.send_message - await send(embed=embed) + cmd = inter.application_command + await self._handle_error(inter, error, f"User command `{cmd.name}`") async def on_message_command_error( - self, - inter: disnake.AppCmdInter, - error: commands.CommandError, + self, inter: disnake.AppCommandInter, error: commands.CommandError ) -> None: - msg = f"Message command `{inter.data.name}` failed due to `{error}`" - logger.error(msg, exc_info=True) - embed = disnake.Embed( - title=msg, - description=fancy_traceback(error), - color=disnake.Color.red(), - ) - if inter.response.is_done(): - send = inter.channel.send - else: - send = inter.response.send_message - await send(embed=embed) + cmd = inter.application_command + await self._handle_error(inter, error, f"Message command `{cmd.name}`") print(f"disnake: {disnake.__version__}\n") diff --git a/test_bot/cogs/guild_scheduled_events.py b/test_bot/cogs/guild_scheduled_events.py index dac3b6171c..1ffcb92295 100644 --- a/test_bot/cogs/guild_scheduled_events.py +++ b/test_bot/cogs/guild_scheduled_events.py @@ -13,14 +13,17 @@ def __init__(self, bot: commands.Bot) -> None: @commands.slash_command() async def fetch_event( - self, inter: disnake.GuildCommandInteraction, id: commands.LargeInt + self, inter: disnake.GuildCommandInteraction[commands.Bot], id: commands.LargeInt ) -> None: gse = await inter.guild.fetch_scheduled_event(id) await inter.response.send_message(str(gse.image)) @commands.slash_command() async def edit_event( - self, inter: disnake.GuildCommandInteraction, id: commands.LargeInt, new_image: bool + self, + inter: disnake.GuildCommandInteraction[commands.Bot], + id: commands.LargeInt, + new_image: bool, ) -> None: await inter.response.defer() gse = await inter.guild.fetch_scheduled_event(id) @@ -33,7 +36,10 @@ async def edit_event( @commands.slash_command() async def create_event( - self, inter: disnake.GuildCommandInteraction, name: str, channel: disnake.VoiceChannel + self, + inter: disnake.GuildCommandInteraction[commands.Bot], + name: str, + channel: disnake.VoiceChannel, ) -> None: image = disnake.File("./assets/banner.png") gse = await inter.guild.create_scheduled_event( diff --git a/test_bot/cogs/injections.py b/test_bot/cogs/injections.py index 2621c69b08..192ca10137 100644 --- a/test_bot/cogs/injections.py +++ b/test_bot/cogs/injections.py @@ -31,7 +31,7 @@ def __init__(self, prefix: str, suffix: str = "") -> None: self.prefix = prefix self.suffix = suffix - def __call__(self, inter: disnake.CommandInteraction, a: str = "init"): + def __call__(self, inter: disnake.CommandInteraction[commands.Bot], a: str = "init"): return self.prefix + a + self.suffix @@ -41,7 +41,7 @@ def __init__(self, username: str, discriminator: str) -> None: self.discriminator = discriminator @commands.converter_method - async def convert(cls, inter: disnake.CommandInteraction, user: disnake.User): + async def convert(cls, inter: disnake.CommandInteraction[commands.Bot], user: disnake.User): return cls(user.name, user.discriminator) def __repr__(self) -> str: @@ -89,7 +89,7 @@ async def injected_method(self, number: int = 3): @commands.slash_command() async def injection_command( self, - inter: disnake.CommandInteraction, + inter: disnake.CommandInteraction[commands.Bot], sqrt: Optional[float] = commands.Param(None, converter=lambda i, x: x**0.5), prefixed: str = commands.Param(converter=PrefixConverter("__", "__")), other: Tuple[int, str] = commands.inject(injected), @@ -109,7 +109,7 @@ async def injection_command( @commands.slash_command() async def discerned_injections( self, - inter: disnake.CommandInteraction, + inter: disnake.CommandInteraction[commands.Bot], perhaps: PerhapsThis, god: Optional[HopeToGod] = None, ) -> None: diff --git a/test_bot/cogs/localization.py b/test_bot/cogs/localization.py index ae93d5b770..9bba8d1495 100644 --- a/test_bot/cogs/localization.py +++ b/test_bot/cogs/localization.py @@ -16,7 +16,7 @@ def __init__(self, bot) -> None: @commands.slash_command() async def localized_command( self, - inter: disnake.AppCmdInter, + inter: disnake.AppCmdInter[commands.Bot], auto: str, choice: str = commands.Param( choices=[ @@ -45,7 +45,7 @@ async def localized_command( @localized_command.autocomplete("auto") async def autocomp( - self, inter: disnake.AppCmdInter, value: str + self, inter: disnake.AppCmdInter[commands.Bot], value: str ) -> "disnake.app_commands.Choices": # not really autocomplete, only used for showing autocomplete localization x = list(map(str, range(1, 6))) @@ -53,11 +53,11 @@ async def autocomp( return [Localized(v, key=f"AUTOCOMP_{v}") for v in x] @commands.slash_command() - async def localized_top_level(self, inter: disnake.AppCmdInter) -> None: + async def localized_top_level(self, inter: disnake.AppCmdInter[commands.Bot]) -> None: pass @localized_top_level.sub_command_group() - async def second(self, inter: disnake.AppCmdInter) -> None: + async def second(self, inter: disnake.AppCmdInter[commands.Bot]) -> None: pass @second.sub_command( @@ -66,7 +66,7 @@ async def second(self, inter: disnake.AppCmdInter) -> None: ) async def third( self, - inter: disnake.AppCmdInter, + inter: disnake.AppCmdInter[commands.Bot], value: str = commands.Param(name=Localized("a_string", key="A_VERY_COOL_PARAM_NAME")), ) -> None: await inter.response.send_message(f"```py\n{pformat(locals())}\n```") @@ -75,7 +75,7 @@ async def third( @commands.message_command( name=Localized("Localized Reverse", key="MSG_REVERSE"), ) - async def cmd_msg(self, inter: disnake.AppCmdInter, msg: disnake.Message) -> None: + async def cmd_msg(self, inter: disnake.AppCmdInter[commands.Bot], msg: disnake.Message) -> None: await inter.response.send_message(msg.content[::-1]) diff --git a/test_bot/cogs/message_commands.py b/test_bot/cogs/message_commands.py index f322584090..d4101b8e41 100644 --- a/test_bot/cogs/message_commands.py +++ b/test_bot/cogs/message_commands.py @@ -9,7 +9,7 @@ def __init__(self, bot) -> None: self.bot: commands.Bot = bot @commands.message_command(name="Reverse") - async def reverse(self, inter: disnake.MessageCommandInteraction) -> None: + async def reverse(self, inter: disnake.MessageCommandInteraction[commands.Bot]) -> None: await inter.response.send_message(inter.target.content[::-1]) diff --git a/test_bot/cogs/misc.py b/test_bot/cogs/misc.py index 5784a65496..c10081e967 100644 --- a/test_bot/cogs/misc.py +++ b/test_bot/cogs/misc.py @@ -19,7 +19,9 @@ def _get_file(self, description: str) -> disnake.File: return disnake.File(io.BytesIO(data), "image.png", description=description) @commands.slash_command() - async def attachment_desc(self, inter: disnake.AppCmdInter, desc: str = "test") -> None: + async def attachment_desc( + self, inter: disnake.AppCmdInter[commands.Bot], desc: str = "test" + ) -> None: """Send an attachment with the given description (or the default) Parameters @@ -29,7 +31,9 @@ async def attachment_desc(self, inter: disnake.AppCmdInter, desc: str = "test") await inter.response.send_message(file=self._get_file(desc)) @commands.slash_command() - async def attachment_desc_edit(self, inter: disnake.AppCmdInter, desc: str = "test") -> None: + async def attachment_desc_edit( + self, inter: disnake.AppCmdInter[commands.Bot], desc: str = "test" + ) -> None: """Send a message with a button, which sends an attachment with the given description (or the default) Parameters diff --git a/test_bot/cogs/modals.py b/test_bot/cogs/modals.py index 64c095839f..13c84bddf2 100644 --- a/test_bot/cogs/modals.py +++ b/test_bot/cogs/modals.py @@ -24,7 +24,7 @@ def __init__(self) -> None: ] super().__init__(title="Create Tag", custom_id="create_tag", components=components) - async def callback(self, inter: disnake.ModalInteraction) -> None: + async def callback(self, inter: disnake.ModalInteraction[commands.Bot]) -> None: embed = disnake.Embed(title="Tag Creation") for key, value in inter.text_values.items(): embed.add_field(name=key.capitalize(), value=value, inline=False) @@ -36,12 +36,12 @@ def __init__(self, bot: commands.Bot) -> None: self.bot = bot @commands.slash_command() - async def create_tag(self, inter: disnake.AppCmdInter) -> None: + async def create_tag(self, inter: disnake.AppCmdInter[commands.Bot]) -> None: """Sends a Modal to create a tag.""" await inter.response.send_modal(modal=MyModal()) @commands.slash_command() - async def create_tag_low(self, inter: disnake.AppCmdInter) -> None: + async def create_tag_low(self, inter: disnake.AppCmdInter[commands.Bot]) -> None: """Sends a Modal to create a tag but with a low-level implementation.""" await inter.response.send_modal( title="Create Tag", diff --git a/test_bot/cogs/slash_commands.py b/test_bot/cogs/slash_commands.py index a2c7847f15..e7a2437d56 100644 --- a/test_bot/cogs/slash_commands.py +++ b/test_bot/cogs/slash_commands.py @@ -15,11 +15,11 @@ def __init__(self, bot) -> None: self.bot: commands.Bot = bot @commands.slash_command() - async def hello(self, inter: disnake.CommandInteraction) -> None: + async def hello(self, inter: disnake.CommandInteraction[commands.Bot]) -> None: await inter.response.send_message("Hello world!") @commands.slash_command() - async def auto(self, inter: disnake.CommandInteraction, mood: str) -> None: + async def auto(self, inter: disnake.CommandInteraction[commands.Bot], mood: str) -> None: """Has an autocomplete option. Parameters @@ -29,27 +29,27 @@ async def auto(self, inter: disnake.CommandInteraction, mood: str) -> None: await inter.send(mood) @auto.autocomplete("mood") - async def test_autocomp(self, inter: disnake.CommandInteraction, string: str): + async def test_autocomp(self, inter: disnake.CommandInteraction[commands.Bot], string: str): return ["XD", ":D", ":)", ":|", ":("] @commands.slash_command() async def alt_auto( self, - inter: disnake.AppCmdInter, + inter: disnake.AppCmdInter[commands.Bot], mood: str = commands.Param(autocomplete=test_autocomp), ) -> None: await inter.send(mood) @commands.slash_command() async def guild_only( - self, inter: disnake.GuildCommandInteraction, option: Optional[str] = None + self, inter: disnake.GuildCommandInteraction[commands.Bot], option: Optional[str] = None ) -> None: await inter.send(f"guild: {inter.guild} | option: {option!r}") @commands.slash_command() async def ranges( self, - inter: disnake.CommandInteraction, + inter: disnake.CommandInteraction[commands.Bot], a: int = commands.Param(None, lt=0), b: Optional[commands.Range[int, 1, ...]] = None, c: Optional[commands.Range[int, 0, 10]] = None, @@ -68,7 +68,7 @@ async def ranges( @commands.slash_command() async def largenumber( - self, inter: disnake.CommandInteraction, largenum: commands.LargeInt + self, inter: disnake.CommandInteraction[commands.Bot], largenum: commands.LargeInt ) -> None: await inter.send(f"Is int: {isinstance(largenum, int)}") diff --git a/test_bot/cogs/user_commands.py b/test_bot/cogs/user_commands.py index 318f3127a3..e8c67efdca 100644 --- a/test_bot/cogs/user_commands.py +++ b/test_bot/cogs/user_commands.py @@ -9,7 +9,9 @@ def __init__(self, bot) -> None: self.bot: commands.Bot = bot @commands.user_command(name="Avatar") - async def avatar(self, inter: disnake.UserCommandInteraction, user: disnake.User) -> None: + async def avatar( + self, inter: disnake.UserCommandInteraction[commands.Bot], user: disnake.User + ) -> None: await inter.response.send_message(user.display_avatar.url, ephemeral=True) diff --git a/tests/test_activity.py b/tests/test_activity.py new file mode 100644 index 0000000000..a035309f51 --- /dev/null +++ b/tests/test_activity.py @@ -0,0 +1,97 @@ +# SPDX-License-Identifier: MIT + +from typing import TYPE_CHECKING + +import pytest + +from disnake import activity as _activity + +if TYPE_CHECKING: + from disnake.types.activity import ActivityAssets + + +@pytest.fixture +def activity(): + return _activity.Activity() + + +@pytest.fixture +def game(): + return _activity.Game(name="Celeste") + + +@pytest.fixture +def custom_activity(): + return _activity.CustomActivity(name="custom") + + +@pytest.fixture +def streaming(): + return _activity.Streaming(name="me", url="https://disnake.dev") + + +@pytest.fixture +def spotify(): + return _activity.Spotify() + + +@pytest.fixture(params=["activity", "game", "custom_activity", "streaming", "spotify"]) +def any_activity(request): + return request.getfixturevalue(request.param) + + +class TestAssets: + def test_none(self, any_activity: _activity.ActivityTypes) -> None: + assert any_activity.large_image_url is None + assert any_activity.small_image_url is None + assert any_activity.large_image_text is None + assert any_activity.small_image_text is None + + def test_text(self, any_activity: _activity.ActivityTypes) -> None: + assets: ActivityAssets = {"large_text": "hi", "small_text": "hello"} + any_activity.assets = assets + + assert any_activity.large_image_url is None + assert any_activity.small_image_url is None + assert any_activity.large_image_text == "hi" + assert any_activity.small_image_text == "hello" + + def test_mp(self, any_activity: _activity.ActivityTypes) -> None: + assets: ActivityAssets = { + "large_image": "mp:external/stuff/large", + "small_image": "mp:external/stuff/small", + } + any_activity.assets = assets + + assert any_activity.large_image_url == "https://media.discordapp.net/external/stuff/large" + assert any_activity.small_image_url == "https://media.discordapp.net/external/stuff/small" + + def test_unknown_prefix(self, any_activity: _activity.ActivityTypes) -> None: + assets: ActivityAssets = {"large_image": "unknown:a", "small_image": "unknown:b"} + any_activity.assets = assets + + assert any_activity.large_image_url is None + assert any_activity.small_image_url is None + + def test_asset_id(self, any_activity: _activity.ActivityTypes) -> None: + assets: ActivityAssets = {"large_image": "1234", "small_image": "5678"} + any_activity.assets = assets + + assert any_activity.large_image_url is None + assert any_activity.small_image_url is None + + # test `Activity` with application_id separately; + # without application_id, it should behave like the other types (see previous test) + def test_asset_id_activity(self, activity: _activity.Activity) -> None: + activity.application_id = 1010 + + assets: ActivityAssets = {"large_image": "1234", "small_image": "5678"} + activity.assets = assets + assert activity.large_image_url == "https://cdn.discordapp.com/app-assets/1010/1234.png" + assert activity.small_image_url == "https://cdn.discordapp.com/app-assets/1010/5678.png" + + # if it's a prefixed asset, it's should return `None` again + assets: ActivityAssets = {"large_image": "unknown:1234", "small_image": "unknown:5678"} + activity.assets = assets + assert activity.large_image_url is None + assert activity.small_image_url is None diff --git a/tests/test_events.py b/tests/test_events.py index bba4682f42..a25f7dd771 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -9,9 +9,8 @@ from disnake import Event from disnake.ext import commands -# n.b. the specific choice of events used in this file is irrelevant - +# n.b. the specific choice of events used in this file is irrelevant @pytest.fixture def client(): return disnake.Client() @@ -25,26 +24,31 @@ def bot(): ) +@pytest.fixture(params=["client", "bot"]) +def client_or_bot(request): + return request.getfixturevalue(request.param) + + # @Client.event -def test_client_event(client: disnake.Client) -> None: - assert not hasattr(client, "on_message_edit") +def test_event(client_or_bot: disnake.Client) -> None: + assert not hasattr(client_or_bot, "on_message_edit") - @client.event + @client_or_bot.event async def on_message_edit(self, *args: Any) -> None: ... - assert client.on_message_edit is on_message_edit # type: ignore + assert client_or_bot.on_message_edit is on_message_edit # type: ignore -# Bot.wait_for +# Client.wait_for @pytest.mark.parametrize("event", ["thread_create", Event.thread_create]) -def test_wait_for(bot: commands.Bot, event) -> None: - coro = bot.wait_for(event) - assert len(bot._listeners["thread_create"]) == 1 +def test_wait_for(client_or_bot: disnake.Client, event) -> None: + coro = client_or_bot.wait_for(event) + assert len(client_or_bot._listeners["thread_create"]) == 1 coro.close() # close coroutine to avoid warning @@ -72,55 +76,51 @@ def _test_typing_wait_for(client: disnake.Client, bot: commands.Bot) -> None: ) -# Bot.add_listener / Bot.remove_listener +# Client.add_listener / Client.remove_listener @pytest.mark.parametrize("event", ["on_guild_remove", Event.guild_remove]) -def test_addremove_listener(bot: commands.Bot, event) -> None: +def test_addremove_listener(client_or_bot: disnake.Client, event) -> None: async def callback(self, *args: Any) -> None: ... - bot.add_listener(callback, event) - assert len(bot.extra_events["on_guild_remove"]) == 1 + client_or_bot.add_listener(callback, event) + assert len(client_or_bot.extra_events["on_guild_remove"]) == 1 + client_or_bot.remove_listener(callback, event) + assert len(client_or_bot.extra_events["on_guild_remove"]) == 0 - bot.remove_listener(callback, event) - assert len(bot.extra_events["on_guild_remove"]) == 0 - -def test_addremove_listener__implicit(bot: commands.Bot) -> None: +def test_addremove_listener__implicit(client_or_bot: disnake.Client) -> None: async def on_guild_remove(self, *args: Any) -> None: ... - bot.add_listener(on_guild_remove) - assert len(bot.extra_events["on_guild_remove"]) == 1 - - bot.remove_listener(on_guild_remove) - assert len(bot.extra_events["on_guild_remove"]) == 0 + client_or_bot.add_listener(on_guild_remove) + assert len(client_or_bot.extra_events["on_guild_remove"]) == 1 + client_or_bot.remove_listener(on_guild_remove) + assert len(client_or_bot.extra_events["on_guild_remove"]) == 0 -# @Bot.listen +# @Client.listen @pytest.mark.parametrize("event", ["on_guild_role_create", Event.guild_role_create]) -def test_listen(bot: commands.Bot, event) -> None: - @bot.listen(event) +def test_listen(client_or_bot: disnake.Client, event) -> None: + @client_or_bot.listen(event) async def callback(self, *args: Any) -> None: ... - assert len(bot.extra_events["on_guild_role_create"]) == 1 + assert len(client_or_bot.extra_events["on_guild_role_create"]) == 1 -def test_listen__implicit(bot: commands.Bot) -> None: - @bot.listen() +def test_listen__implicit(client_or_bot: disnake.Client) -> None: + @client_or_bot.listen() async def on_guild_role_create(self, *args: Any) -> None: ... - assert len(bot.extra_events["on_guild_role_create"]) == 1 + assert len(client_or_bot.extra_events["on_guild_role_create"]) == 1 # @commands.Cog.listener - - @pytest.mark.parametrize("event", ["on_automod_rule_update", Event.automod_rule_update]) def test_listener(bot: commands.Bot, event) -> None: class Cog(commands.Cog):