diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md deleted file mode 100644 index 4f0af8ed63..0000000000 --- a/.github/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,120 +0,0 @@ -# Contributor Covenant Code of Conduct - -## Our Pledge - -We as members, contributors, and leaders pledge to make participation in our community a -harassment-free experience for everyone, regardless of age, body size, visible or -invisible disability, ethnicity, sex characteristics, gender identity and expression, -level of experience, education, socio-economic status, nationality, personal appearance, -race, religion, or sexual identity and orientation. - -We pledge to act and interact in ways that contribute to an open, welcoming, diverse, -inclusive, and healthy community. - -## Our Standards - -Examples of behavior that contributes to a positive environment for our community -include: - -- Demonstrating empathy and kindness toward other people -- Being respectful of differing opinions, viewpoints, and experiences -- Giving and gracefully accepting constructive feedback -- Accepting responsibility and apologizing to those affected by our mistakes, and - learning from the experience -- Focusing on what is best not just for us as individuals, but for the overall community - -Examples of unacceptable behavior include: - -- The use of sexualized language or imagery, and sexual attention or advances of any - kind -- Trolling, insulting or derogatory comments, and personal or political attacks -- Public or private harassment -- Publishing others' private information, such as a physical or email address, without - their explicit permission -- Other conduct which could reasonably be considered inappropriate in a professional - setting - -## Enforcement Responsibilities - -Community leaders are responsible for clarifying and enforcing our standards of -acceptable behavior and will take appropriate and fair corrective action in response to -any behavior that they deem inappropriate, threatening, offensive, or harmful. - -Community leaders have the right and responsibility to remove, edit, or reject comments, -commits, code, wiki edits, issues, and other contributions that are not aligned to this -Code of Conduct, and will communicate reasons for moderation decisions when appropriate. - -## Scope - -This Code of Conduct applies within all community spaces, and also applies when an -individual is officially representing the community in public spaces. Examples of -representing our community include using an official e-mail address, posting via an -official social media account, or acting as an appointed representative at an online or -offline event. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to -the community leaders responsible for enforcement at Our Discord server. All complaints -will be reviewed and investigated promptly and fairly. - -All community leaders are obligated to respect the privacy and security of the reporter -of any incident. - -## Enforcement Guidelines - -Community leaders will follow these Community Impact Guidelines in determining the -consequences for any action they deem in violation of this Code of Conduct: - -### 1. Correction - -**Community Impact**: Use of inappropriate language or other behavior deemed -unprofessional or unwelcome in the community. - -**Consequence**: A private, written warning from community leaders, providing clarity -around the nature of the violation and an explanation of why the behavior was -inappropriate. A public apology may be requested. - -### 2. Warning - -**Community Impact**: A violation through a single incident or series of actions. - -**Consequence**: A warning with consequences for continued behavior. No interaction with -the people involved, including unsolicited interaction with those enforcing the Code of -Conduct, for a specified period of time. This includes avoiding interactions in -community spaces as well as external channels like social media. Violating these terms -may lead to a temporary or permanent ban. - -### 3. Temporary Ban - -**Community Impact**: A serious violation of community standards, including sustained -inappropriate behavior. - -**Consequence**: A temporary ban from any sort of interaction or public communication -with the community for a specified period of time. No public or private interaction with -the people involved, including unsolicited interaction with those enforcing the Code of -Conduct, is allowed during this period. Violating these terms may lead to a permanent -ban. - -### 4. Permanent Ban - -**Community Impact**: Demonstrating a pattern of violation of community standards, -including sustained inappropriate behavior, harassment of an individual, or aggression -toward or disparagement of classes of individuals. - -**Consequence**: A permanent ban from any sort of public interaction within the -community. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, -available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. - -Community Impact Guidelines were inspired by -[Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). - -[homepage]: https://www.contributor-covenant.org - -For answers to common questions about this code of conduct, see the FAQ at -https://www.contributor-covenant.org/faq. Translations are available at -https://www.contributor-covenant.org/translations. diff --git a/.github/DEVELOPER_CERTIFICATE_OF_ORIGIN.md b/.github/DEVELOPER_CERTIFICATE_OF_ORIGIN.md deleted file mode 100644 index e003b3bd6d..0000000000 --- a/.github/DEVELOPER_CERTIFICATE_OF_ORIGIN.md +++ /dev/null @@ -1,37 +0,0 @@ -# Developer Certificate of Origin (DCO) - -``` -Version 1.1 - -Copyright (C) 2004, 2006 The Linux Foundation and its contributors. - -Everyone is permitted to copy and distribute verbatim copies of this -license document, but changing it is not allowed. - - -Developer's Certificate of Origin 1.1 - -By making a contribution to this project, I certify that: - -(a) The contribution was created in whole or in part by me and I - have the right to submit it under the open source license - indicated in the file; or - -(b) The contribution is based upon previous work that, to the best - of my knowledge, is covered under an appropriate open source - license and I have the right under that license to submit that - work with modifications, whether created in whole or in part - by me, under the same open source license (unless I am - permitted to submit under a different license), as indicated - in the file; or - -(c) The contribution was provided directly to me by some other - person who certified (a), (b) or (c) and I have not modified - it. - -(d) I understand and agree that this project and the contribution - are public and that a record of the contribution (including all - personal information I submit with it, including my sign-off) is - maintained indefinitely and may be redistributed consistent with - this project or the open source license(s) involved. -``` diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index e171b7955b..0000000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1 +0,0 @@ -patreon: pycord diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index d6ae4ca6aa..0000000000 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,23 +0,0 @@ -## Summary - - - -## Information - - - -- [ ] This PR fixes an issue. -- [ ] This PR adds something new (e.g. new method or parameters). -- [ ] This PR is a breaking change (e.g. methods or parameters removed/renamed). -- [ ] This PR is **not** a code change (e.g. documentation, README, typehinting, - examples, ...). - -## Checklist - - - -- [ ] I have searched the open pull requests for duplicates. -- [ ] If code changes were made then they have been tested. - - [ ] I have updated the documentation to reflect the changes. -- [ ] If `type: ignore` comments were used, a comment is also left explaining why. -- [ ] I have updated the changelog to include these changes. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0fe3702d7d..9ad535184a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -45,20 +45,6 @@ jobs: flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. flake8 . --count --exit-zero --max-complexity=10 --max-line-length=120 --statistics - - name: Run code coverage with pytest - run: | - coverage run -m pytest - coverage xml - - name: Upload code coverage to codecov.io - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - env_vars: OS,PYTHON - files: coverage.xml - flags: ${{ matrix.os }}-${{ matrix.python-version }} - name: codecov-umbrella - fail_ci_if_error: true - verbose: true docs: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/todo.yml b/.github/workflows/todo.yml index 15d85e18b3..fec76a1f57 100644 --- a/.github/workflows/todo.yml +++ b/.github/workflows/todo.yml @@ -6,7 +6,7 @@ jobs: steps: - uses: actions/checkout@v3 - name: Run tdg-github-action - uses: ribtoks/tdg-github-action@v0.4.6-beta + uses: ribtoks/tdg-github-action@v0.4.7-beta with: TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} diff --git a/.github/workflows/version-updates.yml b/.github/workflows/version-updates.yml new file mode 100644 index 0000000000..f98f1f34ed --- /dev/null +++ b/.github/workflows/version-updates.yml @@ -0,0 +1,20 @@ +name: Version Update Auto Merge +on: pull_request + +permissions: + contents: write + pull-requests: write + +jobs: + auto-merge: + runs-on: ubuntu-latest + if: ${{ github.actor == 'dependabot[bot]' }} + steps: + - run: gh pr review --approve "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} + - run: gh pr merge --auto --squash "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4f51e49c19..43519b50cd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ repos: - id: trailing-whitespace - id: end-of-file-fixer - repo: https://github.com/PyCQA/autoflake - rev: v2.0.2 + rev: v2.2.0 hooks: - id: autoflake # args: @@ -19,7 +19,7 @@ repos: # - --remove-duplicate-keys # - --remove-unused-variables - repo: https://github.com/asottile/pyupgrade - rev: v3.3.1 + rev: v3.9.0 hooks: - id: pyupgrade args: [--py38-plus] @@ -28,7 +28,7 @@ repos: hooks: - id: isort - repo: https://github.com/psf/black - rev: 23.3.0 + rev: 23.7.0 hooks: - id: black args: [--safe, --quiet] @@ -77,7 +77,7 @@ repos: # - id: mypy - repo: https://github.com/pre-commit/mirrors-prettier - rev: v3.0.0-alpha.6 + rev: v3.0.0 hooks: - id: prettier args: [--prose-wrap=always, --print-width=88] diff --git a/.readthedocs.yml b/.readthedocs.yml index b12d1ab002..59842ef8bb 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -2,7 +2,9 @@ version: 2 formats: [] build: - image: latest + os: ubuntu-22.04 + tools: + python: "3.8" sphinx: configuration: docs/conf.py @@ -10,7 +12,6 @@ sphinx: builder: html python: - version: "3.8" install: - method: pip path: . diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e65373326..45f8d1e2fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ These changes are available on the `master` branch, but have not yet been releas - Added possibility to start bot via async context manager. ([#1801](https://github.com/Pycord-Development/pycord/pull/1801)) +- Change default for all `name_localizations` & `description_localizations` attributes + from being `None` to be `MISSING`. + ([#1866](https://github.com/Pycord-Development/pycord/pull/1866)) - Added new parameters (`author`, `footer`, `image`, `thumbnail`) to `discord.Embed`. ([#1996](https://github.com/Pycord-Development/pycord/pull/1996)) - Added new events `on_bridge_command`, `on_bridge_command_completion`, and @@ -29,21 +32,84 @@ These changes are available on the `master` branch, but have not yet been releas ([#1983](https://github.com/Pycord-Development/pycord/pull/1983)) - Added new `application_auto_moderation_rule_create_badge` to `ApplicationFlags`. ([#1992](https://github.com/Pycord-Development/pycord/pull/1992)) +- Added `sync_start` argument to `VoiceClient.start_recording()`. This adds silence to + the start of audio recordings. + ([#1984](https://github.com/Pycord-Development/pycord/pull/1984)) +- Added `custom_message` to AutoModActionMetadata. + ([#2029](https://github.com/Pycord-Development/pycord/pull/2029)) +- Added support for + [voice messages](https://github.com/discord/discord-api-docs/pull/6082). + ([#2016](https://github.com/Pycord-Development/pycord/pull/2016)) +- Added the `data` attribute to all + [Raw Event payloads](https://docs.pycord.dev/en/master/api/models.html#events). + ([#2023](https://github.com/Pycord-Development/pycord/pull/2023)) +- Added and documented missing `AuditLogAction` enums. + ([#2030](https://github.com/Pycord-Development/pycord/pull/2030), + [#2171](https://github.com/Pycord-Development/pycord/pull/2171)) +- `AuditLogDiff` now supports AutoMod related models. + ([#2030](https://github.com/Pycord-Development/pycord/pull/2030)) +- Added `Interaction.respond` and `Interaction.edit` as shortcut responses. + ([#2026](https://github.com/Pycord-Development/pycord/pull/2026)) +- Added `view.parent` which is set when the view was sent by + `interaction.response.send_message`. + ([#2036](https://github.com/Pycord-Development/pycord/pull/2036)) +- Added functions (`bridge.Bot.walk_bridge_commands` & + `BridgeCommandGroup.walk_commands`) to cycle through all bridge commands and their + children/subcommands. + ([#1867](https://github.com/Pycord-Development/pycord/pull/1867)) +- Added support for usernames and modified multiple methods accordingly. + ([#2042](https://github.com/Pycord-Development/pycord/pull/2042)) +- Added `icon` and `unicode_emoji` to `Guild.create_role`. + ([#2086](https://github.com/Pycord-Development/pycord/pull/2086)) +- Added `cooldown` and `max_concurrency` to `SlashCommandGroup`. + ([#2091](https://github.com/Pycord-Development/pycord/pull/2091)) +- Added new embedded activities, Gartic Phone and Jamspace. + ([#2102](https://github.com/Pycord-Development/pycord/pull/2102)) +- Added `bridge.Context` as a shortcut to `Union` of subclasses. + ([#2106](https://github.com/Pycord-Development/pycord/pull/2106)) +- Added Annotated forms support for typehinting slash command options. + ([#2124](https://github.com/Pycord-Development/pycord/pull/2124)) +- Added `suppress` and `allowed_mentions` parameters to `Webhook` and + `InteractionResponse` edit methods. + ([#2138](https://github.com/Pycord-Development/pycord/pull/2138)) +- Added `wait_finish` parameter to `VoiceClient.play` for awaiting the end of a play. + ([#2194](https://github.com/Pycord-Development/pycord/pull/2194)) +- Added support for custom bot status. + ([#2206](https://github.com/Pycord-Development/pycord/pull/2206)) ### Changed +- Suppressed FFMPEG output when recording voice channels. + ([#1993](https://github.com/Pycord-Development/pycord/pull/1993)) - Changed file-upload size limit from 8 MB to 25 MB accordingly. ([#2014](https://github.com/Pycord-Development/pycord/pull/2014)) +- `Interaction.channel` is received from the gateway, so it can now be `DMChannel` and + `GroupChannel`. ([#2025](https://github.com/Pycord-Development/pycord/pull/2025)) +- `DMChannel.recipients` can now be `None`. + ([#2025](https://github.com/Pycord-Development/pycord/pull/2025)) +- Store `view.message` on receiving Interaction for a component. + ([#2036](https://github.com/Pycord-Development/pycord/pull/2036)) +- Attributes shared between ext and slash commands are now dynamically fetched on bridge + commands. ([#1867](https://github.com/Pycord-Development/pycord/pull/1867)) +- Embed attribues like author, footer, etc now return `None` when not set, and return + their respective classes when set. + ([#2063](https://github.com/Pycord-Development/pycord/pull/2063)) +- `default_avatar` behavior changes depending on the user's username migration status. + ([#2087](https://github.com/Pycord-Development/pycord/pull/2087)) +- Typehinted `command_prefix` and `help_command` arguments properly. + ([#2099](https://github.com/Pycord-Development/pycord/pull/2099)) +- Replace `orjson` support with `msgspec` support. + ([#2170](https://github.com/Pycord-Development/pycord/pull/2170)) ### Removed - Removed `@client.once()` in favour of `@client.listen(once=True)`. ([#1957](https://github.com/Pycord-Development/pycord/pull/1957)) - -### Changed - -- Suppressed FFMPEG output when recording voice channels. - ([#1993](https://github.com/Pycord-Development/pycord/pull/1993)) +- Removed `view.message` being set when the view was sent by + `interaction.response.send_message`. + ([#2036](https://github.com/Pycord-Development/pycord/pull/2036)) +- Removed `Embed.Empty` in favour of `None`, and `EmbedProxy` in favour of individual + classes. ([#2063](https://github.com/Pycord-Development/pycord/pull/2063)) ### Fixed @@ -63,6 +129,43 @@ These changes are available on the `master` branch, but have not yet been releas working. ([#1999](https://github.com/Pycord-Development/pycord/pull/1999)) - Fixed `TypeError` being raised when passing `name` argument to bridge groups. ([#2000](https://github.com/Pycord-Development/pycord/pull/2000)) +- Fixed `TypeError` in AutoModRule. + ([#2029](https://github.com/Pycord-Development/pycord/pull/2029)) +- Reflecting the api for gettings bans correctly. + ([#1922](https://github.com/Pycord-Development/pycord/pull/1922)) +- Restored functionality for overriding default `on_application_command_error` via + listeners. ([#2044](https://github.com/Pycord-Development/pycord/pull/2044)) +- Fixed unloading of cogs having bridge commands. + ([#2048](https://github.com/Pycord-Development/pycord/pull/2048)) +- Fixed the Slash command syncronization method `indiviual`. + ([#1925](https://github.com/Pycord-Development/pycord/pull/1925)) +- Fixed an issue that occurred when `webhooks_update` event payload channel ID was + `None`. ([#2078](https://github.com/Pycord-Development/pycord/pull/2078)) +- Fixed major TypeError when an AuditLogEntry has no user. + ([#2079](https://github.com/Pycord-Development/pycord/pull/2079)) +- Fixed `HTTPException` when trying to create a forum thread with files. + ([#2075](https://github.com/Pycord-Development/pycord/pull/2075)) +- Fixed `before_invoke` not being run for `SlashCommandGroup`. + ([#2091](https://github.com/Pycord-Development/pycord/pull/2091)) +- Fixed `AttributeError` when accessing a `Select`'s values when it hasn't been + interacted with. ([#2104](https://github.com/Pycord-Development/pycord/pull/2104)) +- Fixed `before_invoke` being run twice for slash subcommands. + ([#2139](https://github.com/Pycord-Development/pycord/pull/2139)) +- Fixed `Guild._member_count` sometimes not being set. + ([#2145](https://github.com/Pycord-Development/pycord/pull/2145)) +- Fixed `Thread.applied_tags` not being updated. + ([#2146](https://github.com/Pycord-Development/pycord/pull/2146)) +- Fixed type-hinting of `author` property of `ApplicationContext` to include typehinting + of `User` or `Member`. + ([#2148](https://github.com/Pycord-Development/pycord/pull/2148)) +- Fixed missing `delete_after` parameter in overload type-hinting for `send` method in + `Webhook` class. ([#2156](https://github.com/Pycord-Development/pycord/pull/2156)) +- Fixed `ScheduledEvent.creator_id` returning `str` instead of `int`. + ([#2162](https://github.com/Pycord-Development/pycord/pull/2162)) +- Fixed initial message inside of the create thread payload sending legacy beta payload. + ([#2191](https://github.com/Pycord-Development/pycord/pull/2191)) +- Fixed a misplaced payload object inside of the thread creation payload. + ([#2192](https://github.com/Pycord-Development/pycord/pull/2192)) ## [2.4.1] - 2023-03-20 diff --git a/.github/CONTRIBUTING.md b/CONTRIBUTING.md similarity index 100% rename from .github/CONTRIBUTING.md rename to CONTRIBUTING.md diff --git a/README.rst b/README.rst index 72eea5a0ef..278ee90ab5 100644 --- a/README.rst +++ b/README.rst @@ -87,7 +87,7 @@ Optional Packages * `PyNaCl `__ (for voice support) * `aiodns `__, `brotlipy `__, `cchardet `__ (for aiohttp speedup) -* `orjson `__ (for json speedup) +* `msgspec `__ (for json speedup) Please note that while installing voice support on Linux, you must install the following packages via your preferred package manager (e.g. ``apt``, ``dnf``, etc) BEFORE running the above commands: diff --git a/discord/abc.py b/discord/abc.py index 5aa94840eb..71a307ab87 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -215,6 +215,14 @@ class User(Snowflake, Protocol): The user's username. discriminator: :class:`str` The user's discriminator. + + .. note:: + + If the user has migrated to the new username system, this will always be "0". + global_name: :class:`str` + The user's global name. + + .. versionadded:: 2.5 avatar: :class:`~discord.Asset` The avatar asset the user has. bot: :class:`bool` @@ -225,6 +233,7 @@ class User(Snowflake, Protocol): name: str discriminator: str + global_name: str | None avatar: Asset bot: bool diff --git a/discord/activity.py b/discord/activity.py index 52e7df08e2..96b20255a8 100644 --- a/discord/activity.py +++ b/discord/activity.py @@ -160,7 +160,7 @@ class Activity(BaseActivity): type: :class:`ActivityType` The type of activity currently being done. state: Optional[:class:`str`] - The user's current state. For example, "In Game". + The user's current party status or text used for a custom status. details: Optional[:class:`str`] The detail of the user's current activity. timestamps: Dict[:class:`str`, :class:`int`] @@ -229,7 +229,6 @@ def __init__(self, **kwargs): self.assets: ActivityAssets = kwargs.pop("assets", {}) self.party: ActivityParty = kwargs.pop("party", {}) self.application_id: int | None = _get_as_snowflake(kwargs, "application_id") - self.name: str | None = kwargs.pop("name", None) self.url: str | None = kwargs.pop("url", None) self.flags: int = kwargs.pop("flags", 0) self.sync_id: str | None = kwargs.pop("sync_id", None) @@ -242,6 +241,9 @@ def __init__(self, **kwargs): if isinstance(activity_type, ActivityType) else try_enum(ActivityType, activity_type) ) + self.name: str | None = kwargs.pop( + "name", "Custom Status" if self.type == ActivityType.custom else None + ) emoji = kwargs.pop("emoji", None) self.emoji: PartialEmoji | None = ( @@ -252,6 +254,7 @@ def __repr__(self) -> str: attrs = ( ("type", self.type), ("name", self.name), + ("state", self.state), ("url", self.url), ("details", self.details), ("application_id", self.application_id), @@ -432,9 +435,6 @@ def to_dict(self) -> dict[str, Any]: def __eq__(self, other: Any) -> bool: return isinstance(other, Game) and other.name == self.name - def __ne__(self, other: Any) -> bool: - return not self.__eq__(other) - def __hash__(self) -> int: return hash(self.name) @@ -542,9 +542,6 @@ def __eq__(self, other: Any) -> bool: and other.url == self.url ) - def __ne__(self, other: Any) -> bool: - return not self.__eq__(other) - def __hash__(self) -> int: return hash(self.name) @@ -654,9 +651,6 @@ def __eq__(self, other: Any) -> bool: and other.start == self.start ) - def __ne__(self, other: Any) -> bool: - return not self.__eq__(other) - def __hash__(self) -> int: return hash(self._session_id) @@ -769,6 +763,8 @@ class CustomActivity(BaseActivity): The custom activity's name. emoji: Optional[:class:`PartialEmoji`] The emoji to pass to the activity, if any. + state: Optional[:class:`str`] + The text used for the custom activity. """ __slots__ = ("name", "emoji", "state") @@ -778,7 +774,7 @@ def __init__( ): super().__init__(**extra) self.name: str | None = name - self.state: str | None = extra.pop("state", None) + self.state: str | None = extra.pop("state", name) if self.name == "Custom Status": self.name = self.state @@ -829,9 +825,6 @@ def __eq__(self, other: Any) -> bool: and other.emoji == self.emoji ) - def __ne__(self, other: Any) -> bool: - return not self.__eq__(other) - def __hash__(self) -> int: return hash((self.name, str(self.emoji))) diff --git a/discord/audit_logs.py b/discord/audit_logs.py index c427534ea7..27a4f740b2 100644 --- a/discord/audit_logs.py +++ b/discord/audit_logs.py @@ -29,6 +29,7 @@ from . import enums, utils from .asset import Asset +from .automod import AutoModAction, AutoModTriggerMetadata from .colour import Colour from .invite import Invite from .mixins import Hashable @@ -57,6 +58,8 @@ from .threads import Thread from .types.audit_log import AuditLogChange as AuditLogChangePayload from .types.audit_log import AuditLogEntry as AuditLogEntryPayload + from .types.automod import AutoModAction as AutoModActionPayload + from .types.automod import AutoModTriggerMetadata as AutoModTriggerMetadataPayload from .types.channel import PermissionOverwrite as PermissionOverwritePayload from .types.role import Role as RolePayload from .types.snowflake import Snowflake @@ -83,6 +86,22 @@ def _transform_channel( return entry.guild.get_channel(int(data)) or Object(id=data) +def _transform_channels( + entry: AuditLogEntry, data: list[Snowflake] | None +) -> list[abc.GuildChannel | Object] | None: + if data is None: + return None + return [_transform_channel(entry, channel) for channel in data] + + +def _transform_roles( + entry: AuditLogEntry, data: list[Snowflake] | None +) -> list[Role | Object] | None: + if data is None: + return None + return [entry.guild.get_role(int(r)) or Object(id=r) for r in data] + + def _transform_member_id( entry: AuditLogEntry, data: Snowflake | None ) -> Member | User | None: @@ -172,6 +191,24 @@ def _transform_type( return enums.try_enum(enums.ChannelType, data) +def _transform_actions( + entry: AuditLogEntry, data: list[AutoModActionPayload] | None +) -> AutoModAction | None: + if data is None: + return None + else: + return [AutoModAction.from_dict(d) for d in data] + + +def _transform_trigger_metadata( + entry: AuditLogEntry, data: list[AutoModActionPayload] | None +) -> AutoModAction | None: + if data is None: + return None + else: + return AutoModTriggerMetadata.from_dict(data) + + class AuditLogDiff: def __len__(self) -> int: return len(self.__dict__) @@ -240,6 +277,12 @@ class AuditLogChanges: ), "command_id": ("command_id", _transform_snowflake), "image_hash": ("cover", _transform_scheduled_event_cover), + "trigger_type": (None, _enum_transformer(enums.AutoModTriggerType)), + "event_type": (None, _enum_transformer(enums.AutoModEventType)), + "actions": (None, _transform_actions), + "trigger_metadata": (None, _transform_trigger_metadata), + "exempt_roles": (None, _transform_roles), + "exempt_channels": (None, _transform_channels), } def __init__( @@ -255,13 +298,31 @@ def __init__( for elem in sorted(data, key=lambda i: i["key"]): attr = elem["key"] - # special cases for role add/remove + # special cases for role/trigger_metadata add/remove if attr == "$add": self._handle_role(self.before, self.after, entry, elem["new_value"]) # type: ignore continue elif attr == "$remove": self._handle_role(self.after, self.before, entry, elem["new_value"]) # type: ignore continue + elif attr in [ + "$add_keyword_filter", + "$add_regex_patterns", + "$add_allow_list", + ]: + self._handle_trigger_metadata( + self.before, self.after, entry, elem["new_value"], attr + ) + continue + elif attr in [ + "$remove_keyword_filter", + "$remove_regex_patterns", + "$remove_allow_list", + ]: + self._handle_trigger_metadata( + self.after, self.before, entry, elem["new_value"], attr + ) + continue try: key, transformer = self.TRANSFORMERS[attr] @@ -355,6 +416,23 @@ def _handle_role( setattr(second, "roles", data) + def _handle_trigger_metadata( + self, + first: AuditLogDiff, + second: AuditLogDiff, + entry: AuditLogEntry, + elem: list[AutoModTriggerMetadataPayload], + attr: str, + ) -> None: + if not hasattr(first, "trigger_metadata"): + setattr(first, "trigger_metadata", None) + + key = attr.split("_", 1)[-1] + data = {key: elem} + tm = AutoModTriggerMetadata.from_dict(data) + + setattr(second, "trigger_metadata", tm) + class _AuditLogProxyMemberPrune: delete_member_days: int @@ -405,7 +483,7 @@ class AuditLogEntry(Hashable): ----------- action: :class:`AuditLogAction` The action that was done. - user: :class:`abc.User` + user: Optional[:class:`abc.User`] The user who initiated this action. Usually a :class:`Member`\, unless gone then it's a :class:`User`. id: :class:`int` diff --git a/discord/automod.py b/discord/automod.py index 5f091d0f65..c4b1e23c0e 100644 --- a/discord/automod.py +++ b/discord/automod.py @@ -76,6 +76,10 @@ class AutoModActionMetadata: timeout_duration: :class:`datetime.timedelta` How long the member that triggered the action should be timed out for. Only for actions of type :attr:`AutoModActionType.timeout`. + custom_message: :class:`str` + An additional message shown to members when their message is blocked. + Maximum 150 characters. + Only for actions of type :attr:`AutoModActionType.block_message`. """ # maybe add a table of action types and attributes? @@ -83,13 +87,18 @@ class AutoModActionMetadata: __slots__ = ( "channel_id", "timeout_duration", + "custom_message", ) def __init__( - self, channel_id: int = MISSING, timeout_duration: timedelta = MISSING + self, + channel_id: int = MISSING, + timeout_duration: timedelta = MISSING, + custom_message: str = MISSING, ): self.channel_id: int = channel_id self.timeout_duration: timedelta = timeout_duration + self.custom_message: str = custom_message def to_dict(self) -> dict: data = {} @@ -100,6 +109,9 @@ def to_dict(self) -> dict: if self.timeout_duration is not MISSING: data["duration_seconds"] = self.timeout_duration.total_seconds() + if self.custom_message is not MISSING: + data["custom_message"] = self.custom_message + return data @classmethod @@ -113,12 +125,16 @@ def from_dict(cls, data: AutoModActionMetadataPayload): # might need an explicit int cast kwargs["timeout_duration"] = timedelta(seconds=duration_seconds) + if (custom_message := data.get("custom_message")) is not None: + kwargs["custom_message"] = custom_message + return cls(**kwargs) def __repr__(self) -> str: repr_attrs = ( "channel_id", "timeout_duration", + "custom_message", ) inner = [] @@ -301,7 +317,7 @@ def __repr__(self) -> str: inner.append(f"{attr}={value}") inner = " ".join(inner) - return f"" + return f"" class AutoModRule(Hashable): @@ -352,6 +368,7 @@ class AutoModRule(Hashable): """ __slots__ = ( + "__dict__", "_state", "id", "guild_id", diff --git a/discord/bot.py b/discord/bot.py index b7bf968ea4..5587aef34b 100644 --- a/discord/bot.py +++ b/discord/bot.py @@ -501,7 +501,11 @@ def _register( ) def register( - method: Literal["bulk", "upsert", "delete", "edit"], *args, **kwargs + method: Literal["bulk", "upsert", "delete", "edit"], + *args, + cmd_name: str = None, + guild_id: int | None = None, + **kwargs, ): if kwargs.pop("_log", True): if method == "bulk": @@ -509,13 +513,12 @@ def register( f"Bulk updating commands {[c['name'] for c in args[0]]} for" f" guild {guild_id}" ) - # TODO: Find where "cmd" is defined elif method == "upsert": - _log.debug(f"Creating command {cmd['name']} for guild {guild_id}") # type: ignore + _log.debug(f"Creating command {cmd_name} for guild {guild_id}") # type: ignore elif method == "edit": - _log.debug(f"Editing command {cmd['name']} for guild {guild_id}") # type: ignore + _log.debug(f"Editing command {cmd_name} for guild {guild_id}") # type: ignore elif method == "delete": - _log.debug(f"Deleting command {cmd['name']} for guild {guild_id}") # type: ignore + _log.debug(f"Deleting command {cmd_name} for guild {guild_id}") # type: ignore return _register(method, *args, **kwargs) pending_actions = [] @@ -602,15 +605,31 @@ def register( registered = [] for cmd in filtered_no_action: if cmd["action"] == "delete": - await register("delete", cmd["command"]) + await register( + "delete", + cmd["id"], + cmd_name=cmd["command"].name, + guild_id=guild_id, + ) continue if cmd["action"] == "edit": registered.append( - await register("edit", cmd["id"], cmd["command"].to_dict()) + await register( + "edit", + cmd["id"], + cmd["command"].to_dict(), + cmd_name=cmd["command"].name, + guild_id=guild_id, + ) ) elif cmd["action"] == "upsert": registered.append( - await register("upsert", cmd["command"].to_dict()) + await register( + "upsert", + cmd["command"].to_dict(), + cmd_name=cmd["command"].name, + guild_id=guild_id, + ) ) else: raise ValueError(f"Unknown action: {cmd['action']}") @@ -628,7 +647,7 @@ def register( ) else: data = [cmd.to_dict() for cmd in pending] - registered = await register("bulk", data) + registered = await register("bulk", data, guild_id=guild_id) for i in registered: cmd = get( @@ -1177,6 +1196,8 @@ async def on_application_command_error( This only fires if you do not specify any listeners for command error. """ + if self._event_handlers.get("on_application_command_error", None): + return command = context.command if command and command.has_error_handler(): return diff --git a/discord/channel.py b/discord/channel.py index 88ba717db5..076b2c704f 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -751,7 +751,7 @@ def _repr_attrs(self) -> tuple[str, ...]: def _update(self, guild: Guild, data: TextChannelPayload) -> None: super()._update(guild, data) - async def _get_channel(self) -> "TextChannel": + async def _get_channel(self) -> TextChannel: return self def is_news(self) -> bool: @@ -1064,11 +1064,11 @@ async def edit( available_tags: list[ForumTag] = ..., require_tag: bool = ..., overwrites: Mapping[Role | Member | Snowflake, PermissionOverwrite] = ..., - ) -> "ForumChannel" | None: + ) -> ForumChannel | None: ... @overload - async def edit(self) -> "ForumChannel" | None: + async def edit(self) -> ForumChannel | None: ... async def edit(self, *, reason=None, **options): @@ -1274,26 +1274,7 @@ async def create_thread( if file is not None and files is not None: raise InvalidArgument("cannot pass both file and files parameter to send()") - if file is not None: - if not isinstance(file, File): - raise InvalidArgument("file parameter must be File") - - try: - data = await state.http.send_files( - self.id, - files=[file], - allowed_mentions=allowed_mentions, - content=message_content, - embed=embed, - embeds=embeds, - nonce=nonce, - stickers=stickers, - components=components, - ) - finally: - file.close() - - elif files is not None: + if files is not None: if len(files) > 10: raise InvalidArgument( "files parameter must be a list of up to 10 elements" @@ -1301,26 +1282,17 @@ async def create_thread( elif not all(isinstance(file, File) for file in files): raise InvalidArgument("files parameter must be a list of File") - try: - data = await state.http.send_files( - self.id, - files=files, - content=message_content, - embed=embed, - embeds=embeds, - nonce=nonce, - allowed_mentions=allowed_mentions, - stickers=stickers, - components=components, - ) - finally: - for f in files: - f.close() - else: + if file is not None: + if not isinstance(file, File): + raise InvalidArgument("file parameter must be File") + files = [file] + + try: data = await state.http.start_forum_thread( self.id, content=message_content, name=name, + files=files, embed=embed, embeds=embeds, nonce=nonce, @@ -1333,6 +1305,11 @@ async def create_thread( applied_tags=applied_tags, reason=reason, ) + finally: + if files is not None: + for f in files: + f.close() + ret = Thread(guild=self.guild, state=self._state, data=data) msg = ret.get_partial_message(data["last_message_id"]) if view: @@ -2838,7 +2815,9 @@ def __init__( self, *, me: ClientUser, state: ConnectionState, data: DMChannelPayload ): self._state: ConnectionState = state - self.recipient: User | None = state.store_user(data["recipients"][0]) + self.recipient: User | None = None + if r := data.get("recipients"): + self.recipient: state.store_user(r[0]) self.me: ClientUser = me self.id: int = int(data["id"]) diff --git a/discord/client.py b/discord/client.py index 27e94d7c2f..5a025aa660 100644 --- a/discord/client.py +++ b/discord/client.py @@ -1049,7 +1049,9 @@ def get_all_members(self) -> Generator[Member, None, None]: yield from guild.members async def get_or_fetch_user(self, id: int, /) -> User | None: - """Looks up a user in the user cache or fetches if not found. + """|coro| + + Looks up a user in the user cache or fetches if not found. Parameters ---------- diff --git a/discord/cog.py b/discord/cog.py index 6f6a573869..686575db6f 100644 --- a/discord/cog.py +++ b/discord/cog.py @@ -586,7 +586,10 @@ def _eject(self, bot) -> None: try: for command in self.__cog_commands__: - if isinstance(command, ApplicationCommand): + if hasattr(command, "add_to"): + bot.bridge_commands.remove(command) + continue + elif isinstance(command, ApplicationCommand): bot.remove_application_command(command) elif command.parent is None: bot.remove_command(command.name) diff --git a/discord/colour.py b/discord/colour.py index c202351859..4472f028bf 100644 --- a/discord/colour.py +++ b/discord/colour.py @@ -86,9 +86,6 @@ def _get_byte(self, byte: int) -> int: def __eq__(self, other: Any) -> bool: return isinstance(other, Colour) and self.value == other.value - def __ne__(self, other: Any) -> bool: - return not self.__eq__(other) - def __str__(self) -> str: return f"#{self.value:0>6x}" @@ -335,8 +332,8 @@ def nitro_pink(cls: type[CT]) -> CT: @classmethod def embed_background(cls: type[CT], theme: str = "dark") -> CT: - """A factory method that returns a :class:`Color` corresponding to the - embed colors on discord clients, with a value of: + """A factory method that returns a :class:`Colour` corresponding to the + embed colours on discord clients, with a value of: - ``0x2B2D31`` (dark) - ``0xEEEFF1`` (light) @@ -347,7 +344,7 @@ def embed_background(cls: type[CT], theme: str = "dark") -> CT: Parameters ---------- theme: :class:`str` - The theme color to apply, must be one of "dark", "light", or "amoled". + The theme colour to apply, must be one of "dark", "light", or "amoled". """ themes_cls = { "dark": 0x2B2D31, diff --git a/discord/commands/context.py b/discord/commands/context.py index f20db34e9f..576a804494 100644 --- a/discord/commands/context.py +++ b/discord/commands/context.py @@ -207,7 +207,7 @@ def user(self) -> Member | User: """ return self.interaction.user # type: ignore # command user will never be None - author = user + author: Member | User = user @property def voice_client(self) -> VoiceProtocol | None: @@ -266,26 +266,12 @@ def unselected_options(self) -> list[Option] | None: def send_modal(self) -> Callable[..., Awaitable[Interaction]]: return self.interaction.response.send_modal - async def respond(self, *args, **kwargs) -> Interaction | WebhookMessage: - """|coro| - - Sends either a response or a message using the followup webhook determined by whether the interaction - has been responded to or not. - - Returns - ------- - Union[:class:`discord.Interaction`, :class:`discord.WebhookMessage`]: - The response, its type depending on whether it's an interaction response or a followup. - """ - try: - if not self.interaction.response.is_done(): - return await self.interaction.response.send_message( - *args, **kwargs - ) # self.response - else: - return await self.followup.send(*args, **kwargs) # self.send_followup - except discord.errors.InteractionResponded: - return await self.followup.send(*args, **kwargs) + @property + @discord.utils.copy_doc(Interaction.respond) + def respond( + self, *args, **kwargs + ) -> Callable[..., Awaitable[Interaction | WebhookMessage]]: + return self.interaction.respond @property @discord.utils.copy_doc(InteractionResponse.send_message) diff --git a/discord/commands/core.py b/discord/commands/core.py index d4f7c0ff96..d53bac0d71 100644 --- a/discord/commands/core.py +++ b/discord/commands/core.py @@ -30,6 +30,7 @@ import functools import inspect import re +import sys import types from collections import OrderedDict from enum import Enum @@ -64,6 +65,11 @@ from .context import ApplicationContext, AutocompleteContext from .options import Option, OptionChoice +if sys.version_info >= (3, 11): + from typing import Annotated, get_args, get_origin +else: + from typing_extensions import Annotated, get_args, get_origin + __all__ = ( "_BaseCommand", "ApplicationCommand", @@ -84,6 +90,7 @@ from .. import Permissions from ..cog import Cog + from ..ext.commands.cooldowns import CooldownMapping, MaxConcurrency T = TypeVar("T") CogT = TypeVar("CogT", bound="Cog") @@ -294,18 +301,17 @@ async def prepare(self, ctx: ApplicationContext) -> None: f"The check functions for the command {self.name} failed" ) - if hasattr(self, "_max_concurrency"): - if self._max_concurrency is not None: - # For this application, context can be duck-typed as a Message - await self._max_concurrency.acquire(ctx) # type: ignore # ctx instead of non-existent message + if self._max_concurrency is not None: + # For this application, context can be duck-typed as a Message + await self._max_concurrency.acquire(ctx) # type: ignore # ctx instead of non-existent message - try: - self._prepare_cooldowns(ctx) - await self.call_before_hooks(ctx) - except: - if self._max_concurrency is not None: - await self._max_concurrency.release(ctx) # type: ignore # ctx instead of non-existent message - raise + try: + self._prepare_cooldowns(ctx) + await self.call_before_hooks(ctx) + except: + if self._max_concurrency is not None: + await self._max_concurrency.release(ctx) # type: ignore # ctx instead of non-existent message + raise def is_on_cooldown(self, ctx: ApplicationContext) -> bool: """Checks whether the command is currently on cooldown. @@ -647,10 +653,10 @@ class SlashCommand(ApplicationCommand): cooldown: Optional[:class:`~discord.ext.commands.Cooldown`] The cooldown applied when the command is invoked. ``None`` if the command doesn't have a cooldown. - name_localizations: Optional[Dict[:class:`str`, :class:`str`]] + name_localizations: Dict[:class:`str`, :class:`str`] The name localizations for this command. The values of this should be ``"locale": "name"``. See `here `_ for a list of valid locales. - description_localizations: Optional[Dict[:class:`str`, :class:`str`]] + description_localizations: Dict[:class:`str`, :class:`str`] The description localizations for this command. The values of this should be ``"locale": "description"``. See `here `_ for a list of valid locales. """ @@ -668,8 +674,8 @@ def __init__(self, func: Callable, *args, **kwargs) -> None: raise TypeError("Callback must be a coroutine.") self.callback = func - self.name_localizations: dict[str, str] | None = kwargs.get( - "name_localizations", None + self.name_localizations: dict[str, str] = kwargs.get( + "name_localizations", MISSING ) _validate_names(self) @@ -680,8 +686,8 @@ def __init__(self, func: Callable, *args, **kwargs) -> None: ) self.description: str = description - self.description_localizations: dict[str, str] | None = kwargs.get( - "description_localizations", None + self.description_localizations: dict[str, str] = kwargs.get( + "description_localizations", MISSING ) _validate_descriptions(self) @@ -732,6 +738,19 @@ def _parse_options(self, params, *, check_params: bool = True) -> list[Option]: if option == inspect.Parameter.empty: option = str + if self._is_typing_annotated(option): + type_hint = get_args(option)[0] + metadata = option.__metadata__ + # If multiple Options in metadata, the first will be used. + option_gen = (elem for elem in metadata if isinstance(elem, Option)) + option = next(option_gen, Option()) + # Handle Optional + if self._is_typing_optional(type_hint): + option.input_type = get_args(type_hint)[0] + option.default = None + else: + option.input_type = type_hint + if self._is_typing_union(option): if self._is_typing_optional(option): option = Option(option.__args__[0], default=None) @@ -820,6 +839,9 @@ def _is_typing_union(self, annotation): def _is_typing_optional(self, annotation): return self._is_typing_union(annotation) and type(None) in annotation.__args__ # type: ignore + def _is_typing_annotated(self, annotation): + return get_origin(annotation) is Annotated + @property def cog(self): return getattr(self, "_cog", MISSING) @@ -843,9 +865,9 @@ def to_dict(self) -> dict: "description": self.description, "options": [o.to_dict() for o in self.options], } - if self.name_localizations is not None: + if self.name_localizations is not MISSING: as_dict["name_localizations"] = self.name_localizations - if self.description_localizations is not None: + if self.description_localizations is not MISSING: as_dict["description_localizations"] = self.description_localizations if self.is_subcommand: as_dict["type"] = SlashCommandOptionType.sub_command.value @@ -1081,10 +1103,10 @@ class SlashCommandGroup(ApplicationCommand): :exc:`.ApplicationCommandError` should be used. Note that if the checks fail then :exc:`.CheckFailure` exception is raised to the :func:`.on_application_command_error` event. - name_localizations: Optional[Dict[:class:`str`, :class:`str`]] + name_localizations: Dict[:class:`str`, :class:`str`] The name localizations for this command. The values of this should be ``"locale": "name"``. See `here `_ for a list of valid locales. - description_localizations: Optional[Dict[:class:`str`, :class:`str`]] + description_localizations: Dict[:class:`str`, :class:`str`] The description localizations for this command. The values of this should be ``"locale": "description"``. See `here `_ for a list of valid locales. """ @@ -1119,6 +1141,8 @@ def __init__( description: str | None = None, guild_ids: list[int] | None = None, parent: SlashCommandGroup | None = None, + cooldown: CooldownMapping | None = None, + max_concurrency: MaxConcurrency | None = None, **kwargs, ) -> None: self.name = str(name) @@ -1146,13 +1170,40 @@ def __init__( self.guild_only: bool | None = kwargs.get("guild_only", None) self.nsfw: bool | None = kwargs.get("nsfw", None) - self.name_localizations: dict[str, str] | None = kwargs.get( - "name_localizations", None + self.name_localizations: dict[str, str] = kwargs.get( + "name_localizations", MISSING ) - self.description_localizations: dict[str, str] | None = kwargs.get( - "description_localizations", None + self.description_localizations: dict[str, str] = kwargs.get( + "description_localizations", MISSING ) + # similar to ApplicationCommand + from ..ext.commands.cooldowns import BucketType, CooldownMapping, MaxConcurrency + + # no need to getattr, since slash cmds groups cant be created using a decorator + + if cooldown is None: + buckets = CooldownMapping(cooldown, BucketType.default) + elif isinstance(cooldown, CooldownMapping): + buckets = cooldown + else: + raise TypeError( + "Cooldown must be a an instance of CooldownMapping or None." + ) + + self._buckets: CooldownMapping = buckets + + # no need to getattr, since slash cmds groups cant be created using a decorator + + if max_concurrency is not None and not isinstance( + max_concurrency, MaxConcurrency + ): + raise TypeError( + "max_concurrency must be an instance of MaxConcurrency or None" + ) + + self._max_concurrency: MaxConcurrency | None = max_concurrency + @property def module(self) -> str | None: return self.__module__ @@ -1163,9 +1214,9 @@ def to_dict(self) -> dict: "description": self.description, "options": [c.to_dict() for c in self.subcommands], } - if self.name_localizations is not None: + if self.name_localizations is not MISSING: as_dict["name_localizations"] = self.name_localizations - if self.description_localizations is not None: + if self.description_localizations is not MISSING: as_dict["description_localizations"] = self.description_localizations if self.parent is not None: @@ -1236,10 +1287,10 @@ def create_subgroup( :exc:`.ApplicationCommandError` should be used. Note that if the checks fail then :exc:`.CheckFailure` exception is raised to the :func:`.on_application_command_error` event. - name_localizations: Optional[Dict[:class:`str`, :class:`str`]] + name_localizations: Dict[:class:`str`, :class:`str`] The name localizations for this command. The values of this should be ``"locale": "name"``. See `here `_ for a list of valid locales. - description_localizations: Optional[Dict[:class:`str`, :class:`str`]] + description_localizations: Dict[:class:`str`, :class:`str`] The description localizations for this command. The values of this should be ``"locale": "description"``. See `here `_ for a list of valid locales. @@ -1317,6 +1368,28 @@ async def invoke_autocomplete_callback(self, ctx: AutocompleteContext) -> None: ctx.interaction.data = option await command.invoke_autocomplete_callback(ctx) + async def call_before_hooks(self, ctx: ApplicationContext) -> None: + # only call local hooks + cog = self.cog + if self._before_invoke is not None: + # should be cog if @commands.before_invoke is used + instance = getattr(self._before_invoke, "__self__", cog) + # __self__ only exists for methods, not functions + # however, if @command.before_invoke is used, it will be a function + if instance: + await self._before_invoke(instance, ctx) # type: ignore + else: + await self._before_invoke(ctx) # type: ignore + + async def call_after_hooks(self, ctx: ApplicationContext) -> None: + cog = self.cog + if self._after_invoke is not None: + instance = getattr(self._after_invoke, "__self__", cog) + if instance: + await self._after_invoke(instance, ctx) # type: ignore + else: + await self._after_invoke(ctx) # type: ignore + def walk_commands(self) -> Generator[SlashCommand | SlashCommandGroup, None, None]: """An iterator that recursively walks through all slash commands and groups in this group. @@ -1413,7 +1486,7 @@ class ContextMenuCommand(ApplicationCommand): cooldown: Optional[:class:`~discord.ext.commands.Cooldown`] The cooldown applied when the command is invoked. ``None`` if the command doesn't have a cooldown. - name_localizations: Optional[Dict[:class:`str`, :class:`str`]] + name_localizations: Dict[:class:`str`, :class:`str`] The name localizations for this command. The values of this should be ``"locale": "name"``. See `here `_ for a list of valid locales. """ @@ -1430,8 +1503,8 @@ def __init__(self, func: Callable, *args, **kwargs) -> None: raise TypeError("Callback must be a coroutine.") self.callback = func - self.name_localizations: dict[str, str] | None = kwargs.get( - "name_localizations", None + self.name_localizations: dict[str, str] = kwargs.get( + "name_localizations", MISSING ) # Discord API doesn't support setting descriptions for context menu commands, so it must be empty @@ -1506,7 +1579,7 @@ def to_dict(self) -> dict[str, str | int]: "default_member_permissions" ] = self.default_member_permissions.value - if self.name_localizations is not None: + if self.name_localizations: as_dict["name_localizations"] = self.name_localizations return as_dict diff --git a/discord/commands/options.py b/discord/commands/options.py index d6c04faf72..73cda77dc9 100644 --- a/discord/commands/options.py +++ b/discord/commands/options.py @@ -41,6 +41,7 @@ from ..enums import ChannelType from ..enums import Enum as DiscordEnum from ..enums import SlashCommandOptionType +from ..utils import MISSING if TYPE_CHECKING: from ..ext.commands import Converter @@ -141,9 +142,9 @@ class Option: The maximum length of the string that can be entered. Must be between 1 and 6000 (inclusive). Only applies to Options with an :attr:`input_type` of :class:`str`. autocomplete: Optional[:class:`Any`] - The autocomplete handler for the option. Accepts an iterable of :class:`str`, a callable (sync or async) + The autocomplete handler for the option. Accepts an iterable of :class:`str` or :class:`OptionChoice`, a callable (sync or async) that takes a single argument of :class:`AutocompleteContext`, or a coroutine. - Must resolve to an iterable of :class:`str`. + Must resolve to an iterable of :class:`str` or :class:`OptionChoice`. .. note:: @@ -152,10 +153,10 @@ class Option: A list of channel types that can be selected in this option. Only applies to Options with an :attr:`input_type` of :class:`discord.SlashCommandOptionType.channel`. If this argument is used, :attr:`input_type` will be ignored. - name_localizations: Optional[Dict[:class:`str`, :class:`str`]] + name_localizations: Dict[:class:`str`, :class:`str`] The name localizations for this option. The values of this should be ``"locale": "name"``. See `here `_ for a list of valid locales. - description_localizations: Optional[Dict[:class:`str`, :class:`str`]] + description_localizations: Dict[:class:`str`, :class:`str`] The description localizations for this option. The values of this should be ``"locale": "description"``. See `here `_ for a list of valid locales. @@ -323,8 +324,10 @@ def __init__( self.autocomplete = kwargs.pop("autocomplete", None) - self.name_localizations = kwargs.pop("name_localizations", None) - self.description_localizations = kwargs.pop("description_localizations", None) + self.name_localizations = kwargs.pop("name_localizations", MISSING) + self.description_localizations = kwargs.pop( + "description_localizations", MISSING + ) def to_dict(self) -> dict: as_dict = { @@ -335,9 +338,9 @@ def to_dict(self) -> dict: "choices": [c.to_dict() for c in self.choices], "autocomplete": bool(self.autocomplete), } - if self.name_localizations is not None: + if self.name_localizations is not MISSING: as_dict["name_localizations"] = self.name_localizations - if self.description_localizations is not None: + if self.description_localizations is not MISSING: as_dict["description_localizations"] = self.description_localizations if self.channel_types: as_dict["channel_types"] = [t.value for t in self.channel_types] @@ -368,7 +371,7 @@ class OptionChoice: The name of the choice. Shown in the UI when selecting an option. value: Optional[Union[:class:`str`, :class:`int`, :class:`float`]] The value of the choice. If not provided, will use the value of ``name``. - name_localizations: Optional[Dict[:class:`str`, :class:`str`]] + name_localizations: Dict[:class:`str`, :class:`str`] The name localizations for this choice. The values of this should be ``"locale": "name"``. See `here `_ for a list of valid locales. """ @@ -377,7 +380,7 @@ def __init__( self, name: str, value: str | int | float | None = None, - name_localizations: dict[str, str] | None = None, + name_localizations: dict[str, str] = MISSING, ): self.name = str(name) self.value = value if value is not None else name @@ -385,16 +388,22 @@ def __init__( def to_dict(self) -> dict[str, str | int | float]: as_dict = {"name": self.name, "value": self.value} - if self.name_localizations is not None: + if self.name_localizations is not MISSING: as_dict["name_localizations"] = self.name_localizations return as_dict def option(name, type=None, **kwargs): - """A decorator that can be used instead of typehinting :class:`Option`. + """A decorator that can be used instead of typehinting :class:`.Option`. .. versionadded:: 2.0 + + Attributes + ---------- + parameter_name: :class:`str` + The name of the target parameter this option is mapped to. + This allows you to have a separate UI ``name`` and parameter name. """ def decorator(func): diff --git a/discord/embeds.py b/discord/embeds.py index 1d6a7d11f2..81424050e2 100644 --- a/discord/embeds.py +++ b/discord/embeds.py @@ -26,7 +26,7 @@ from __future__ import annotations import datetime -from typing import TYPE_CHECKING, Any, Final, Mapping, Protocol, TypeVar, Union +from typing import TYPE_CHECKING, Any, Mapping, TypeVar from . import utils from .colour import Colour @@ -36,76 +36,19 @@ "EmbedField", "EmbedAuthor", "EmbedFooter", + "EmbedMedia", + "EmbedProvider", ) -class _EmptyEmbed: - def __bool__(self) -> bool: - return False - - def __repr__(self) -> str: - return "Embed.Empty" - - def __len__(self) -> int: - return 0 - - -EmptyEmbed: Final = _EmptyEmbed() - - -class EmbedProxy: - def __init__(self, layer: dict[str, Any]): - self.__dict__.update(layer) - - def __len__(self) -> int: - return len(self.__dict__) - - def __repr__(self) -> str: - inner = ", ".join( - (f"{k}={v!r}" for k, v in self.__dict__.items() if not k.startswith("_")) - ) - return f"{type(self).__name__}({inner})" - - def __getattr__(self, attr: str) -> _EmptyEmbed: - return EmptyEmbed - - E = TypeVar("E", bound="Embed") if TYPE_CHECKING: from discord.types.embed import Embed as EmbedData from discord.types.embed import EmbedType - T = TypeVar("T") - MaybeEmpty = Union[T, _EmptyEmbed] - - class _EmbedFooterProxy(Protocol): - text: MaybeEmpty[str] - icon_url: MaybeEmpty[str] - - class _EmbedMediaProxy(Protocol): - url: MaybeEmpty[str] - proxy_url: MaybeEmpty[str] - height: MaybeEmpty[int] - width: MaybeEmpty[int] - - class _EmbedVideoProxy(Protocol): - url: MaybeEmpty[str] - height: MaybeEmpty[int] - width: MaybeEmpty[int] - - class _EmbedProviderProxy(Protocol): - name: MaybeEmpty[str] - url: MaybeEmpty[str] - class _EmbedAuthorProxy(Protocol): - name: MaybeEmpty[str] - url: MaybeEmpty[str] - icon_url: MaybeEmpty[str] - proxy_icon_url: MaybeEmpty[str] - - -class EmbedAuthor(EmbedProxy): +class EmbedAuthor: """Represents the author on the :class:`Embed` object. .. versionadded:: 2.5 @@ -118,25 +61,50 @@ class EmbedAuthor(EmbedProxy): The URL of the hyperlink created in the author's name. icon_url: :class:`str` The URL of the author icon image. + proxy_icon_url: :class:`str` + The proxied URL of the author icon image. This can't be set directly, it is set by Discord. """ def __init__( self, name: str, - url: MaybeEmpty[str] = EmptyEmbed, - icon_url: MaybeEmpty[str] = EmptyEmbed, - proxy_icon_url: MaybeEmpty[str] = EmptyEmbed, + url: str | None = None, + icon_url: str | None = None, ) -> None: - layer = { - k: v - for k, v in locals().items() - if k in {"name", "url", "icon_url", "proxy_icon_url"} - and v is not EmptyEmbed - } - super().__init__(layer) + self.name: str = name + self.url: str | None = url + self.icon_url: str | None = icon_url + self.proxy_icon_url: str | None = None + @classmethod + def from_dict(cls, data: dict[str, str | None]) -> EmbedAuthor: + self = cls.__new__(cls) + name = data.get("name") + if not name: + raise ValueError("name field is required") + self.name = name + self.url = data.get("url") + self.icon_url = data.get("icon_url") + self.proxy_icon_url = data.get("proxy_icon_url") + return self -class EmbedFooter(EmbedProxy): + def to_dict(self) -> dict[str, str]: + d = {"name": str(self.name)} + if self.url: + d["url"] = str(self.url) + if self.icon_url: + d["icon_url"] = str(self.icon_url) + return d + + def __len__(self) -> int: + """Returns the total number of characters in the author name.""" + return len(self.name) + + def __repr__(self) -> str: + return f"" + + +class EmbedFooter: """Represents the footer on the :class:`Embed` object. .. versionadded:: 2.5 @@ -147,20 +115,113 @@ class EmbedFooter(EmbedProxy): The text inside the footer. icon_url: :class:`str` The URL of the footer icon image. + proxy_icon_url: :class:`str` + The proxied URL of the footer icon image. This can't be set directly, it is set by Discord. """ def __init__( self, text: str, - icon_url: MaybeEmpty[str] = EmptyEmbed, - proxy_icon_url: MaybeEmpty[str] = EmptyEmbed, + icon_url: str | None = None, ) -> None: - layer = { - k: v - for k, v in locals().items() - if k in {"text", "icon_url", "proxy_icon_url"} and v is not EmptyEmbed - } - super().__init__(layer) + self.text: str = text + self.icon_url: str | None = icon_url + self.proxy_icon_url: str | None = None + + @classmethod + def from_dict(cls, data: dict[str, str | None]) -> EmbedFooter: + self = cls.__new__(cls) + text = data.get("text") + if not text: + raise ValueError("text field is required") + self.text = text + self.icon_url = data.get("icon_url") + self.proxy_icon_url = data.get("proxy_icon_url") + return self + + def to_dict(self) -> dict[str, Any]: + d = {"text": str(self.text)} + if self.icon_url: + d["icon_url"] = str(self.icon_url) + return d + + def __len__(self) -> int: + """Returns the total number of characters in the footer text.""" + return len(self.text) + + def __repr__(self) -> str: + return f"" + + +class EmbedMedia: # Thumbnail, Image, Video + """Represents a media on the :class:`Embed` object. + This includes thumbnails, images, and videos. + + .. versionadded:: 2.5 + + Attributes + ---------- + url: :class:`str` + The source URL of the media. + proxy_url: :class:`str` + The proxied URL of the media. + height: :class:`int` + The height of the media. + width: :class:`int` + The width of the media. + """ + + url: str + proxy_url: str | None + height: int | None + width: int | None + + def __init__(self, url: str): + self.url = url + self.proxy_url = None + self.height = None + self.width = None + + @classmethod + def from_dict(cls, data: dict[str, str | int]) -> EmbedMedia: + self = cls.__new__(cls) + self.url = str(data.get("url")) + self.proxy_url = ( + str(proxy_url) if (proxy_url := data.get("proxy_url")) else None + ) + self.height = int(height) if (height := data.get("height")) else None + self.width = int(width) if (width := data.get("width")) else None + return self + + def __repr__(self) -> str: + return f" height={self.height!r} width={self.width!r}>" + + +class EmbedProvider: + """Represents a provider on the :class:`Embed` object. + + .. versionadded:: 2.5 + + Attributes + ---------- + name: :class:`str` + The name of the provider. + url: :class:`str` + The URL of the provider. + """ + + name: str | None + url: str | None + + @classmethod + def from_dict(cls, data: dict[str, str | None]) -> EmbedProvider: + self = cls.__new__(cls) + self.name = data.get("name") + self.url = data.get("url") + return self + + def __repr__(self) -> str: + return f"" class EmbedField: @@ -184,7 +245,7 @@ def __init__(self, name: str, value: str, inline: bool | None = False): self.inline = inline @classmethod - def from_dict(cls: type[E], data: Mapping[str, Any]) -> E: + def from_dict(cls, data: dict[str, str | bool]) -> EmbedField: """Converts a :class:`dict` to a :class:`EmbedField` provided it is in the format that Discord expects it to be in. @@ -199,7 +260,7 @@ def from_dict(cls: type[E], data: Mapping[str, Any]) -> E: data: :class:`dict` The dictionary to convert into an EmbedField object. """ - self: E = cls.__new__(cls) + self = cls.__new__(cls) self.name = data["name"] self.value = data["value"] @@ -207,7 +268,7 @@ def from_dict(cls: type[E], data: Mapping[str, Any]) -> E: return self - def to_dict(self) -> dict[str, str | bool]: + def to_dict(self) -> dict[str, str | bool | None]: """Converts this EmbedField object into a dict. Returns @@ -221,6 +282,9 @@ def to_dict(self) -> dict[str, str | bool]: "inline": self.inline, } + def __repr__(self) -> str: + return f"" + class Embed: """Represents a Discord embed. @@ -238,12 +302,6 @@ class Embed: .. versionadded:: 2.0 - Certain properties return an ``EmbedProxy``, a type - that acts similar to a regular :class:`dict` except using dotted access, - e.g. ``embed.author.icon_url``. If the attribute - is invalid or empty, then a special sentinel value is returned, - :attr:`Embed.Empty`. - For ease of use, all parameters that expect a :class:`str` are implicitly cast to :class:`str` for you. @@ -272,9 +330,6 @@ class Embed: colour: Union[:class:`Colour`, :class:`int`] The colour code of the embed. Aliased to ``color`` as well. This can be set during initialisation. - Empty - A special sentinel value used by ``EmbedProxy`` and this class - to denote that the value or attribute is empty. """ __slots__ = ( @@ -293,54 +348,46 @@ class Embed: "description", ) - Empty: Final = EmptyEmbed - def __init__( self, *, - colour: int | Colour | _EmptyEmbed = EmptyEmbed, - color: int | Colour | _EmptyEmbed = EmptyEmbed, - title: MaybeEmpty[Any] = EmptyEmbed, + colour: int | Colour | None = None, + color: int | Colour | None = None, + title: Any | None = None, type: EmbedType = "rich", - url: MaybeEmpty[Any] = EmptyEmbed, - description: MaybeEmpty[Any] = EmptyEmbed, - timestamp: datetime.datetime = None, + url: Any | None = None, + description: Any | None = None, + timestamp: datetime.datetime | None = None, fields: list[EmbedField] | None = None, - author: MaybeEmpty[EmbedAuthor] = EmptyEmbed, - footer: MaybeEmpty[EmbedFooter] = EmptyEmbed, - image: MaybeEmpty[str] = EmptyEmbed, - thumbnail: MaybeEmpty[str] = EmptyEmbed, + author: EmbedAuthor | None = None, + footer: EmbedFooter | None = None, + image: str | EmbedMedia | None = None, + thumbnail: str | EmbedMedia | None = None, ): - self.colour = colour if colour is not EmptyEmbed else color + self.colour = colour if colour else color self.title = title self.type = type self.url = url self.description = description - if self.title is not EmptyEmbed and self.title is not None: + if self.title: self.title = str(self.title) - if self.description is not EmptyEmbed and self.description is not None: + if self.description: self.description = str(self.description) - if self.url is not EmptyEmbed and self.url is not None: + if self.url: self.url = str(self.url) if timestamp: self.timestamp = timestamp - self._fields: list[EmbedField] = fields or [] - if author is not EmptyEmbed: - self.set_author(**author.__dict__) + self._fields: list[EmbedField] = fields if fields is not None else [] - if footer is not EmptyEmbed: - self.set_footer(**footer.__dict__) - - if image is not EmptyEmbed: - self.set_image(url=image) - - if thumbnail is not EmptyEmbed: - self.set_thumbnail(url=thumbnail) + self.author = author + self.footer = footer + self.image = image + self.thumbnail = thumbnail @classmethod def from_dict(cls: type[E], data: Mapping[str, Any]) -> E: @@ -368,18 +415,18 @@ def from_dict(cls: type[E], data: Mapping[str, Any]) -> E: # fill in the basic fields - self.title = data.get("title", EmptyEmbed) - self.type = data.get("type", EmptyEmbed) - self.description = data.get("description", EmptyEmbed) - self.url = data.get("url", EmptyEmbed) + self.title = data.get("title", None) + self.type = data.get("type", None) + self.description = data.get("description", None) + self.url = data.get("url", None) - if self.title is not EmptyEmbed: + if self.title: self.title = str(self.title) - if self.description is not EmptyEmbed: + if self.description: self.description = str(self.description) - if self.url is not EmptyEmbed: + if self.url: self.url = str(self.url) # try to fill in the more rich fields @@ -427,7 +474,11 @@ def copy(self: E) -> E: return self.__class__.from_dict(self.to_dict()) def __len__(self) -> int: - total = len(self.title) + len(self.description) + total = 0 + if self.title: + total += len(self.title) + if self.description: + total += len(self.description) for field in getattr(self, "_fields", []): total += len(field.name) + len(field.value) @@ -466,56 +517,71 @@ def __bool__(self) -> bool: ) @property - def colour(self) -> MaybeEmpty[Colour]: - return getattr(self, "_colour", EmptyEmbed) + def colour(self) -> Colour | None: + return getattr(self, "_colour", None) @colour.setter - def colour(self, value: int | Colour | _EmptyEmbed): # type: ignore - if isinstance(value, (Colour, _EmptyEmbed)): + def colour(self, value: int | Colour | None): # type: ignore + if value is None or isinstance(value, Colour): self._colour = value elif isinstance(value, int): self._colour = Colour(value=value) else: raise TypeError( - "Expected discord.Colour, int, or Embed.Empty but received" + "Expected discord.Colour, int, or None but received" f" {value.__class__.__name__} instead." ) color = colour @property - def timestamp(self) -> MaybeEmpty[datetime.datetime]: - return getattr(self, "_timestamp", EmptyEmbed) + def timestamp(self) -> datetime.datetime | None: + return getattr(self, "_timestamp", None) @timestamp.setter - def timestamp(self, value: MaybeEmpty[datetime.datetime]): + def timestamp(self, value: datetime.datetime | None): if isinstance(value, datetime.datetime): if value.tzinfo is None: value = value.astimezone() self._timestamp = value - elif isinstance(value, _EmptyEmbed): + elif value is None: self._timestamp = value else: raise TypeError( - "Expected datetime.datetime or Embed.Empty received" + "Expected datetime.datetime or None. Received" f" {value.__class__.__name__} instead" ) @property - def footer(self) -> EmbedFooter: - """Returns an ``EmbedProxy`` denoting the footer contents. + def footer(self) -> EmbedFooter | None: + """Returns an :class:`EmbedFooter` denoting the footer contents. See :meth:`set_footer` for possible values you can access. - If the attribute has no value then :attr:`Empty` is returned. + If the footer is not set then `None` is returned. """ - return EmbedFooter(**getattr(self, "_footer", {})) + foot = getattr(self, "_footer", None) + if not foot: + return None + return EmbedFooter.from_dict(foot) + + @footer.setter + def footer(self, value: EmbedFooter | None): + if value is None: + self.remove_footer() + elif isinstance(value, EmbedFooter): + self._footer = value.to_dict() + else: + raise TypeError( + "Expected EmbedFooter or None. Received" + f" {value.__class__.__name__} instead" + ) def set_footer( self: E, *, - text: MaybeEmpty[Any] = EmptyEmbed, - icon_url: MaybeEmpty[Any] = EmptyEmbed, + text: Any | None = None, + icon_url: Any | None = None, ) -> E: """Sets the footer for the embed content. @@ -532,10 +598,10 @@ def set_footer( """ self._footer = {} - if text is not EmptyEmbed: + if text: self._footer["text"] = str(text) - if icon_url is not EmptyEmbed: + if icon_url: self._footer["icon_url"] = str(icon_url) return self @@ -556,28 +622,45 @@ def remove_footer(self: E) -> E: return self @property - def image(self) -> _EmbedMediaProxy: - """Returns an ``EmbedProxy`` denoting the image contents. + def image(self) -> EmbedMedia | None: + """Returns an :class:`EmbedMedia` denoting the image contents. - Possible attributes you can access are: + Attributes you can access are: - ``url`` - ``proxy_url`` - ``width`` - ``height`` - If the attribute has no value then :attr:`Empty` is returned. + If the image is not set then `None` is returned. """ - return EmbedProxy(getattr(self, "_image", {})) # type: ignore + img = getattr(self, "_image", None) + if not img: + return None + return EmbedMedia.from_dict(img) + + @image.setter + def image(self, value: str | EmbedMedia | None): + if value is None: + self.remove_image() + elif isinstance(value, str): + self.set_image(url=value) + elif isinstance(value, EmbedMedia): + self.set_image(url=value.url) + else: + raise TypeError( + "Expected discord.EmbedMedia, or None but received" + f" {value.__class__.__name__} instead." + ) - def set_image(self: E, *, url: MaybeEmpty[Any]) -> E: + def set_image(self: E, *, url: Any | None) -> E: """Sets the image for the embed content. This function returns the class instance to allow for fluent-style chaining. .. versionchanged:: 1.4 - Passing :attr:`Empty` removes the image. + Passing `None` removes the image. Parameters ---------- @@ -585,7 +668,7 @@ def set_image(self: E, *, url: MaybeEmpty[Any]) -> E: The source URL for the image. Only HTTP(S) is supported. """ - if url is EmptyEmbed: + if url is None: try: del self._image except AttributeError: @@ -613,28 +696,45 @@ def remove_image(self: E) -> E: return self @property - def thumbnail(self) -> _EmbedMediaProxy: - """Returns an ``EmbedProxy`` denoting the thumbnail contents. + def thumbnail(self) -> EmbedMedia | None: + """Returns an :class:`EmbedMedia` denoting the thumbnail contents. - Possible attributes you can access are: + Attributes you can access are: - ``url`` - ``proxy_url`` - ``width`` - ``height`` - If the attribute has no value then :attr:`Empty` is returned. + If the thumbnail is not set then `None` is returned. """ - return EmbedProxy(getattr(self, "_thumbnail", {})) # type: ignore + thumb = getattr(self, "_thumbnail", None) + if not thumb: + return None + return EmbedMedia.from_dict(thumb) + + @thumbnail.setter + def thumbnail(self, value: str | EmbedMedia | None): + if value is None: + self.remove_thumbnail() + elif isinstance(value, str): + self.set_thumbnail(url=value) + elif isinstance(value, EmbedMedia): + self.set_thumbnail(url=value.url) + else: + raise TypeError( + "Expected discord.EmbedMedia, or None but received" + f" {value.__class__.__name__} instead." + ) - def set_thumbnail(self: E, *, url: MaybeEmpty[Any]) -> E: + def set_thumbnail(self: E, *, url: Any | None) -> E: """Sets the thumbnail for the embed content. This function returns the class instance to allow for fluent-style chaining. .. versionchanged:: 1.4 - Passing :attr:`Empty` removes the thumbnail. + Passing `None` removes the thumbnail. Parameters ---------- @@ -642,7 +742,7 @@ def set_thumbnail(self: E, *, url: MaybeEmpty[Any]) -> E: The source URL for the thumbnail. Only HTTP(S) is supported. """ - if url is EmptyEmbed: + if url is None: try: del self._thumbnail except AttributeError: @@ -670,45 +770,66 @@ def remove_thumbnail(self: E) -> E: return self @property - def video(self) -> _EmbedVideoProxy: - """Returns an ``EmbedProxy`` denoting the video contents. + def video(self) -> EmbedMedia | None: + """Returns an :class:`EmbedMedia` denoting the video contents. - Possible attributes include: + Attributes include: - ``url`` for the video URL. - ``height`` for the video height. - ``width`` for the video width. - If the attribute has no value then :attr:`Empty` is returned. + If the video is not set then `None` is returned. """ - return EmbedProxy(getattr(self, "_video", {})) # type: ignore + vid = getattr(self, "_video", None) + if not vid: + return None + return EmbedMedia.from_dict(vid) @property - def provider(self) -> _EmbedProviderProxy: - """Returns an ``EmbedProxy`` denoting the provider contents. + def provider(self) -> EmbedProvider | None: + """Returns an :class:`EmbedProvider` denoting the provider contents. The only attributes that might be accessed are ``name`` and ``url``. - If the attribute has no value then :attr:`Empty` is returned. + If the provider is not set then `None` is returned. """ - return EmbedProxy(getattr(self, "_provider", {})) # type: ignore + prov = getattr(self, "_provider", None) + if not prov: + return None + return EmbedProvider.from_dict(prov) @property - def author(self) -> EmbedAuthor: - """Returns an ``EmbedProxy`` denoting the author contents. + def author(self) -> EmbedAuthor | None: + """Returns an :class:`EmbedAuthor` denoting the author contents. See :meth:`set_author` for possible values you can access. - If the attribute has no value then :attr:`Empty` is returned. + If the author is not set then `None` is returned. """ - return EmbedAuthor(**getattr(self, "_author", {})) # type: ignore + auth = getattr(self, "_author", None) + if not auth: + return None + return EmbedAuthor.from_dict(auth) + + @author.setter + def author(self, value: EmbedAuthor | None): + if value is None: + self.remove_author() + elif isinstance(value, EmbedAuthor): + self._author = value.to_dict() + else: + raise TypeError( + "Expected discord.EmbedAuthor, or None but received" + f" {value.__class__.__name__} instead." + ) def set_author( self: E, *, name: Any, - url: MaybeEmpty[Any] = EmptyEmbed, - icon_url: MaybeEmpty[Any] = EmptyEmbed, + url: Any | None = None, + icon_url: Any | None = None, ) -> E: """Sets the author for the embed content. @@ -730,10 +851,10 @@ def set_author( "name": str(name), } - if url is not EmptyEmbed: + if url: self._author["url"] = str(url) - if icon_url is not EmptyEmbed: + if icon_url: self._author["icon_url"] = str(icon_url) return self diff --git a/discord/emoji.py b/discord/emoji.py index 002aa1586f..cbb51eee57 100644 --- a/discord/emoji.py +++ b/discord/emoji.py @@ -147,9 +147,6 @@ def __repr__(self) -> str: def __eq__(self, other: Any) -> bool: return isinstance(other, _EmojiTag) and self.id == other.id - def __ne__(self, other: Any) -> bool: - return not self.__eq__(other) - def __hash__(self) -> int: return self.id >> 22 diff --git a/discord/enums.py b/discord/enums.py index 75af244382..f4b44623b0 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -37,7 +37,6 @@ "VerificationLevel", "ContentFilter", "Status", - "DefaultAvatar", "AuditLogAction", "AuditLogActionCategory", "UserFlags", @@ -354,20 +353,6 @@ def __str__(self): return self.value -class DefaultAvatar(Enum): - """Default avatar""" - - blurple = 0 - grey = 1 - gray = 1 - green = 2 - orange = 3 - red = 4 - - def __str__(self): - return self.name - - class NotificationLevel(Enum, comparable=True): """Notification level""" @@ -438,6 +423,10 @@ class AuditLogAction(Enum): auto_moderation_rule_update = 141 auto_moderation_rule_delete = 142 auto_moderation_block_message = 143 + auto_moderation_flag_to_channel = 144 + auto_moderation_user_communication_disabled = 145 + creator_monetization_request_created = 150 + creator_monetization_terms_accepted = 151 @property def category(self) -> AuditLogActionCategory | None: @@ -496,6 +485,10 @@ def category(self) -> AuditLogActionCategory | None: AuditLogAction.auto_moderation_rule_update: AuditLogActionCategory.update, AuditLogAction.auto_moderation_rule_delete: AuditLogActionCategory.delete, AuditLogAction.auto_moderation_block_message: None, + AuditLogAction.auto_moderation_flag_to_channel: None, + AuditLogAction.auto_moderation_user_communication_disabled: None, + AuditLogAction.creator_monetization_request_created: None, + AuditLogAction.creator_monetization_terms_accepted: None, } return lookup[self] @@ -534,7 +527,7 @@ def target_type(self) -> str | None: return "thread" elif v < 122: return "application_command_permission" - elif v < 144: + elif v < 146: return "auto_moderation_rule" @@ -848,6 +841,8 @@ class EmbeddedActivity(Enum): doodle_crew = 878067389634314250 doodle_crew_dev = 878067427668275241 fishington = 814288819477020702 + gartic_phone = 1007373802981822582 + jamspace = 1070087967294631976 know_what_i_meme = 950505761862189096 land = 903769130790969345 letter_league = 879863686565621790 diff --git a/discord/ext/bridge/bot.py b/discord/ext/bridge/bot.py index 1d9a36f37d..e44927e735 100644 --- a/discord/ext/bridge/bot.py +++ b/discord/ext/bridge/bot.py @@ -25,6 +25,7 @@ from __future__ import annotations from abc import ABC +from collections.abc import Iterator from discord.commands import ApplicationContext from discord.errors import CheckFailure, DiscordException @@ -60,6 +61,21 @@ def bridge_commands(self) -> list[BridgeCommand | BridgeCommandGroup]: return cmds + def walk_bridge_commands( + self, + ) -> Iterator[BridgeCommand | BridgeCommandGroup]: + """An iterator that recursively walks through all the bot's bridge commands. + + Yields + ------ + Union[:class:`.BridgeCommand`, :class:`.BridgeCommandGroup`] + A bridge command or bridge group of the bot. + """ + for cmd in self._bridge_commands: + yield cmd + if isinstance(cmd, BridgeCommandGroup): + yield from cmd.walk_commands() + async def get_application_context( self, interaction: Interaction, cls=None ) -> BridgeApplicationContext: diff --git a/discord/ext/bridge/context.py b/discord/ext/bridge/context.py index 3ba5989886..033fdd6caf 100644 --- a/discord/ext/bridge/context.py +++ b/discord/ext/bridge/context.py @@ -25,7 +25,7 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Any, overload +from typing import TYPE_CHECKING, Any, Union, overload from discord.commands import ApplicationContext from discord.interactions import Interaction, InteractionMessage @@ -38,7 +38,7 @@ from .core import BridgeExtCommand, BridgeSlashCommand -__all__ = ("BridgeContext", "BridgeExtContext", "BridgeApplicationContext") +__all__ = ("BridgeContext", "BridgeExtContext", "BridgeApplicationContext", "Context") class BridgeContext(ABC): @@ -195,3 +195,10 @@ async def delete( """ if self._original_response_message: await self._original_response_message.delete(delay=delay, reason=reason) + + +Context = Union[BridgeExtContext, BridgeApplicationContext] +""" +A Union class for either :class:`BridgeExtContext` or :class:`BridgeApplicationContext`. +Can be used as a type hint for Context for bridge commands. +""" diff --git a/discord/ext/bridge/core.py b/discord/ext/bridge/core.py index 4f53389d05..427bd0269f 100644 --- a/discord/ext/bridge/core.py +++ b/discord/ext/bridge/core.py @@ -25,6 +25,7 @@ from __future__ import annotations import inspect +from collections.abc import Iterator from typing import TYPE_CHECKING, Any, Callable import discord.commands.options @@ -38,7 +39,7 @@ SlashCommandOptionType, ) -from ...utils import filter_params, find, get +from ...utils import MISSING, find, get from ..commands import BadArgument from ..commands import Bot as ExtBot from ..commands import ( @@ -156,6 +157,8 @@ class BridgeCommand: The prefix-based version of this bridge command. """ + __special_attrs__ = ["slash_variant", "ext_variant", "parent"] + def __init__(self, callback, **kwargs): self.parent = kwargs.pop("parent", None) self.slash_variant: BridgeSlashCommand = kwargs.pop( @@ -166,13 +169,10 @@ def __init__(self, callback, **kwargs): ) or BridgeExtCommand(callback, **kwargs) @property - def name_localizations(self) -> dict[str, str]: + def name_localizations(self) -> dict[str, str] | None: """Returns name_localizations from :attr:`slash_variant` - You can edit/set name_localizations directly with - .. code-block:: python3 - bridge_command.name_localizations["en-UK"] = ... # or any other locale # or bridge_command.name_localizations = {"en-UK": ..., "fr-FR": ...} @@ -184,13 +184,10 @@ def name_localizations(self, value): self.slash_variant.name_localizations = value @property - def description_localizations(self) -> dict[str, str]: + def description_localizations(self) -> dict[str, str] | None: """Returns description_localizations from :attr:`slash_variant` - You can edit/set description_localizations directly with - .. code-block:: python3 - bridge_command.description_localizations["en-UK"] = ... # or any other locale # or bridge_command.description_localizations = {"en-UK": ..., "fr-FR": ...} @@ -201,9 +198,34 @@ def description_localizations(self) -> dict[str, str]: def description_localizations(self, value): self.slash_variant.description_localizations = value - @property - def qualified_name(self) -> str: - return self.slash_variant.qualified_name + def __getattribute__(self, name): + try: + # first, look for the attribute on the bridge command + return super().__getattribute__(name) + except AttributeError as e: + # if it doesn't exist, check this list, if the name of + # the parameter is here + if name is self.__special_attrs__: + raise e + + # looks up the result in the variants. + # slash cmd prioritized + result = getattr(self.slash_variant, name, MISSING) + try: + if result is MISSING: + return getattr(self.ext_variant, name) + return result + except AttributeError: + raise AttributeError( + f"'{self.__class__.__name__}' object has no attribute '{name}'" + ) + + def __setattr__(self, name, value) -> None: + if name not in self.__special_attrs__: + setattr(self.slash_variant, name, value) + setattr(self.ext_variant, name, value) + + return super().__setattr__(name, value) def add_to(self, bot: ExtBot) -> None: """Adds the command to a bot. This method is inherited by :class:`.BridgeCommandGroup`. @@ -321,6 +343,14 @@ class BridgeCommandGroup(BridgeCommand): If :func:`map_to` is used, the mapped slash command. """ + __special_attrs__ = [ + "slash_variant", + "ext_variant", + "parent", + "subcommands", + "mapped", + ] + ext_variant: BridgeExtGroup slash_variant: BridgeSlashGroup @@ -341,6 +371,16 @@ def __init__(self, callback, *args, **kwargs): kwargs.update(map_to) self.mapped = self.slash_variant.command(**kwargs)(callback) + def walk_commands(self) -> Iterator[BridgeCommand]: + """An iterator that recursively walks through all the bridge group's subcommands. + + Yields + ------ + :class:`.BridgeCommand` + A bridge command of this bridge group. + """ + yield from self.subcommands + def command(self, *args, **kwargs): """A decorator to register a function as a subcommand. diff --git a/discord/ext/commands/bot.py b/discord/ext/commands/bot.py index 4f1753e49a..cd70a2393f 100644 --- a/discord/ext/commands/bot.py +++ b/discord/ext/commands/bot.py @@ -29,7 +29,7 @@ import collections.abc import sys import traceback -from typing import TYPE_CHECKING, Any, Callable, TypeVar +from typing import TYPE_CHECKING, Any, Callable, Coroutine, Iterable, TypeVar import discord @@ -109,28 +109,28 @@ def _is_submodule(parent: str, child: str) -> bool: return parent == child or child.startswith(f"{parent}.") -class _DefaultRepr: - def __repr__(self): - return "" - - -_default = _DefaultRepr() - - class BotBase(GroupMixin, discord.cog.CogMixin): + _help_command = None _supports_prefixed_commands = True - def __init__(self, command_prefix=when_mentioned, help_command=_default, **options): + def __init__( + self, + command_prefix: str + | Iterable[str] + | Callable[ + [Bot | AutoShardedBot, Message], + str | Iterable[str] | Coroutine[Any, Any, str | Iterable[str]], + ] = when_mentioned, + help_command: HelpCommand | None = MISSING, + **options, + ): super().__init__(**options) self.command_prefix = command_prefix - self._help_command = None + self.help_command = ( + DefaultHelpCommand() if help_command is MISSING else help_command + ) self.strip_after_prefix = options.get("strip_after_prefix", False) - if help_command is _default: - self.help_command = DefaultHelpCommand() - else: - self.help_command = help_command - @discord.utils.copy_doc(discord.Client.close) async def close(self) -> None: for extension in tuple(self.__extensions): diff --git a/discord/ext/commands/converter.py b/discord/ext/commands/converter.py index ee131f0424..7a4bbabf46 100644 --- a/discord/ext/commands/converter.py +++ b/discord/ext/commands/converter.py @@ -199,11 +199,11 @@ async def query_member_named(self, guild, argument): return discord.utils.get( members, name=username, discriminator=discriminator ) - else: - members = await guild.query_members(argument, limit=100, cache=cache) - return discord.utils.find( - lambda m: m.name == argument or m.nick == argument, members - ) + members = await guild.query_members(argument, limit=100, cache=cache) + return discord.utils.find( + lambda m: argument in (m.nick, m.name, m.global_name), + members, + ) async def query_member_by_id(self, bot, guild, user_id): ws = bot._get_websocket(shard_id=guild.shard_id) @@ -320,7 +320,7 @@ async def convert(self, ctx: Context, argument: str) -> discord.User: if result is not None: return result - predicate = lambda u: u.name == arg + predicate = lambda u: arg in (u.name, u.global_name) result = discord.utils.find(predicate, state._users.values()) if result is None: diff --git a/discord/ext/commands/flags.py b/discord/ext/commands/flags.py index d265442bc0..54e7e0c37c 100644 --- a/discord/ext/commands/flags.py +++ b/discord/ext/commands/flags.py @@ -33,6 +33,11 @@ from discord.utils import MISSING, MissingField, maybe_coroutine, resolve_annotation +if sys.version_info >= (3, 11): + _MISSING = MissingField +else: + _MISSING = MISSING + from .converter import run_converters from .errors import ( BadFlagArgument, @@ -81,13 +86,13 @@ class Flag: Whether multiple given values overrides the previous value. """ - name: str = MissingField + name: str = _MISSING aliases: list[str] = field(default_factory=list) - attribute: str = MissingField - annotation: Any = MissingField - default: Any = MissingField - max_args: int = MissingField - override: bool = MissingField + attribute: str = _MISSING + annotation: Any = _MISSING + default: Any = _MISSING + max_args: int = _MISSING + override: bool = _MISSING cast_to_dict: bool = False @property diff --git a/discord/flags.py b/discord/flags.py index ae5d74da82..308083d673 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -116,9 +116,6 @@ def _from_value(cls, value): def __eq__(self, other: Any) -> bool: return isinstance(other, self.__class__) and self.value == other.value - def __ne__(self, other: Any) -> bool: - return not self.__eq__(other) - def __hash__(self) -> int: return hash(self.value) @@ -404,6 +401,14 @@ def suppress_notifications(self): return 4096 + @flag_value + def is_voice_message(self): + """:class:`bool`: Returns ``True`` if this message is a voice message. + + .. versionadded:: 2.5 + """ + return 8192 + @fill_with_flags() class PublicUserFlags(BaseFlags): diff --git a/discord/guild.py b/discord/guild.py index a84f0d2eba..2bd77b2dd5 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -202,50 +202,8 @@ class Guild(Hashable): The guild's notification settings. features: List[:class:`str`] A list of features that the guild has. The features that a guild can have are - subject to arbitrary change by Discord. - - They are currently as follows: - - - ``ANIMATED_BANNER``: Guild can upload an animated banner. - - ``ANIMATED_ICON``: Guild can upload an animated icon. - - ``APPLICATION_COMMAND_PERMISSIONS_V2``: Guild is using the old command permissions behavior. - - ``AUTO_MODERATION``: Guild has enabled the auto moderation system. - - ``BANNER``: Guild can upload and use a banner. (i.e. :attr:`.banner`) - - ``CHANNEL_BANNER``: Guild can upload and use a channel banners. - - ``COMMERCE``: Guild can sell things using store channels, which have now been removed. - - ``COMMUNITY``: Guild is a community server. - - ``DEVELOPER_SUPPORT_SERVER``: Guild has been set as a support server on the App Directory. - - ``DISCOVERABLE``: Guild shows up in Server Discovery. - - ``FEATURABLE``: Guild can be featured in the Server Directory. - - ``HAS_DIRECTORY_ENTRY``: Unknown. - - ``HUB``: Hubs contain a directory channel that let you find school-related, student-run servers for your school or university. - - ``INTERNAL_EMPLOYEE_ONLY``: Indicates that only users with the staff badge can join the guild. - - ``INVITES_DISABLED``: Guild Invites are disabled. - - ``INVITE_SPLASH``: Guild's invite page can have a special splash. - - ``LINKED_TO_HUB``: 'Guild is linked to a hub. - - ``MEMBER_PROFILES``: Unknown. - - ``MEMBER_VERIFICATION_GATE_ENABLED``: Guild has Membership Screening enabled. - - ``MONETIZATION_ENABLED``: Guild has enabled monetization. - - ``MORE_EMOJI``: Guild has increased custom emoji slots. - - ``MORE_STICKERS``: Guild has increased custom sticker slots. - - ``NEWS``: Guild can create news channels. - - ``NEW_THREAD_PERMISSIONS``: Guild has new thread permissions. - - ``PARTNERED``: Guild is a partnered server. - - ``PREMIUM_TIER_3_OVERRIDE``: Forces the server to server boosting level 3 (specifically created by Discord Staff Member "Jethro" for their personal server). - - ``PREVIEW_ENABLED``: Guild can be viewed before being accepted via Membership Screening. - - ``ROLE_ICONS``: Guild can set an image or emoji as a role icon. - - ``ROLE_SUBSCRIPTIONS_AVAILABLE_FOR_PURCHASE``: Role subscriptions are available for purchasing. - - ``ROLE_SUBSCRIPTIONS_ENABLED``: Guild is able to view and manage role subscriptions. - - ``SEVEN_DAY_THREAD_ARCHIVE``: Users can set the thread archive time to 7 days. - - ``TEXT_IN_VOICE_ENABLED``: Guild has a chat button inside voice channels that opens a dedicated text channel in a sidebar similar to thread view. - - ``THREADS_ENABLED_TESTING``: Used by bot developers to test their bots with threads in guilds with 5 or fewer members and a bot. Also gives the premium thread features. - - ``THREE_DAY_THREAD_ARCHIVE``: Users can set the thread archive time to 3 days. - - ``TICKETED_EVENTS_ENABLED``: Guild has enabled ticketed events. - - ``VANITY_URL``: Guild can have a vanity invite URL (e.g. discord.gg/discord-api). - - ``VERIFIED``: Guild is a verified server. - - ``VIP_REGIONS``: Guild has VIP voice regions. - - ``WELCOME_SCREEN_ENABLED``: Guild has enabled the welcome screen. - + subject to arbitrary change by Discord. You can find a catalog of guild features + `here `_. premium_tier: :class:`int` The premium tier for this guild. Corresponds to "Nitro Server" in the official UI. The number goes from 0 to 3 inclusive. @@ -425,7 +383,7 @@ def __repr__(self) -> str: ("name", self.name), ("shard_id", self.shard_id), ("chunked", self.chunked), - ("member_count", getattr(self, "_member_count", None)), + ("member_count", self._member_count), ) inner = " ".join("%s=%r" % t for t in attrs) return f"" @@ -483,11 +441,11 @@ def _remove_role(self, role_id: int, /) -> Role: return role def _from_data(self, guild: GuildPayload) -> None: - # according to Stan, this is always available even if the guild is unavailable - # I don't have this guarantee when someone updates the guild. - member_count = guild.get("member_count", None) - if member_count is not None: - self._member_count: int = member_count + member_count = guild.get("member_count") + # Either the payload includes member_count, or it hasn't been set yet. + # Prevents valid _member_count from suddenly changing to None + if member_count is not None or not hasattr(self, "_member_count"): + self._member_count: int | None = member_count self.name: str = guild.get("name") self.verification_level: VerificationLevel = try_enum( @@ -574,7 +532,7 @@ def _from_data(self, guild: GuildPayload) -> None: self._sync(guild) self._large: bool | None = ( - None if member_count is None else self._member_count >= 250 + None if self._member_count is None else self._member_count >= 250 ) self.owner_id: int | None = utils._get_as_snowflake(guild, "owner_id") @@ -640,10 +598,7 @@ def large(self) -> bool: members, which for this library is set to the maximum of 250. """ if self._large is None: - try: - return self._member_count >= 250 - except AttributeError: - return len(self._members) >= 250 + return (self._member_count or len(self._members)) >= 250 return self._large @property @@ -1044,10 +999,9 @@ def chunked(self) -> bool: If this value returns ``False``, then you should request for offline members. """ - count = getattr(self, "_member_count", None) - if count is None: + if self._member_count is None: return False - return count == len(self._members) + return self._member_count == len(self._members) @property def shard_id(self) -> int: @@ -1110,10 +1064,7 @@ def get_member_named(self, name: str, /) -> Member | None: if result is not None: return result - def pred(m: Member) -> bool: - return m.nick == name or m.name == name - - return utils.find(pred, members) + return utils.find(lambda m: name in (m.nick, m.name, m.global_name), members) def _create_channel( self, @@ -2158,13 +2109,19 @@ async def fetch_channel(self, channel_id: int, /) -> GuildChannel | Thread: def bans( self, limit: int | None = None, - before: SnowflakeTime | None = None, - after: SnowflakeTime | None = None, + before: Snowflake | None = None, + after: Snowflake | None = None, ) -> BanIterator: """|coro| Retrieves an :class:`.AsyncIterator` that enables receiving the guild's bans. In order to use this, you must have the :attr:`~Permissions.ban_members` permission. + Users will always be returned in ascending order sorted by user ID. + If both the ``before`` and ``after`` parameters are provided, only before is respected. + + .. versionchanged:: 2.5 + The ``before``. and ``after`` parameters were changed. They are now of the type :class:`.abc.Snowflake` instead of + `SnowflakeTime` to comply with the discord api. .. versionchanged:: 2.0 The ``limit``, ``before``. and ``after`` parameters were added. Now returns a :class:`.BanIterator` instead @@ -2176,14 +2133,10 @@ def bans( ---------- limit: Optional[:class:`int`] The number of bans to retrieve. Defaults to 1000. - before: Optional[Union[:class:`.abc.Snowflake`, :class:`datetime.datetime`]] - Retrieve bans before this date or object. - If a datetime is provided, it is recommended to use a UTC aware datetime. - If the datetime is naive, it is assumed to be local time. - after: Optional[Union[:class:`.abc.Snowflake`, :class:`datetime.datetime`]] - Retrieve bans after this date or object. - If a datetime is provided, it is recommended to use a UTC aware datetime. - If the datetime is naive, it is assumed to be local time. + before: Optional[:class:`.abc.Snowflake`] + Retrieve bans before the given user. + after: Optional[:class:`.abc.Snowflake`] + Retrieve bans after the given user. Yields ------ @@ -2846,6 +2799,8 @@ async def create_role( colour: Colour | int = ..., hoist: bool = ..., mentionable: bool = ..., + icon: bytes | None = MISSING, + unicode_emoji: str | None = MISSING, ) -> Role: ... @@ -2859,6 +2814,8 @@ async def create_role( color: Colour | int = ..., hoist: bool = ..., mentionable: bool = ..., + icon: bytes | None = ..., + unicode_emoji: str | None = ..., ) -> Role: ... @@ -2872,6 +2829,8 @@ async def create_role( hoist: bool = MISSING, mentionable: bool = MISSING, reason: str | None = None, + icon: bytes | None = MISSING, + unicode_emoji: str | None = MISSING, ) -> Role: """|coro| @@ -2902,6 +2861,13 @@ async def create_role( Defaults to ``False``. reason: Optional[:class:`str`] The reason for creating this role. Shows up on the audit log. + icon: Optional[:class:`bytes`] + A :term:`py:bytes-like object` representing the icon. Only PNG/JPEG/WebP is supported. + If this argument is passed, ``unicode_emoji`` is set to None. + Only available to guilds that contain ``ROLE_ICONS`` in :attr:`Guild.features`. + unicode_emoji: Optional[:class:`str`] + The role's unicode emoji. If this argument is passed, ``icon`` is set to None. + Only available to guilds that contain ``ROLE_ICONS`` in :attr:`Guild.features`. Returns ------- @@ -2938,6 +2904,17 @@ async def create_role( if name is not MISSING: fields["name"] = name + if icon is not MISSING: + if icon is None: + fields["icon"] = None + else: + fields["icon"] = _bytes_to_base64_data(icon) + fields["unicode_emoji"] = None + + if unicode_emoji is not MISSING: + fields["unicode_emoji"] = unicode_emoji + fields["icon"] = None + data = await self._state.http.create_role(self.id, reason=reason, **fields) role = Role(guild=self, data=data, state=self._state) diff --git a/discord/http.py b/discord/http.py index d70380cd4f..9eb7837d13 100644 --- a/discord/http.py +++ b/discord/http.py @@ -1170,6 +1170,7 @@ def start_forum_thread( invitable: bool = True, applied_tags: SnowflakeList | None = None, reason: str | None = None, + files: Sequence[File] | None = None, embed: embed.Embed | None = None, embeds: list[embed.Embed] | None = None, nonce: str | None = None, @@ -1177,43 +1178,74 @@ def start_forum_thread( stickers: list[sticker.StickerItem] | None = None, components: list[components.Component] | None = None, ) -> Response[threads.Thread]: - payload = { + payload: dict[str, Any] = { "name": name, "auto_archive_duration": auto_archive_duration, "invitable": invitable, } - if content: - payload["content"] = content if applied_tags: payload["applied_tags"] = applied_tags + if rate_limit_per_user: + payload["rate_limit_per_user"] = rate_limit_per_user + + message = {} + + if content: + message["content"] = content + if embed: - payload["embeds"] = [embed] + message["embeds"] = [embed] if embeds: - payload["embeds"] = embeds + message["embeds"] = embeds if nonce: - payload["nonce"] = nonce + message["nonce"] = nonce if allowed_mentions: - payload["allowed_mentions"] = allowed_mentions + message["allowed_mentions"] = allowed_mentions if components: - payload["components"] = components + message["components"] = components if stickers: - payload["sticker_ids"] = stickers + message["sticker_ids"] = stickers + + if message != {}: + payload["message"] = message - if rate_limit_per_user: - payload["rate_limit_per_user"] = rate_limit_per_user - # TODO: Once supported by API, remove has_message=true query parameter route = Route( "POST", - "/channels/{channel_id}/threads?has_message=true", + "/channels/{channel_id}/threads", channel_id=channel_id, ) + + if files: + form = [{"name": "payload_json"}] + + attachments = [] + for index, file in enumerate(files): + attachments.append( + { + "id": index, + "filename": file.filename, + "description": file.description, + } + ) + form.append( + { + "name": f"files[{index}]", + "value": file.fp, + "filename": file.filename, + "content_type": "application/octet-stream", + } + ) + + payload["attachments"] = attachments + form[0]["value"] = utils._to_json(payload) + return self.request(route, form=form, reason=reason) return self.request(route, json=payload, reason=reason) def join_thread(self, channel_id: Snowflake) -> Response[None]: diff --git a/discord/interactions.py b/discord/interactions.py index 2cd4b740b8..83ca14f128 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -29,16 +29,22 @@ from typing import TYPE_CHECKING, Any, Coroutine, Union from . import utils -from .channel import ChannelType, PartialMessageable +from .channel import ChannelType, PartialMessageable, _threaded_channel_factory from .enums import InteractionResponseType, InteractionType, try_enum from .errors import ClientException, InteractionResponded, InvalidArgument from .file import File +from .flags import MessageFlags from .member import Member from .message import Attachment, Message from .object import Object from .permissions import Permissions from .user import User -from .webhook.async_ import Webhook, async_context, handle_message_parameters +from .webhook.async_ import ( + Webhook, + WebhookMessage, + async_context, + handle_message_parameters, +) __all__ = ( "Interaction", @@ -52,7 +58,9 @@ from .channel import ( CategoryChannel, + DMChannel, ForumChannel, + GroupChannel, StageChannel, TextChannel, VoiceChannel, @@ -77,6 +85,8 @@ ForumChannel, CategoryChannel, Thread, + DMChannel, + GroupChannel, PartialMessageable, ] @@ -99,8 +109,10 @@ class Interaction: The interaction type. guild_id: Optional[:class:`int`] The guild ID the interaction was sent from. + channel: Optional[Union[:class:`abc.GuildChannel`, :class:`abc.PrivateChannel`, :class:`Thread`]] + The channel the interaction was sent from. channel_id: Optional[:class:`int`] - The channel ID the interaction was sent from. + The ID of the channel the interaction was sent from. application_id: :class:`int` The application ID that the interaction was for. user: Optional[Union[:class:`User`, :class:`Member`]] @@ -124,6 +136,7 @@ class Interaction: "id", "type", "guild_id", + "channel", "channel_id", "data", "application_id", @@ -134,6 +147,7 @@ class Interaction: "token", "version", "custom_id", + "_channel_data", "_message_data", "_permissions", "_app_permissions", @@ -169,13 +183,7 @@ def _from_data(self, data: InteractionPayload): self._app_permissions: int = int(data.get("app_permissions", 0)) self.message: Message | None = None - - if message_data := data.get("message"): - self.message = Message( - state=self._state, channel=self.channel, data=message_data - ) - - self._message_data = message_data + self.channel = None self.user: User | Member | None = None self._permissions: int = 0 @@ -206,6 +214,30 @@ def _from_data(self, data: InteractionPayload): except KeyError: pass + if channel := data.get("channel"): + if (ch_type := channel.get("type")) is not None: + factory, ch_type = _threaded_channel_factory(ch_type) + + if ch_type in (ChannelType.group, ChannelType.private): + self.channel = factory( + me=self.user, data=channel, state=self._state + ) + elif self.guild: + self.channel = factory( + guild=self.guild, state=self._state, data=channel + ) + else: + self.channel = self.cached_channel + + self._channel_data = channel + + if message_data := data.get("message"): + self.message = Message( + state=self._state, channel=self.channel, data=message_data + ) + + self._message_data = message_data + @property def client(self) -> Client: """Returns the client that sent the interaction.""" @@ -225,7 +257,7 @@ def is_component(self) -> bool: return self.type == InteractionType.component @utils.cached_slot_property("_cs_channel") - def channel(self) -> InteractionChannel | None: + def cached_channel(self) -> InteractionChannel | None: """The channel the interaction was sent from. @@ -355,6 +387,7 @@ async def edit_original_response( view: View | None = MISSING, allowed_mentions: AllowedMentions | None = None, delete_after: float | None = None, + suppress: bool = False, ) -> InteractionMessage: """|coro| @@ -393,6 +426,8 @@ async def edit_original_response( If provided, the number of seconds to wait in the background before deleting the message we just edited. If the deletion fails, then it is silently ignored. + suppress: :class:`bool` + Whether to suppress embeds for the message. Returns ------- @@ -422,6 +457,7 @@ async def edit_original_response( view=view, allowed_mentions=allowed_mentions, previous_allowed_mentions=previous_mentions, + suppress=suppress, ) adapter = async_context.get() http = self._state.http @@ -519,6 +555,44 @@ async def delete_original_message(self, **kwargs): """ return await self.delete_original_response(**kwargs) + async def respond(self, *args, **kwargs) -> Interaction | WebhookMessage: + """|coro| + + Sends either a response or a message using the followup webhook determined by whether the interaction + has been responded to or not. + + Returns + ------- + Union[:class:`discord.Interaction`, :class:`discord.WebhookMessage`]: + The response, its type depending on whether it's an interaction response or a followup. + """ + try: + if not self.response.is_done(): + return await self.response.send_message(*args, **kwargs) + else: + return await self.followup.send(*args, **kwargs) + except InteractionResponded: + return await self.followup.send(*args, **kwargs) + + async def edit(self, *args, **kwargs) -> InteractionMessage | None: + """|coro| + + Either respond to the interaction with an edit_message or edits the existing response, determined by + whether the interaction has been responded to or not. + + Returns + ------- + Union[:class:`discord.InteractionMessage`, :class:`discord.WebhookMessage`]: + The response, its type depending on whether it's an interaction response or a followup. + """ + try: + if not self.response.is_done(): + return await self.response.edit_message(*args, **kwargs) + else: + return await self.edit_original_response(*args, **kwargs) + except InteractionResponded: + return await self.edit_original_response(*args, **kwargs) + def to_dict(self) -> dict[str, Any]: """ Converts this interaction object into a dict. @@ -607,6 +681,10 @@ async def defer(self, *, ephemeral: bool = False, invisible: bool = True) -> Non - :attr:`InteractionType.component` - :attr:`InteractionType.modal_submit` + .. note:: + The follow-up response will also be non-ephemeral if the `ephemeral` + argument is ``False``, and ephemeral if ``True``. + Parameters ---------- ephemeral: :class:`bool` @@ -844,7 +922,7 @@ async def send_message( if ephemeral and view.timeout is None: view.timeout = 15 * 60.0 - view.message = await self._parent.original_response() + view.parent = self._parent self._parent._state.store_view(view) self._responded = True @@ -863,6 +941,8 @@ async def edit_message( attachments: list[Attachment] = MISSING, view: View | None = MISSING, delete_after: float | None = None, + suppress: bool | None = MISSING, + allowed_mentions: AllowedMentions | None = None, ) -> None: """|coro| @@ -893,6 +973,15 @@ async def edit_message( If provided, the number of seconds to wait in the background before deleting the message we just edited. If the deletion fails, then it is silently ignored. + suppress: Optional[:class:`bool`] + Whether to suppress embeds for the message. + allowed_mentions: Optional[:class:`~discord.AllowedMentions`] + Controls the mentions being processed in this message. If this is + passed, then the object is merged with :attr:`~discord.Client.allowed_mentions`. + The merging behaviour only overrides attributes that have been explicitly passed + to the object, otherwise it uses the attributes set in :attr:`~discord.Client.allowed_mentions`. + If no object is passed at all then the defaults given by :attr:`~discord.Client.allowed_mentions` + are used instead. Raises ------ @@ -956,6 +1045,23 @@ async def edit_message( # we keep previous attachments when adding new files payload["attachments"] = [a.to_dict() for a in msg.attachments] + if suppress is not MISSING: + flags = MessageFlags._from_value(self._parent.message.flags.value) + flags.suppress_embeds = suppress + payload["flags"] = flags.value + + if allowed_mentions is None: + payload["allowed_mentions"] = ( + state.allowed_mentions and state.allowed_mentions.to_dict() + ) + + elif state.allowed_mentions is not None: + payload["allowed_mentions"] = state.allowed_mentions.merge( + allowed_mentions + ).to_dict() + else: + payload["allowed_mentions"] = allowed_mentions.to_dict() + adapter = async_context.get() http = parent._state.http try: @@ -1142,6 +1248,7 @@ async def edit( view: View | None = MISSING, allowed_mentions: AllowedMentions | None = None, delete_after: float | None = None, + suppress: bool | None = MISSING, ) -> InteractionMessage: """|coro| @@ -1174,6 +1281,8 @@ async def edit( If provided, the number of seconds to wait in the background before deleting the message we just edited. If the deletion fails, then it is silently ignored. + suppress: Optional[:class:`bool`] + Whether to suppress embeds for the message. Returns ------- @@ -1193,6 +1302,8 @@ async def edit( """ if attachments is MISSING: attachments = self.attachments or MISSING + if suppress is MISSING: + suppress = self.flags.suppress_embeds return await self._state._interaction.edit_original_response( content=content, embeds=embeds, @@ -1203,6 +1314,7 @@ async def edit( view=view, allowed_mentions=allowed_mentions, delete_after=delete_after, + suppress=suppress, ) async def delete(self, *, delay: float | None = None) -> None: diff --git a/discord/iterators.py b/discord/iterators.py index f4ec6cda28..78edb7570d 100644 --- a/discord/iterators.py +++ b/discord/iterators.py @@ -689,12 +689,6 @@ def create_member(self, data): class BanIterator(_AsyncIterator["BanEntry"]): def __init__(self, guild, limit=None, before=None, after=None): - if isinstance(after, datetime.datetime): - after = Object(id=time_snowflake(after, high=True)) - - if isinstance(before, datetime.datetime): - before = Object(id=time_snowflake(before, high=True)) - self.guild = guild self.limit = limit self.after = after diff --git a/discord/member.py b/discord/member.py index 0c798a725d..3e60fc1365 100644 --- a/discord/member.py +++ b/discord/member.py @@ -230,7 +230,7 @@ class Member(discord.abc.Messageable, _UserTag): .. describe:: str(x) - Returns the member's name with the discriminator. + Returns the member's name with the discriminator or global_name. Attributes ---------- @@ -322,6 +322,19 @@ def __str__(self) -> str: return str(self._user) def __repr__(self) -> str: + if self._user.is_migrated: + if self.global_name is not None: + return ( + "" + ) + else: + return ( + "" + ) return ( " str: def __eq__(self, other: Any) -> bool: return isinstance(other, _UserTag) and other.id == self.id - def __ne__(self, other: Any) -> bool: - return not self.__eq__(other) - def __hash__(self) -> int: return hash(self._user) @@ -427,17 +437,24 @@ def _presence_update( def _update_inner_user(self, user: UserPayload) -> tuple[User, User] | None: u = self._user - original = (u.name, u._avatar, u.discriminator, u._public_flags) + original = (u.name, u._avatar, u.discriminator, u.global_name, u._public_flags) # These keys seem to always be available modified = ( user["username"], user["avatar"], user["discriminator"], + user.get("global_name", None) or None, user.get("public_flags", 0), ) if original != modified: to_return = User._copy(self._user) - u.name, u._avatar, u.discriminator, u._public_flags = modified + ( + u.name, + u._avatar, + u.discriminator, + u.global_name, + u._public_flags, + ) = modified # Signal to dispatch on_user_update return to_return, u @@ -476,6 +493,11 @@ def web_status(self) -> Status: """The member's status on the web client, if applicable.""" return try_enum(Status, self._client_status.get("web", "offline")) + @property + def global_name(self) -> str | None: + """The member's global name, if applicable.""" + return self._user.global_name + def is_on_mobile(self) -> bool: """A helper function that determines if a member is active on a mobile device.""" return "mobile" in self._client_status @@ -535,12 +557,9 @@ def mention(self) -> str: @property def display_name(self) -> str: """Returns the user's display name. - - For regular users this is just their username, but - if they have a guild specific nickname then that - is returned instead. + This will either be their guild specific nickname, global name or username. """ - return self.nick or self.name + return self.nick or self.global_name or self.name @property def display_avatar(self) -> Asset: diff --git a/discord/message.py b/discord/message.py index 77193b2920..c74f40c288 100644 --- a/discord/message.py +++ b/discord/message.py @@ -159,7 +159,7 @@ class Attachment(Hashable): case of images. When the message is deleted, this URL might be valid for a few minutes or not valid at all. content_type: Optional[:class:`str`] - The attachment's `media type `_ + The attachment's `media type `_. ephemeral: :class:`bool` Whether the attachment is ephemeral or not. @@ -169,6 +169,16 @@ class Attachment(Hashable): The attachment's description. .. versionadded:: 2.0 + + duration_secs: Optional[:class:`float`] + The duration of the audio file (currently for voice messages). + + .. versionadded:: 2.5 + + waveform: Optional[:class:`str`] + The base64 encoded bytearray representing a sampled waveform (currently for voice messages). + + .. versionadded:: 2.5 """ __slots__ = ( @@ -183,6 +193,8 @@ class Attachment(Hashable): "content_type", "ephemeral", "description", + "duration_secs", + "waveform", ) def __init__(self, *, data: AttachmentPayload, state: ConnectionState): @@ -197,6 +209,8 @@ def __init__(self, *, data: AttachmentPayload, state: ConnectionState): self.content_type: str | None = data.get("content_type") self.ephemeral: bool = data.get("ephemeral", False) self.description: str | None = data.get("description") + self.duration_secs: float | None = data.get("duration_secs") + self.waveform: str | None = data.get("waveform") def is_spoiler(self) -> bool: """Whether this attachment contains a spoiler.""" diff --git a/discord/mixins.py b/discord/mixins.py index cfbb030bbd..acf451361d 100644 --- a/discord/mixins.py +++ b/discord/mixins.py @@ -38,9 +38,7 @@ def __eq__(self, other: object) -> bool: return isinstance(other, self.__class__) and other.id == self.id def __ne__(self, other: object) -> bool: - if isinstance(other, self.__class__): - return other.id != self.id - return True + return not self.__eq__(other) class Hashable(EqualityComparable): diff --git a/discord/partial_emoji.py b/discord/partial_emoji.py index dd2d6fa04d..ec5495c7af 100644 --- a/discord/partial_emoji.py +++ b/discord/partial_emoji.py @@ -202,9 +202,6 @@ def __eq__(self, other: Any) -> bool: return self.id == other.id return False - def __ne__(self, other: Any) -> bool: - return not self.__eq__(other) - def __hash__(self) -> int: return hash((self.id, self.name)) diff --git a/discord/permissions.py b/discord/permissions.py index 43dc6f2f7d..e94e6c2116 100644 --- a/discord/permissions.py +++ b/discord/permissions.py @@ -610,6 +610,14 @@ def moderate_members(self) -> int: """ return 1 << 40 + @flag_value + def send_voice_messages(self) -> int: + """:class:`bool`: Returns ``True`` if a member can send voice messages. + + .. versionadded:: 2.5 + """ + return 1 << 46 + PO = TypeVar("PO", bound="PermissionOverwrite") @@ -727,6 +735,7 @@ class PermissionOverwrite: use_external_stickers: bool | None start_embedded_activities: bool | None moderate_members: bool | None + send_voice_messages: bool | None def __init__(self, **kwargs: bool | None): self._values: dict[str, bool | None] = {} diff --git a/discord/raw_models.py b/discord/raw_models.py index 78f4d01903..1cd88ea0c2 100644 --- a/discord/raw_models.py +++ b/discord/raw_models.py @@ -97,9 +97,13 @@ class RawMessageDeleteEvent(_RawReprMixin): The message ID that got deleted. cached_message: Optional[:class:`Message`] The cached message, if found in the internal message cache. + data: :class:`dict` + The raw data sent by the `gateway `_. + + .. versionadded:: 2.5 """ - __slots__ = ("message_id", "channel_id", "guild_id", "cached_message") + __slots__ = ("message_id", "channel_id", "guild_id", "cached_message", "data") def __init__(self, data: MessageDeleteEvent) -> None: self.message_id: int = int(data["id"]) @@ -109,6 +113,7 @@ def __init__(self, data: MessageDeleteEvent) -> None: self.guild_id: int | None = int(data["guild_id"]) except KeyError: self.guild_id: int | None = None + self.data: MessageDeleteEvent = data class RawBulkMessageDeleteEvent(_RawReprMixin): @@ -124,9 +129,13 @@ class RawBulkMessageDeleteEvent(_RawReprMixin): The guild ID where the message got deleted, if applicable. cached_messages: List[:class:`Message`] The cached messages, if found in the internal message cache. + data: :class:`dict` + The raw data sent by the `gateway `_. + + .. versionadded:: 2.5 """ - __slots__ = ("message_ids", "channel_id", "guild_id", "cached_messages") + __slots__ = ("message_ids", "channel_id", "guild_id", "cached_messages", "data") def __init__(self, data: BulkMessageDeleteEvent) -> None: self.message_ids: set[int] = {int(x) for x in data.get("ids", [])} @@ -137,6 +146,7 @@ def __init__(self, data: BulkMessageDeleteEvent) -> None: self.guild_id: int | None = int(data["guild_id"]) except KeyError: self.guild_id: int | None = None + self.data: BulkMessageDeleteEvent = data class RawMessageUpdateEvent(_RawReprMixin): @@ -156,7 +166,7 @@ class RawMessageUpdateEvent(_RawReprMixin): .. versionadded:: 1.7 data: :class:`dict` - The raw data given by the `gateway `_ + The raw data sent by the `gateway `_ cached_message: Optional[:class:`Message`] The cached message, if found in the internal message cache. Represents the message before it is modified by the data in :attr:`RawMessageUpdateEvent.data`. @@ -204,6 +214,10 @@ class RawReactionActionEvent(_RawReprMixin): ``REACTION_REMOVE`` for reaction removal. .. versionadded:: 1.3 + data: :class:`dict` + The raw data sent by the `gateway `_. + + .. versionadded:: 2.5 """ __slots__ = ( @@ -214,6 +228,7 @@ class RawReactionActionEvent(_RawReprMixin): "emoji", "event_type", "member", + "data", ) def __init__( @@ -230,6 +245,7 @@ def __init__( self.guild_id: int | None = int(data["guild_id"]) except KeyError: self.guild_id: int | None = None + self.data: ReactionActionEvent = data class RawReactionClearEvent(_RawReprMixin): @@ -243,9 +259,13 @@ class RawReactionClearEvent(_RawReprMixin): The channel ID where the reactions got cleared. guild_id: Optional[:class:`int`] The guild ID where the reactions got cleared. + data: :class:`dict` + The raw data sent by the `gateway `_. + + .. versionadded:: 2.5 """ - __slots__ = ("message_id", "channel_id", "guild_id") + __slots__ = ("message_id", "channel_id", "guild_id", "data") def __init__(self, data: ReactionClearEvent) -> None: self.message_id: int = int(data["message_id"]) @@ -255,6 +275,7 @@ def __init__(self, data: ReactionClearEvent) -> None: self.guild_id: int | None = int(data["guild_id"]) except KeyError: self.guild_id: int | None = None + self.data: ReactionClearEvent = data class RawReactionClearEmojiEvent(_RawReprMixin): @@ -272,9 +293,13 @@ class RawReactionClearEmojiEvent(_RawReprMixin): The guild ID where the reactions got cleared. emoji: :class:`PartialEmoji` The custom or unicode emoji being removed. + data: :class:`dict` + The raw data sent by the `gateway `_. + + .. versionadded:: 2.5 """ - __slots__ = ("message_id", "channel_id", "guild_id", "emoji") + __slots__ = ("message_id", "channel_id", "guild_id", "emoji", "data") def __init__(self, data: ReactionClearEmojiEvent, emoji: PartialEmoji) -> None: self.emoji: PartialEmoji = emoji @@ -285,6 +310,7 @@ def __init__(self, data: ReactionClearEmojiEvent, emoji: PartialEmoji) -> None: self.guild_id: int | None = int(data["guild_id"]) except KeyError: self.guild_id: int | None = None + self.data: ReactionClearEmojiEvent = data class RawIntegrationDeleteEvent(_RawReprMixin): @@ -300,9 +326,13 @@ class RawIntegrationDeleteEvent(_RawReprMixin): The ID of the bot/OAuth2 application for this deleted integration. guild_id: :class:`int` The guild ID where the integration got deleted. + data: :class:`dict` + The raw data sent by the `gateway `_. + + .. versionadded:: 2.5 """ - __slots__ = ("integration_id", "application_id", "guild_id") + __slots__ = ("integration_id", "application_id", "guild_id", "data") def __init__(self, data: IntegrationDeleteEvent) -> None: self.integration_id: int = int(data["id"]) @@ -312,6 +342,7 @@ def __init__(self, data: IntegrationDeleteEvent) -> None: self.application_id: int | None = int(data["application_id"]) except KeyError: self.application_id: int | None = None + self.data: IntegrationDeleteEvent = data class RawThreadUpdateEvent(_RawReprMixin): @@ -330,7 +361,7 @@ class RawThreadUpdateEvent(_RawReprMixin): parent_id: :class:`int` The ID of the channel the thread belongs to. data: :class:`dict` - The raw data given by the `gateway `_. + The raw data sent by the `gateway `_. thread: :class:`discord.Thread` | None The thread, if it could be found in the internal cache. """ @@ -364,9 +395,13 @@ class RawThreadDeleteEvent(_RawReprMixin): The ID of the channel the thread belonged to. thread: Optional[:class:`discord.Thread`] The thread that was deleted. This may be ``None`` if deleted thread is not found in internal cache. + data: :class:`dict` + The raw data sent by the `gateway `_. + + .. versionadded:: 2.5 """ - __slots__ = ("thread_id", "thread_type", "guild_id", "parent_id", "thread") + __slots__ = ("thread_id", "thread_type", "guild_id", "parent_id", "thread", "data") def __init__(self, data: ThreadDeleteEvent) -> None: self.thread_id: int = int(data["id"]) @@ -374,6 +409,7 @@ def __init__(self, data: ThreadDeleteEvent) -> None: self.guild_id: int = int(data["guild_id"]) self.parent_id: int = int(data["parent_id"]) self.thread: Thread | None = None + self.data: ThreadDeleteEvent = data class RawTypingEvent(_RawReprMixin): @@ -393,9 +429,13 @@ class RawTypingEvent(_RawReprMixin): The guild ID where the typing originated from, if applicable. member: Optional[:class:`Member`] The member who started typing. Only available if the member started typing in a guild. + data: :class:`dict` + The raw data sent by the `gateway `_. + + .. versionadded:: 2.5 """ - __slots__ = ("channel_id", "user_id", "when", "guild_id", "member") + __slots__ = ("channel_id", "user_id", "when", "guild_id", "member", "data") def __init__(self, data: TypingEvent) -> None: self.channel_id: int = int(data["channel_id"]) @@ -409,6 +449,7 @@ def __init__(self, data: TypingEvent) -> None: self.guild_id: int | None = int(data["guild_id"]) except KeyError: self.guild_id: int | None = None + self.data: TypingEvent = data class RawMemberRemoveEvent(_RawReprMixin): @@ -422,13 +463,18 @@ class RawMemberRemoveEvent(_RawReprMixin): The user that left the guild. guild_id: :class:`int` The ID of the guild the user left. + data: :class:`dict` + The raw data sent by the `gateway `_. + + .. versionadded:: 2.5 """ - __slots__ = ("user", "guild_id") + __slots__ = ("user", "guild_id", "data") def __init__(self, data: MemberRemoveEvent, user: User): self.user: User = user self.guild_id: int = int(data["guild_id"]) + self.data: MemberRemoveEvent = data class RawScheduledEventSubscription(_RawReprMixin): @@ -448,15 +494,20 @@ class RawScheduledEventSubscription(_RawReprMixin): event_type: :class:`str` Can be either ``USER_ADD`` or ``USER_REMOVE`` depending on the event called. + data: :class:`dict` + The raw data sent by the `gateway `_. + + .. versionadded:: 2.5 """ - __slots__ = ("event_id", "guild", "user_id", "event_type") + __slots__ = ("event_id", "guild", "user_id", "event_type", "data") def __init__(self, data: ScheduledEventSubscription, event_type: str): self.event_id: int = int(data["guild_scheduled_event_id"]) self.user_id: int = int(data["user_id"]) self.guild: Guild | None = None self.event_type: str = event_type + self.data: ScheduledEventSubscription = data class AutoModActionExecutionEvent: @@ -503,6 +554,10 @@ class AutoModActionExecutionEvent: The word or phrase configured that was matched in the content. matched_content: :class:`str` The substring in the content that was matched. + data: :class:`dict` + The raw data sent by the `gateway `_. + + .. versionadded:: 2.5 """ __slots__ = ( @@ -522,6 +577,7 @@ class AutoModActionExecutionEvent: "message", "alert_system_message_id", "alert_system_message", + "data", ) def __init__(self, state: ConnectionState, data: AutoModActionExecution) -> None: @@ -570,6 +626,7 @@ def __init__(self, state: ConnectionState, data: AutoModActionExecution) -> None except KeyError: self.alert_system_message_id: int | None = None self.alert_system_message: Message | None = None + self.data: AutoModActionExecution = data def __repr__(self) -> str: return ( @@ -593,7 +650,9 @@ class RawThreadMembersUpdateEvent(_RawReprMixin): member_count: :class:`int` The approximate number of members in the thread. Maximum of 50. data: :class:`dict` - The raw data given by the `gateway `_. + The raw data sent by the `gateway `_. + + .. versionadded:: 2.5 """ __slots__ = ("thread_id", "guild_id", "member_count", "data") @@ -602,7 +661,7 @@ def __init__(self, data: ThreadMembersUpdateEvent) -> None: self.thread_id = int(data["id"]) self.guild_id = int(data["guild_id"]) self.member_count = int(data["member_count"]) - self.data = data + self.data: ThreadMembersUpdateEvent = data class RawAuditLogEntryEvent(_RawReprMixin): @@ -618,7 +677,7 @@ class RawAuditLogEntryEvent(_RawReprMixin): The entry ID. guild_id: :class:`int` The ID of the guild this action came from. - user_id: :class:`int` + user_id: Optional[:class:`int`] The ID of the user who initiated this action. target_id: Optional[:class:`int`] The ID of the target that got changed. @@ -632,7 +691,7 @@ class RawAuditLogEntryEvent(_RawReprMixin): contains extra information. See :class:`AuditLogAction` for which actions have this field filled out. data: :class:`dict` - The raw data given by the `gateway `_. + The raw data sent by the `gateway `_. """ __slots__ = ( @@ -644,11 +703,14 @@ class RawAuditLogEntryEvent(_RawReprMixin): "reason", "extra", "changes", + "data", ) def __init__(self, data: AuditLogEntryEvent) -> None: self.id = int(data["id"]) - self.user_id = int(data["user_id"]) + self.user_id = data.get("user_id") + if self.user_id: + self.user_id = int(self.user_id) self.guild_id = int(data["guild_id"]) self.target_id = data.get("target_id") if self.target_id: @@ -657,4 +719,4 @@ def __init__(self, data: AuditLogEntryEvent) -> None: self.reason = data.get("reason") self.extra = data.get("options") self.changes = data.get("changes") - self.data = data + self.data: AuditLogEntryEvent = data diff --git a/discord/scheduled_events.py b/discord/scheduled_events.py index 6f70ff88c6..f7c0f898d9 100644 --- a/discord/scheduled_events.py +++ b/discord/scheduled_events.py @@ -158,7 +158,7 @@ class ScheduledEvent(Hashable): The number of users that have marked themselves as interested in the event. creator_id: Optional[:class:`int`] The ID of the user who created the event. - It may be ``None`` because events created before October 25th, 2021, haven't + It may be ``None`` because events created before October 25th, 2021 haven't had their creators tracked. creator: Optional[:class:`User`] The resolved user object of who created the event. @@ -209,7 +209,7 @@ def __init__( ScheduledEventStatus, data.get("status") ) self.subscriber_count: int | None = data.get("user_count", None) - self.creator_id = data.get("creator_id", None) + self.creator_id: int | None = utils._get_as_snowflake(data, "creator_id") self.creator: Member | None = creator entity_metadata = data.get("entity_metadata") diff --git a/discord/sinks/core.py b/discord/sinks/core.py index 479acbe179..05d090a07a 100644 --- a/discord/sinks/core.py +++ b/discord/sinks/core.py @@ -34,7 +34,7 @@ from .errors import SinkException if TYPE_CHECKING: - from ..channel import VoiceChannel + from ..voice_client import VoiceClient __all__ = ( "Filters", @@ -115,6 +115,7 @@ def __init__(self, data, client): self.decoded_data = None self.user_id = None + self.receive_time = time.perf_counter() class AudioData: @@ -200,11 +201,11 @@ def __init__(self, *, filters=None): filters = default_filters self.filters = filters Filters.__init__(self, **self.filters) - self.vc: VoiceChannel = None + self.vc: VoiceClient = None self.audio_data = {} def init(self, vc): # called under listen - self.vc: VoiceChannel = vc + self.vc: VoiceClient = vc super().init() @Filters.container diff --git a/discord/state.py b/discord/state.py index 90fd97ab53..d222ba4518 100644 --- a/discord/state.py +++ b/discord/state.py @@ -1139,10 +1139,8 @@ def parse_guild_member_add(self, data) -> None: if self.member_cache_flags.joined: guild._add_member(member) - try: + if guild._member_count is not None: guild._member_count += 1 - except AttributeError: - pass self.dispatch("member_join", member) @@ -1152,10 +1150,8 @@ def parse_guild_member_remove(self, data) -> None: guild = self._get_guild(int(data["guild_id"])) if guild is not None: - try: + if guild._member_count is not None: guild._member_count -= 1 - except AttributeError: - pass member = guild.get_member(user.id) if member is not None: @@ -1256,8 +1252,10 @@ def is_guild_evicted(self, guild) -> bool: return guild.id not in self._guilds async def chunk_guild(self, guild, *, wait=True, cache=None): + # Note: This method makes an API call without timeout, and should be used in + # conjunction with `asyncio.wait_for(..., timeout=...)`. cache = cache or self.member_cache_flags.joined - request = self._chunk_requests.get(guild.id) + request = self._chunk_requests.get(guild.id) # nosec B113 if request is None: self._chunk_requests[guild.id] = request = ChunkRequest( guild.id, self.loop, self._get_guild, cache=cache @@ -1635,13 +1633,20 @@ def parse_webhooks_update(self, data) -> None: ) return - channel = guild.get_channel(int(data["channel_id"])) - if channel is not None: - self.dispatch("webhooks_update", channel) + channel_id = data["channel_id"] + if channel_id is not None: + channel = guild.get_channel(int(channel_id)) + if channel is not None: + self.dispatch("webhooks_update", channel) + else: + _log.debug( + "WEBHOOKS_UPDATE referencing an unknown channel ID: %s. Discarding.", + data["channel_id"], + ) else: _log.debug( - "WEBHOOKS_UPDATE referencing an unknown channel ID: %s. Discarding.", - data["channel_id"], + "WEBHOOKS_UPDATE channel ID was null for guild: %s. Discarding.", + data["guild_id"], ) def parse_stage_instance_create(self, data) -> None: diff --git a/discord/team.py b/discord/team.py index 10432a4d01..58f638e425 100644 --- a/discord/team.py +++ b/discord/team.py @@ -108,7 +108,7 @@ class TeamMember(BaseUser): .. describe:: str(x) - Returns the team member's name with discriminator. + Returns the team member's name with discriminator or global_name. .. versionadded:: 1.3 @@ -120,6 +120,14 @@ class TeamMember(BaseUser): The team member's unique ID. discriminator: :class:`str` The team member's discriminator. This is given when the username has conflicts. + + .. note:: + + If the user has migrated to the new username system, this will always be "0". + global_name: :class:`str` + The team member's global name. + + .. versionadded:: 2.5 avatar: Optional[:class:`str`] The avatar hash the team member has. Could be ``None``. bot: :class:`bool` @@ -141,6 +149,16 @@ def __init__(self, team: Team, state: ConnectionState, data: TeamMemberPayload): super().__init__(state=state, data=data["user"]) def __repr__(self) -> str: + if self.is_migrated: + if self.global_name is not None: + return ( + f"<{self.__class__.__name__} id={self.id} username={self.name!r} " + f"global_name={self.global_name!r} membership_state={self.membership_state!r}>" + ) + return ( + f"<{self.__class__.__name__} id={self.id} username={self.name!r} " + f"membership_state={self.membership_state!r}>" + ) return ( f"<{self.__class__.__name__} id={self.id} name={self.name!r} " f"discriminator={self.discriminator!r} membership_state={self.membership_state!r}>" diff --git a/discord/threads.py b/discord/threads.py index 6eb1bfc0a9..9a65997e52 100644 --- a/discord/threads.py +++ b/discord/threads.py @@ -247,6 +247,9 @@ def _update(self, data): except KeyError: pass + self._applied_tags: list[int] = [ + int(tag_id) for tag_id in data.get("applied_tags", []) + ] self.flags: ChannelFlags = ChannelFlags._from_value(data.get("flags", 0)) self.slowmode_delay = data.get("rate_limit_per_user", 0) @@ -611,7 +614,7 @@ async def edit( Editing the thread requires :attr:`.Permissions.manage_threads`. The thread creator can also edit ``name``, ``archived`` or ``auto_archive_duration``. Note that if the thread is locked then only those with :attr:`.Permissions.manage_threads` - can unarchive a thread. + can send messages in it or unarchive a thread. The thread must be unarchived to be edited. diff --git a/discord/types/audit_log.py b/discord/types/audit_log.py index 7e00526e6b..c0b1303947 100644 --- a/discord/types/audit_log.py +++ b/discord/types/audit_log.py @@ -267,6 +267,9 @@ class AuditEntryInfo(TypedDict): id: Snowflake type: Literal["0", "1"] role_name: str + application_id: Snowflake + auto_moderation_rule_name: str + auto_moderation_rule_trigger_type: str class AuditLogEntry(TypedDict): diff --git a/discord/types/automod.py b/discord/types/automod.py index 4632c8d8c4..4f13b46ae0 100644 --- a/discord/types/automod.py +++ b/discord/types/automod.py @@ -47,6 +47,7 @@ class AutoModTriggerMetadata(TypedDict, total=False): class AutoModActionMetadata(TypedDict, total=False): channel_id: Snowflake duration_seconds: int + custom_message: str class AutoModAction(TypedDict): diff --git a/discord/types/interactions.py b/discord/types/interactions.py index 458247709d..ad891e3203 100644 --- a/discord/types/interactions.py +++ b/discord/types/interactions.py @@ -39,6 +39,7 @@ if TYPE_CHECKING: from .message import AllowedMentions, Message + from ..interactions import InteractionChannel from .._typed_dict import NotRequired, TypedDict @@ -196,6 +197,7 @@ class Interaction(TypedDict): data: NotRequired[InteractionData] guild_id: NotRequired[Snowflake] channel_id: NotRequired[Snowflake] + channel: NotRequired[InteractionChannel] member: NotRequired[Member] user: NotRequired[User] message: NotRequired[Message] diff --git a/discord/types/message.py b/discord/types/message.py index 4d6d295d09..b141531e9a 100644 --- a/discord/types/message.py +++ b/discord/types/message.py @@ -66,6 +66,8 @@ class Attachment(TypedDict): size: int url: str proxy_url: str + duration_secs: NotRequired[float] + waveform: NotRequired[str] MessageActivityType = Literal[1, 2, 3, 5] diff --git a/discord/types/raw_models.py b/discord/types/raw_models.py index 405cce4123..453e75cf17 100644 --- a/discord/types/raw_models.py +++ b/discord/types/raw_models.py @@ -134,7 +134,7 @@ class ThreadMembersUpdateEvent(TypedDict): class AuditLogEntryEvent(TypedDict): id: Snowflake - user_id: Snowflake + user_id: NotRequired[Snowflake] guild_id: Snowflake target_id: NotRequired[Snowflake] action_type: int diff --git a/discord/types/user.py b/discord/types/user.py index 233eb4e4aa..6398fe1fc8 100644 --- a/discord/types/user.py +++ b/discord/types/user.py @@ -33,10 +33,11 @@ class PartialUser(TypedDict): id: Snowflake username: str discriminator: str + global_name: str | None avatar: str | None -PremiumType = Literal[0, 1, 2] +PremiumType = Literal[0, 1, 2, 3] class User(PartialUser, total=False): diff --git a/discord/ui/select.py b/discord/ui/select.py index 5b373bd188..fb55d39d1d 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -334,10 +334,13 @@ def values( | list[Member | User | Role] | list[GuildChannel | Thread] ): - """Union[List[:class:`str`], List[Union[:class:`discord.Member`, :class:`discord.User`]], List[:class:`discord.Role`]], - List[Union[:class:`discord.Member`, :class:`discord.User`, :class:`discord.Role`]], List[:class:`discord.abc.GuildChannel`]]: - A list of values that have been selected by the user. + """List[:class:`str`] | List[:class:`discord.Member` | :class:`discord.User`]] | List[:class:`discord.Role`]] | + List[:class:`discord.Member` | :class:`discord.User` | :class:`discord.Role`]] | List[:class:`discord.abc.GuildChannel`] | None: + A list of values that have been selected by the user. This will be ``None`` if the select has not been interacted with yet. """ + if self._interaction is None: + # The select has not been interacted with yet + return None select_type = self._underlying.type if select_type is ComponentType.string_select: return self._selected_values diff --git a/discord/ui/view.py b/discord/ui/view.py index 8172e726f8..5bf96f2612 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -141,6 +141,9 @@ class View: message: Optional[:class:`.Message`] The message that this view is attached to. If ``None`` then the view has not been sent with a message. + parent: Optional[:class:`.Interaction`] + The parent interaction which this view was sent from. + If ``None`` then the view was not sent using :meth:`InteractionResponse.send_message`. """ __discord_ui_view__: ClassVar[bool] = True @@ -187,6 +190,7 @@ def __init__( self.__timeout_task: asyncio.Task[None] | None = None self.__stopped: asyncio.Future[bool] = loop.create_future() self._message: Message | InteractionMessage | None = None + self.parent: Interaction | None = None def __repr__(self) -> str: return f"<{self.__class__.__name__} timeout={self.timeout} children={len(self.children)}>" @@ -363,9 +367,12 @@ async def on_timeout(self) -> None: A callback that is called when a view's timeout elapses without being explicitly stopped. """ if self.disable_on_timeout: - if self._message: - self.disable_all_items() - await self._message.edit(view=self) + self.disable_all_items() + message = self._message or self.parent + if message: + m = await message.edit(view=self) + if m: + self._message = m async def on_check_failure(self, interaction: Interaction) -> None: """|coro| @@ -438,6 +445,9 @@ def _dispatch_item(self, item: Item, interaction: Interaction): if self.__stopped.done(): return + if interaction.message: + self.message = interaction.message + asyncio.create_task( self._scheduled_task(item, interaction), name=f"discord-ui-view-dispatch-{self.id}", diff --git a/discord/user.py b/discord/user.py index 540032f0b9..c3dee8eeb7 100644 --- a/discord/user.py +++ b/discord/user.py @@ -31,7 +31,6 @@ from .asset import Asset from .colour import Colour -from .enums import DefaultAvatar from .flags import PublicUserFlags from .utils import MISSING, _bytes_to_base64_data, snowflake_time @@ -64,6 +63,7 @@ class BaseUser(_UserTag): "name", "id", "discriminator", + "global_name", "_avatar", "_banner", "_accent_colour", @@ -77,6 +77,7 @@ class BaseUser(_UserTag): name: str id: int discriminator: str + global_name: str | None bot: bool system: bool _state: ConnectionState @@ -90,6 +91,18 @@ def __init__(self, *, state: ConnectionState, data: UserPayload) -> None: self._update(data) def __repr__(self) -> str: + if self.is_migrated: + if self.global_name is not None: + return ( + "" + ) + return ( + "" + ) return ( " str: ) def __str__(self) -> str: - return f"{self.name}#{self.discriminator}" + return ( + f"{self.name}#{self.discriminator}" + if not self.is_migrated + else ( + f"{self.name} ({self.global_name})" + if self.global_name is not None + else self.name + ) + ) def __eq__(self, other: Any) -> bool: return isinstance(other, _UserTag) and other.id == self.id - def __ne__(self, other: Any) -> bool: - return not self.__eq__(other) - def __hash__(self) -> int: return self.id >> 22 @@ -112,6 +130,7 @@ def _update(self, data: UserPayload) -> None: self.name = data["username"] self.id = int(data["id"]) self.discriminator = data["discriminator"] + self.global_name = data.get("global_name", None) or None self._avatar = data["avatar"] self._banner = data.get("banner", None) self._accent_colour = data.get("accent_color", None) @@ -126,6 +145,7 @@ def _copy(cls: type[BU], user: BU) -> BU: self.name = user.name self.id = user.id self.discriminator = user.discriminator + self.global_name = user.global_name self._avatar = user._avatar self._banner = user._banner self._accent_colour = user._accent_colour @@ -141,6 +161,7 @@ def _to_minimal_user_json(self) -> dict[str, Any]: "id": self.id, "avatar": self._avatar, "discriminator": self.discriminator, + "global_name": self.global_name, "bot": self.bot, } @@ -171,11 +192,11 @@ def avatar(self) -> Asset | None: @property def default_avatar(self) -> Asset: """Returns the default avatar for a given user. - This is calculated by the user's discriminator. + This is calculated by the user's ID if they're on the new username system, otherwise their discriminator. """ - return Asset._from_default_avatar( - self._state, int(self.discriminator) % len(DefaultAvatar) - ) + eq = (self.id >> 22) if self.is_migrated else int(self.discriminator) + perc = 6 if self.is_migrated else 5 + return Asset._from_default_avatar(self._state, eq % perc) @property def display_avatar(self) -> Asset: @@ -264,12 +285,9 @@ def created_at(self) -> datetime: @property def display_name(self) -> str: """Returns the user's display name. - - For regular users this is just their username, but - if they have a guild specific nickname then that - is returned instead. + This will be their global name if set, otherwise their username. """ - return self.name + return self.global_name or self.name def mentioned_in(self, message: Message) -> bool: """Checks if the user is mentioned in the specified message. @@ -290,6 +308,11 @@ def mentioned_in(self, message: Message) -> bool: return any(user.id == self.id for user in message.mentions) + @property + def is_migrated(self) -> bool: + """Checks whether the user is already migrated to global name.""" + return self.discriminator == "0" + class ClientUser(BaseUser): """Represents your Discord user. @@ -310,7 +333,7 @@ class ClientUser(BaseUser): .. describe:: str(x) - Returns the user's name with discriminator. + Returns the user's name with discriminator or global_name. Attributes ---------- @@ -320,13 +343,20 @@ class ClientUser(BaseUser): The user's unique ID. discriminator: :class:`str` The user's discriminator. This is given when the username has conflicts. + + .. note:: + + If the user has migrated to the new username system, this will always be 0. + global_name: :class:`str` + The user's global name. + + .. versionadded:: 2.5 bot: :class:`bool` Specifies if the user is a bot account. system: :class:`bool` Specifies if the user is a system user (i.e. represents Discord officially). .. versionadded:: 1.3 - verified: :class:`bool` Specifies if the user's email is verified. locale: Optional[:class:`str`] @@ -347,6 +377,18 @@ def __init__(self, *, state: ConnectionState, data: UserPayload) -> None: super().__init__(state=state, data=data) def __repr__(self) -> str: + if self.is_migrated: + if self.global_name is not None: + return ( + "" + ) + return ( + "" + ) return ( " None: self._flags = data.get("flags", 0) self.mfa_enabled = data.get("mfa_enabled", False) + # TODO: Username might not be able to edit anymore. async def edit( self, *, username: str = MISSING, avatar: bytes = MISSING ) -> ClientUser: @@ -432,7 +475,7 @@ class User(BaseUser, discord.abc.Messageable): .. describe:: str(x) - Returns the user's name with discriminator. + Returns the user's name with discriminator or global_name. Attributes ---------- @@ -442,6 +485,14 @@ class User(BaseUser, discord.abc.Messageable): The user's unique ID. discriminator: :class:`str` The user's discriminator. This is given when the username has conflicts. + + .. note:: + + If the user has migrated to the new username system, this will always be "0". + global_name: :class:`str` + The user's global name. + + .. versionadded:: 2.5 bot: :class:`bool` Specifies if the user is a bot account. system: :class:`bool` @@ -455,6 +506,13 @@ def __init__(self, *, state: ConnectionState, data: UserPayload) -> None: self._stored: bool = False def __repr__(self) -> str: + if self.is_migrated: + if self.global_name is not None: + return ( + "" + ) + return "" return ( "" diff --git a/discord/utils.py b/discord/utils.py index 20add93227..7e41b6d04f 100644 --- a/discord/utils.py +++ b/discord/utils.py @@ -66,11 +66,11 @@ from .errors import HTTPException, InvalidArgument try: - import orjson + import msgspec except ModuleNotFoundError: - HAS_ORJSON = False + HAS_MSGSPEC = False else: - HAS_ORJSON = True + HAS_MSGSPEC = True __all__ = ( @@ -142,6 +142,7 @@ def __get__(self, instance, owner): from .abc import Snowflake from .commands.context import AutocompleteContext + from .commands.options import OptionChoice from .invite import Invite from .permissions import Permissions from .template import Template @@ -156,6 +157,7 @@ class _RequestLike(Protocol): else: cached_property = _cached_property AutocompleteContext = Any + OptionChoice = Any T = TypeVar("T") @@ -660,12 +662,12 @@ def _bytes_to_base64_data(data: bytes) -> str: return fmt.format(mime=mime, data=b64) -if HAS_ORJSON: +if HAS_MSGSPEC: def _to_json(obj: Any) -> str: # type: ignore - return orjson.dumps(obj).decode("utf-8") + return msgspec.json.encode(obj).decode("utf-8") - _from_json = orjson.loads # type: ignore + _from_json = msgspec.json.decode # type: ignore else: @@ -1298,7 +1300,7 @@ def generate_snowflake(dt: datetime.datetime | None = None) -> int: return int(dt.timestamp() * 1000 - DISCORD_EPOCH) << 22 | 0x3FFFFF -V = Union[Iterable[str], Iterable[int], Iterable[float]] +V = Union[Iterable[OptionChoice], Iterable[str], Iterable[int], Iterable[float]] AV = Awaitable[V] Values = Union[V, Callable[[AutocompleteContext], Union[V, AV]], AV] AutocompleteFunc = Callable[[AutocompleteContext], AV] diff --git a/discord/voice_client.py b/discord/voice_client.py index 110977d214..437311122a 100644 --- a/discord/voice_client.py +++ b/discord/voice_client.py @@ -624,7 +624,11 @@ def get_ssrc(self, user_id): ] def play( - self, source: AudioSource, *, after: Callable[[Exception | None], Any] = None + self, + source: AudioSource, + *, + after: Callable[[Exception | None], Any] = None, + wait_finish: bool = False, ) -> None: """Plays an :class:`AudioSource`. @@ -643,6 +647,14 @@ def play( The finalizer that is called after the stream is exhausted. This function must have a single parameter, ``error``, that denotes an optional exception that was raised during playing. + wait_finish: bool + If True, an awaitable will be returned, which can be used to wait for + audio to stop playing. This awaitable will return an exception if raised, + or None when no exception is raised. + + If False, None is returned and the function does not block. + + .. versionadded:: v2.5 Raises ------ @@ -668,8 +680,22 @@ def play( if not self.encoder and not source.is_opus(): self.encoder = opus.Encoder() + future = None + if wait_finish: + future = asyncio.Future() + after_callback = after + + def _after(exc: Exception | None): + if callable(after_callback): + after_callback(exc) + + future.set_result(exc) + + after = _after + self._player = AudioPlayer(source, self, after=after) self._player.start() + return future def unpack_audio(self, data): """Takes an audio packet received from Discord and decodes it into pcm audio data. @@ -700,7 +726,7 @@ def unpack_audio(self, data): self.decoder.decode(data) - def start_recording(self, sink, callback, *args): + def start_recording(self, sink, callback, *args, sync_start: bool = False): """The bot will begin recording audio from the current voice channel it is in. This function uses a thread so the current code line will not be stopped. Must be in a voice channel to use. @@ -716,6 +742,9 @@ def start_recording(self, sink, callback, *args): A function which is called after the bot has stopped recording. *args: Args which will be passed to the callback function. + sync_start: :class:`bool` + If True, the recordings of subsequent users will start with silence. + This is useful for recording audio just as it was heard. Raises ------ @@ -738,6 +767,7 @@ def start_recording(self, sink, callback, *args): self.decoder = opus.DecodeManager(self) self.decoder.start() self.recording = True + self.sync_start = sync_start self.sink = sink sink.init(self) @@ -796,8 +826,9 @@ def recv_audio(self, sink, callback, *args): # it by user, handles pcm files and # silence that should be added. - self.user_timestamps = {} + self.user_timestamps: dict[int, tuple[int, float]] = {} self.starting_time = time.perf_counter() + self.first_packet_timestamp: float while self.recording: ready, _, err = select.select([self.socket], [], [self.socket], 0.01) if not ready: @@ -815,27 +846,46 @@ def recv_audio(self, sink, callback, *args): self.stopping_time = time.perf_counter() self.sink.cleanup() - callback = asyncio.run_coroutine_threadsafe( - callback(self.sink, *args), self.loop - ) + callback = asyncio.run_coroutine_threadsafe(callback(sink, *args), self.loop) result = callback.result() if result is not None: print(result) - def recv_decoded_audio(self, data): - if data.ssrc not in self.user_timestamps: - self.user_timestamps.update({data.ssrc: data.timestamp}) - # Add silence when they were not being recorded. - silence = 0 - else: - silence = data.timestamp - self.user_timestamps[data.ssrc] - 960 - self.user_timestamps[data.ssrc] = data.timestamp + def recv_decoded_audio(self, data: RawData): + # Add silence when they were not being recorded. + if data.ssrc not in self.user_timestamps: # First packet from user + if ( + not self.user_timestamps or not self.sync_start + ): # First packet from anyone + self.first_packet_timestamp = data.receive_time + silence = 0 + + else: # Previously received a packet from someone else + silence = ( + (data.receive_time - self.first_packet_timestamp) * 48000 + ) - 960 + + else: # Previously received a packet from user + dRT = ( + data.receive_time - self.user_timestamps[data.ssrc][1] + ) * 48000 # delta receive time + dT = data.timestamp - self.user_timestamps[data.ssrc][0] # delta timestamp + diff = abs(100 - dT * 100 / dRT) + if ( + diff > 60 and dT != 960 + ): # If the difference in change is more than 60% threshold + silence = dRT - 960 + else: + silence = dT - 960 + + self.user_timestamps.update({data.ssrc: (data.timestamp, data.receive_time)}) data.decoded_data = ( - struct.pack(" ExecuteWebhookParameters: if files is not MISSING and file is not MISSING: raise TypeError("Cannot mix file and files keyword arguments.") @@ -648,8 +650,9 @@ def handle_message_parameters( payload["avatar_url"] = str(avatar_url) if username: payload["username"] = username - if ephemeral: - payload["flags"] = 64 + + flags = MessageFlags(suppress_embeds=suppress, ephemeral=ephemeral) + payload["flags"] = flags.value if allowed_mentions: if previous_allowed_mentions is not None: @@ -827,6 +830,7 @@ async def edit( attachments: list[Attachment] = MISSING, view: View | None = MISSING, allowed_mentions: AllowedMentions | None = None, + suppress: bool | None = MISSING, ) -> WebhookMessage: """|coro| @@ -868,6 +872,8 @@ async def edit( the view is removed. .. versionadded:: 2.0 + suppress: Optional[:class:`bool`] + Whether to suppress embeds for the message. Returns ------- @@ -898,6 +904,9 @@ async def edit( if attachments is MISSING: attachments = self.attachments or MISSING + if suppress is MISSING: + suppress = self.flags.suppress_embeds + return await self._state._webhook.edit_message( self.id, content=content, @@ -909,6 +918,7 @@ async def edit( view=view, allowed_mentions=allowed_mentions, thread=thread, + suppress=suppress, ) async def delete(self, *, delay: float | None = None) -> None: @@ -1267,6 +1277,7 @@ def _as_follower(cls, data, *, channel, user) -> Webhook: "user": { "username": user.name, "discriminator": user.discriminator, + "global_name": user.global_name, "id": user.id, "avatar": user._avatar, }, @@ -1556,6 +1567,7 @@ async def send( thread: Snowflake = MISSING, thread_name: str | None = None, wait: Literal[True], + delete_after: float = None, ) -> WebhookMessage: ... @@ -1577,6 +1589,7 @@ async def send( thread: Snowflake = MISSING, thread_name: str | None = None, wait: Literal[False] = ..., + delete_after: float = None, ) -> None: ... @@ -1844,6 +1857,7 @@ async def edit_message( view: View | None = MISSING, allowed_mentions: AllowedMentions | None = None, thread: Snowflake | None = MISSING, + suppress: bool = False, ) -> WebhookMessage: """|coro| @@ -1891,6 +1905,8 @@ async def edit_message( .. versionadded:: 2.0 thread: Optional[:class:`~discord.abc.Snowflake`] The thread that contains the message. + suppress: :class:`bool` + Whether to suppress embeds for the message. Returns ------- @@ -1938,6 +1954,7 @@ async def edit_message( view=view, allowed_mentions=allowed_mentions, previous_allowed_mentions=previous_mentions, + suppress=suppress, ) thread_id: int | None = None diff --git a/discord/webhook/sync.py b/discord/webhook/sync.py index 042130ac25..9e812b5709 100644 --- a/discord/webhook/sync.py +++ b/discord/webhook/sync.py @@ -472,6 +472,7 @@ def edit( file: File = MISSING, files: list[File] = MISSING, allowed_mentions: AllowedMentions | None = None, + suppress: bool | None = MISSING, ) -> SyncWebhookMessage: """Edits the message. @@ -492,6 +493,8 @@ def edit( allowed_mentions: :class:`AllowedMentions` Controls the mentions being processed in this message. See :meth:`.abc.Messageable.send` for more information. + suppress: Optional[:class:`bool`] + Whether to suppress embeds for the message. Returns ------- @@ -517,6 +520,9 @@ def edit( elif isinstance(self.channel, Thread): thread = Object(self.channel.id) + if suppress is MISSING: + suppress = self.flags.suppress_embeds + return self._state._webhook.edit_message( self.id, content=content, @@ -526,6 +532,7 @@ def edit( files=files, allowed_mentions=allowed_mentions, thread=thread, + suppress=suppress, ) def delete(self, *, delay: float | None = None) -> None: @@ -952,6 +959,7 @@ def send( thread: Snowflake = MISSING, thread_name: str | None = None, wait: Literal[False] = ..., + suppress: bool = MISSING, ) -> None: ... @@ -970,6 +978,7 @@ def send( thread: Snowflake = MISSING, thread_name: str | None = None, wait: bool = False, + suppress: bool = False, ) -> SyncWebhookMessage | None: """Sends a message using the webhook. @@ -1022,6 +1031,8 @@ def send( The name of the thread to create. Only works for forum channels. .. versionadded:: 2.0 + suppress: :class:`bool` + Whether to suppress embeds for the message. Returns ------- @@ -1070,6 +1081,7 @@ def send( embeds=embeds, allowed_mentions=allowed_mentions, previous_allowed_mentions=previous_mentions, + suppress=suppress, ) adapter: WebhookAdapter = _get_webhook_adapter() thread_id: int | None = None @@ -1151,6 +1163,7 @@ def edit_message( files: list[File] = MISSING, allowed_mentions: AllowedMentions | None = None, thread: Snowflake | None = MISSING, + suppress: bool = False, ) -> SyncWebhookMessage: """Edits a message owned by this webhook. @@ -1211,6 +1224,7 @@ def edit_message( embeds=embeds, allowed_mentions=allowed_mentions, previous_allowed_mentions=previous_mentions, + suppress=suppress, ) adapter: WebhookAdapter = _get_webhook_adapter() diff --git a/discord/widget.py b/discord/widget.py index 257846092a..b6bcf0f013 100644 --- a/discord/widget.py +++ b/discord/widget.py @@ -202,6 +202,16 @@ def __init__( self.connected_channel: WidgetChannel | None = connected_channel def __repr__(self) -> str: + if self.is_migrated: + if self.global_name is not None: + return ( + f"" + ) + return ( + f"" + ) return ( f"" @@ -210,7 +220,9 @@ def __repr__(self) -> str: @property def display_name(self) -> str: """Returns the member's display name.""" - return self.nick or self.name + return self.nick or ( + self.global_name or self.name if self.is_migrated else self.name + ) class Widget: diff --git a/docs/api/data_classes.rst b/docs/api/data_classes.rst index f05b38d646..ab9e8d678d 100644 --- a/docs/api/data_classes.rst +++ b/docs/api/data_classes.rst @@ -83,6 +83,16 @@ Embed .. autoclass:: EmbedFooter :members: +.. attributetable:: EmbedMedia + +.. autoclass:: EmbedMedia + :members: + +.. attributetable:: EmbedProvider + +.. autoclass:: EmbedProvider + :members: + Flags diff --git a/docs/api/enums.rst b/docs/api/enums.rst index 49c105878f..e2620ed8e5 100644 --- a/docs/api/enums.rst +++ b/docs/api/enums.rst @@ -1512,6 +1512,86 @@ of :class:`enum.Enum`. .. versionadded:: 2.0 + .. attribute:: auto_moderation_rule_create + + A guild auto moderation rule was created. + + Possible attributes for :class:`AuditLogDiff`: + + - :attr:`~AuditLogDiff.name` + - :attr:`~AuditLogDiff.enabled` + - :attr:`~AuditLogDiff.trigger_type` + - :attr:`~AuditLogDiff.event_type` + - :attr:`~AuditLogDiff.trigger_metadata` + - :attr:`~AuditLogDiff.actions` + - :attr:`~AuditLogDiff.exempt_roles` + - :attr:`~AuditLogDiff.exempt_channels` + + .. versionadded:: 2.5 + + .. attribute:: auto_moderation_rule_update + + A guild auto moderation rule was updated. + + Possible attributes for :class:`AuditLogDiff`: + + - :attr:`~AuditLogDiff.name` + - :attr:`~AuditLogDiff.enabled` + - :attr:`~AuditLogDiff.trigger_type` + - :attr:`~AuditLogDiff.trigger_metadata` + - :attr:`~AuditLogDiff.actions` + - :attr:`~AuditLogDiff.exempt_roles` + - :attr:`~AuditLogDiff.exempt_channels` + + .. versionadded:: 2.5 + + .. attribute:: auto_moderation_rule_delete + + A guild auto moderation rule was deleted. + + Possible attributes for :class:`AuditLogDiff`: + + - :attr:`~AuditLogDiff.name` + - :attr:`~AuditLogDiff.enabled` + - :attr:`~AuditLogDiff.trigger_type` + - :attr:`~AuditLogDiff.event_type` + - :attr:`~AuditLogDiff.trigger_metadata` + - :attr:`~AuditLogDiff.actions` + - :attr:`~AuditLogDiff.exempt_roles` + - :attr:`~AuditLogDiff.exempt_channels` + + .. versionadded:: 2.5 + + .. attribute:: auto_moderation_block_message + + A message was blocked by auto moderation. + + .. versionadded:: 2.5 + + .. attribute:: auto_moderation_flag_to_channel + + A message was flagged by auto moderation. + + .. versionadded:: 2.5 + + .. attribute:: auto_moderation_user_communication_disabled + + A member was timed out by auto moderation. + + .. versionadded:: 2.5 + + .. attribute:: creator_monetization_request_created + + A creator monetization request was created. + + .. versionadded:: 2.5 + + .. attribute:: creator_monetization_terms_accepted + + The creator monetization terms were accepted. + + .. versionadded:: 2.5 + .. class:: AuditLogActionCategory @@ -1878,6 +1958,18 @@ of :class:`enum.Enum`. Represents the embedded application Fishington.io. + .. attribute:: gartic_phone + + Represents the embedded application Gartic Phone. + + .. versionadded:: 2.5 + + .. attribute:: jamspace + + Represents the embedded application Jamspace. + + .. versionadded:: 2.5 + .. attribute:: know_what_i_meme Represents the embedded application Know What I Meme. diff --git a/docs/api/events.rst b/docs/api/events.rst index 4903ae8f92..2eb92c19b5 100644 --- a/docs/api/events.rst +++ b/docs/api/events.rst @@ -7,11 +7,11 @@ Event Reference This section outlines the different types of events listened by :class:`Client`. -There are 4 ways to register an event, the first way is through the use of +There are 3 ways to register an event, the first way is through the use of :meth:`Client.event`. The second way is through subclassing :class:`Client` and overriding the specific events. The third way is through the use of :meth:`Client.listen`, which can be used to assign multiple event handlers instead of only one like in :meth:`Client.event`. -The fourth way is through the use of :meth:`Client.once`, which serves as a one-time event listener. For example: +For example: .. code-block:: python :emphasize-lines: 17, 22 @@ -41,10 +41,10 @@ The fourth way is through the use of :meth:`Client.once`, which serves as a one- async def on_message(message: discord.Message): print(f"Received {message.content}") - # Runs only for the 1st 'on_message' event. Can be useful for listening to 'on_ready' - @client.once() - async def message(message: discord.Message): - print(f"Received {message.content}") + # Runs only for the 1st event dispatch. Can be useful for listening to 'on_ready' + @client.listen(once=True) + async def on_ready(): + print("Client is ready!") If an event handler raises an exception, :func:`on_error` will be called @@ -735,6 +735,7 @@ Members/Users - avatar - username - discriminator + - global_name This requires :attr:`Intents.members` to be enabled. diff --git a/docs/ext/bridge/api.rst b/docs/ext/bridge/api.rst index 327e6600fb..d8f4d78821 100644 --- a/docs/ext/bridge/api.rst +++ b/docs/ext/bridge/api.rst @@ -151,3 +151,9 @@ BridgeContext Subclasses .. autoclass:: discord.ext.bridge.BridgeExtContext :members: + +.. attributetable:: discord.ext.bridge.Context + +.. data:: discord.ext.bridge.Context + + Alias of :data:`typing.Union` [ :class:`.BridgeExtContext`, :class:`.BridgeApplicationContext` ] for typing convenience. diff --git a/examples/audio_recording_merged.py b/examples/audio_recording_merged.py new file mode 100644 index 0000000000..9148a67a43 --- /dev/null +++ b/examples/audio_recording_merged.py @@ -0,0 +1,113 @@ +import io + +import pydub # pip install pydub==0.25.1 + +import discord +from discord.sinks import MP3Sink + +bot = discord.Bot() + + +@bot.event +async def on_ready(): + print(f"Logged in as {bot.user}") + + +async def finished_callback(sink: MP3Sink, channel: discord.TextChannel): + mention_strs = [] + audio_segs: list[pydub.AudioSegment] = [] + files: list[discord.File] = [] + + longest = pydub.AudioSegment.empty() + + for user_id, audio in sink.audio_data.items(): + mention_strs.append(f"<@{user_id}>") + + seg = pydub.AudioSegment.from_file(audio.file, format="mp3") + + # Determine the longest audio segment + if len(seg) > len(longest): + audio_segs.append(longest) + longest = seg + else: + audio_segs.append(seg) + + audio.file.seek(0) + files.append(discord.File(audio.file, filename=f"{user_id}.mp3")) + + for seg in audio_segs: + longest = longest.overlay(seg) + + with io.BytesIO() as f: + longest.export(f, format="mp3") + await channel.send( + f"Finished! Recorded audio for {', '.join(mention_strs)}.", + files=files + [discord.File(f, filename="recording.mp3")], + ) + + +@bot.command() +async def join(ctx: discord.ApplicationContext): + """Join the voice channel!""" + voice = ctx.author.voice + + if not voice: + return await ctx.respond("You're not in a vc right now") + + await voice.channel.connect() + + await ctx.respond("Joined!") + + +@bot.command() +async def start(ctx: discord.ApplicationContext): + """Record the voice channel!""" + voice = ctx.author.voice + + if not voice: + return await ctx.respond("You're not in a vc right now") + + vc: discord.VoiceClient = ctx.voice_client + + if not vc: + return await ctx.respond( + "I'm not in a vc right now. Use `/join` to make me join!" + ) + + vc.start_recording( + MP3Sink(), + finished_callback, + ctx.channel, + sync_start=True, # WARNING: This feature is very unstable and may break at any time. + ) + + await ctx.respond("The recording has started!") + + +@bot.command() +async def stop(ctx: discord.ApplicationContext): + """Stop the recording""" + vc: discord.VoiceClient = ctx.voice_client + + if not vc: + return await ctx.respond("There's no recording going on right now") + + vc.stop_recording() + + await ctx.respond("The recording has stopped!") + + +@bot.command() +async def leave(ctx: discord.ApplicationContext): + """Leave the voice channel!""" + vc: discord.VoiceClient = ctx.voice_client + + if not vc: + return await ctx.respond("I'm not in a vc right now") + + await vc.disconnect() + + await ctx.respond("Left!") + + +bot.run("TOKEN") diff --git a/examples/converters.py b/examples/converters.py index 4d80f5fe86..bbf0fb9e51 100644 --- a/examples/converters.py +++ b/examples/converters.py @@ -21,7 +21,7 @@ async def userinfo(ctx: commands.Context, user: discord.User): # the value passed as `user` to a `discord.User` instance. # The documentation notes what can be converted and, in the case of `discord.User`, # you pass an ID, mention or username (discriminator optional) - # E.g. 80088516616269824, @Danny or Danny#0007 + # E.g. 90339695967350784, @Desch or Desch#3091 # NOTE: Type hinting acts as a converter within the `commands` framework only. # In standard Python, it is used for documentation and IDE assistance purposes. diff --git a/requirements/dev.txt b/requirements/dev.txt index 696ec91da3..2e9fac2a7c 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,11 +1,11 @@ -r _.txt -pylint~=2.17.2 -pytest~=7.3.1 -pytest-asyncio~=0.21.0 +pylint~=2.17.5 +pytest~=7.4.0 +pytest-asyncio~=0.21.1 # pytest-order~=1.0.1 -mypy~=1.2.0 +mypy~=1.5.0 coverage~=7.2 -pre-commit==3.2.2 -codespell==2.2.4 -bandit==1.7.4 -flake8==6.0.0 +pre-commit==3.3.3 +codespell==2.2.5 +bandit==1.7.5 +flake8==6.1.0 diff --git a/requirements/speed.txt b/requirements/speed.txt index f3830485ba..32c37acb7a 100644 --- a/requirements/speed.txt +++ b/requirements/speed.txt @@ -1,2 +1,2 @@ -orjson>=3.5.4 +msgspec~=0.18.0 aiohttp[speedups] diff --git a/tests/test_typing_annotated.py b/tests/test_typing_annotated.py new file mode 100644 index 0000000000..582bd4f8a0 --- /dev/null +++ b/tests/test_typing_annotated.py @@ -0,0 +1,86 @@ +from typing import Optional + +import pytest +from typing_extensions import Annotated + +import discord +from discord import ApplicationContext +from discord.commands.core import SlashCommand, slash_command + + +def test_typing_annotated(): + async def echo(ctx, txt: Annotated[str, discord.Option()]): + await ctx.respond(txt) + + cmd = SlashCommand(echo) + bot = discord.Bot() + bot.add_application_command(cmd) + + +def test_typing_annotated_decorator(): + bot = discord.Bot() + + @bot.slash_command() + async def echo(ctx, txt: Annotated[str, discord.Option(description="Some text")]): + await ctx.respond(txt) + + +def test_typing_annotated_cog(): + class echoCog(discord.Cog): + def __init__(self, bot_) -> None: + self.bot = bot_ + super().__init__() + + @slash_command() + async def echo( + self, ctx, txt: Annotated[str, discord.Option(description="Some text")] + ): + await ctx.respond(txt) + + bot = discord.Bot() + bot.add_cog(echoCog(bot)) + + +def test_typing_annotated_cog_slashgroup(): + class echoCog(discord.Cog): + grp = discord.commands.SlashCommandGroup("echo") + + def __init__(self, bot_) -> None: + self.bot = bot_ + super().__init__() + + @grp.command() + async def echo( + self, ctx, txt: Annotated[str, discord.Option(description="Some text")] + ): + await ctx.respond(txt) + + bot = discord.Bot() + bot.add_cog(echoCog(bot)) + + +def test_typing_annotated_optional(): + async def echo(ctx, txt: Annotated[Optional[str], discord.Option()]): + await ctx.respond(txt) + + cmd = SlashCommand(echo) + bot = discord.Bot() + bot.add_application_command(cmd) + + +def test_no_annotation(): + async def echo(ctx, txt: str): + await ctx.respond(txt) + + cmd = SlashCommand(echo) + bot = discord.Bot() + bot.add_application_command(cmd) + + +def test_annotated_no_option(): + async def echo(ctx, txt: Annotated[str, "..."]): + await ctx.respond(txt) + + cmd = SlashCommand(echo) + bot = discord.Bot() + bot.add_application_command(cmd)