diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index aa77843552..9c500580ee 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,6 +17,15 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + # Python 3.8/3.9 are on macos-13 but not macos-latest (macos-14-arm64) + # https://github.com/actions/setup-python/issues/696#issuecomment-1637587760 + exclude: + - { python-version: "3.8", os: "macos-latest" } + - { python-version: "3.9", os: "macos-latest" } + include: + - { python-version: "3.8", os: "macos-13" } + - { python-version: "3.9", os: "macos-13" } + env: OS: ${{ matrix.os }} PYTHON: ${{ matrix.python-version }} diff --git a/.github/workflows/todo.yml b/.github/workflows/todo.yml index 508f34218f..08271e51e8 100644 --- a/.github/workflows/todo.yml +++ b/.github/workflows/todo.yml @@ -6,7 +6,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Run tdg-github-action - uses: ribtoks/tdg-github-action@v0.4.7-beta + uses: ribtoks/tdg-github-action@v0.4.11-beta with: TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index da4fc0f3b8..ab0e6556f8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,12 +4,12 @@ ci: repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v4.6.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - repo: https://github.com/PyCQA/autoflake - rev: v2.2.1 + rev: v2.3.1 hooks: - id: autoflake # args: @@ -19,7 +19,7 @@ repos: # - --remove-duplicate-keys # - --remove-unused-variables - repo: https://github.com/asottile/pyupgrade - rev: v3.15.0 + rev: v3.16.0 hooks: - id: pyupgrade args: [--py38-plus] @@ -28,7 +28,7 @@ repos: hooks: - id: isort - repo: https://github.com/psf/black - rev: 23.12.1 + rev: 24.4.2 hooks: - id: black args: [--safe, --quiet] diff --git a/CHANGELOG.md b/CHANGELOG.md index 3643ef35f1..4712e2edbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,34 +10,143 @@ possible (see our [Version Guarantees] for more info). These changes are available on the `master` branch, but have not yet been released. +## [2.6.0] - 2024-07-09 + +### Added + +- Added `banner` parameter to `ClientUser.edit`. + ([#2396](https://github.com/Pycord-Development/pycord/pull/2396)) +- Added `user` argument to `Paginator.edit`. + ([#2390](https://github.com/Pycord-Development/pycord/pull/2390)) +- Added `bridge_option` decorator. Required for `bridge.Bot` in 2.7. + ([#2417](https://github.com/Pycord-Development/pycord/pull/2417)) +- Added `Guild.search_members`. + ([#2418](https://github.com/Pycord-Development/pycord/pull/2418)) +- Added bulk banning up to 200 users through `Guild.bulk_ban`. + ([#2421](https://github.com/Pycord-Development/pycord/pull/2421)) +- Added `member` data to the `raw_reaction_remove` event. + ([#2412](https://github.com/Pycord-Development/pycord/pull/2412)) +- Added `Poll` and all related features. + ([#2408](https://github.com/Pycord-Development/pycord/pull/2408)) +- Added `stacklevel` param to `utils.warn_deprecated` and `utils.deprecated`. + ([#2450](https://github.com/Pycord-Development/pycord/pull/2450)) +- Added support for user-installable applications. + ([#2409](https://github.com/Pycord-Development/pycord/pull/2409)) +- Added support for one-time purchases for Discord monetization. + ([#2438](https://github.com/Pycord-Development/pycord/pull/2438)) +- Added `Attachment.title`. + ([#2486](https://github.com/Pycord-Development/pycord/pull/2486)) +- Added `MemberFlags`. ([#2489](https://github.com/Pycord-Development/pycord/pull/2489)) +- Added `bypass_verification` parameter to `Member.edit`. + ([#2489](https://github.com/Pycord-Development/pycord/pull/2489)) +- Added `RoleFlags`. ([#2487](https://github.com/Pycord-Development/pycord/pull/2487)) +- Added `MessageCall` information. + ([#2488](https://github.com/Pycord-Development/pycord/pull/2488)) + +### Fixed + +- Fixed the type-hinting of `Member.move_to` and `Member.edit` to reflect actual + behavior. ([#2386](https://github.com/Pycord-Development/pycord/pull/2386)) +- Fixed a deprecation warning from being displayed when running `python -m discord -v` + by replacing the deprecated module. + ([#2392](https://github.com/Pycord-Development/pycord/pull/2392)) +- Fixed `Paginator.edit` to no longer set user to the bot. + ([#2390](https://github.com/Pycord-Development/pycord/pull/2390)) +- Fixed `NameError` in some instances of `Interaction`. + ([#2402](https://github.com/Pycord-Development/pycord/pull/2402)) +- Fixed interactions being ignored due to `PartialMessage.id` being of type `str`. + ([#2406](https://github.com/Pycord-Development/pycord/pull/2406)) +- Fixed the type-hinting of `ScheduledEvent.subscribers` to reflect actual behavior. + ([#2400](https://github.com/Pycord-Development/pycord/pull/2400)) +- Fixed `ScheduledEvent.subscribers` behavior with `limit=None`. + ([#2407](https://github.com/Pycord-Development/pycord/pull/2407)) +- Fixed invalid data being passed to `Interaction._guild` in certain cases. + ([#2411](https://github.com/Pycord-Development/pycord/pull/2411)) +- Fixed option typehints being ignored when using `parameter_name`. + ([#2417](https://github.com/Pycord-Development/pycord/pull/2417)) +- Fixed parameter `embed=None` causing `AttributeError` on `PartialMessage.edit`. + ([#2446](https://github.com/Pycord-Development/pycord/pull/2446)) +- Fixed paginator to revert state if a page update callback fails. + ([#2448](https://github.com/Pycord-Development/pycord/pull/2448)) +- Fixed missing `application_id` in `Entitlement.delete`. + ([#2458](https://github.com/Pycord-Development/pycord/pull/2458)) +- Fixed issues with enums as `Option` types with long descriptions or too many values. + ([#2463](https://github.com/Pycord-Development/pycord/pull/2463)) +- Fixed many inaccurate type hints throughout the library. + ([#2457](https://github.com/Pycord-Development/pycord/pull/2457)) +- Fixed `AttributeError` due to `discord.Option` being initialised with `input_type` set + to `None`. ([#2464](https://github.com/Pycord-Development/pycord/pull/2464)) +- Fixed `remove_application_command` causing issues while reloading extensions. + ([#2480](https://github.com/Pycord-Development/pycord/pull/2480)) +- Fixed outdated logic for filtering and sorting audit log entries. + ([#2371](https://github.com/Pycord-Development/pycord/pull/2371)) +- Further fixed logic when fetching audit logs. + ([#2492](https://github.com/Pycord-Development/pycord/pull/2492)) + +### Changed + +- Changed the type of `Guild.bitrate_limit` to `int`. + ([#2387](https://github.com/Pycord-Development/pycord/pull/2387)) +- HTTP requests that fail with a 503 status are now re-tried. + ([#2395](https://github.com/Pycord-Development/pycord/pull/2395)) +- `option` decorator now accepts `input_type`. + ([#2417](https://github.com/Pycord-Development/pycord/pull/2417)) +- `Option` may be used instead of `BridgeOption` until 2.7. + ([#2417](https://github.com/Pycord-Development/pycord/pull/2417)) +- `Guild.query_members` now accepts `limit=None` to retrieve all members. + ([#2419](https://github.com/Pycord-Development/pycord/pull/2419)) +- `ApplicationCommand.guild_only` is now deprecated in favor of + `ApplicationCommand.contexts`. + ([#2409](https://github.com/Pycord-Development/pycord/pull/2409)) +- `Message.interaction` is now deprecated in favor of `Message.interaction_metadata`. + ([#2409](https://github.com/Pycord-Development/pycord/pull/2409)) +- Replaced `Client.fetch_entitlements` with `Client.entitlements`, which returns an + `EntitlementIterator`. + ([#2490](https://github.com/Pycord-Development/pycord/pull/2490)) +- Changed the error message that appears when attempting to add a subcommand group to a + subcommand group. ([#2275](https://github.com/Pycord-Development/pycord/pull/2275)) + +### Removed + +- Removed the `delete_message_days` parameter from ban methods. Please use + `delete_message_seconds` instead. + ([#2421](https://github.com/Pycord-Development/pycord/pull/2421)) +- Removed the `oldest_first` parameter from `Guild.audit_logs` in favor of the `before` + and `after` parameters. + ([#2371](https://github.com/Pycord-Development/pycord/pull/2371)) +- Removed the `vanity_code` parameter from `Guild.edit`. + ([#2491](https://github.com/Pycord-Development/pycord/pull/2491)) + +## [2.5.0] - 2024-03-02 + ### Added -- Added possibility to start bot via async context manager. +- Added method to start bot via async context manager. ([#1801](https://github.com/Pycord-Development/pycord/pull/1801)) -- 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 +- Added parameters `author`, `footer`, `image` and `thumbnail` to `discord.Embed` + initializer. ([#1996](https://github.com/Pycord-Development/pycord/pull/1996)) +- Added events `on_bridge_command`, `on_bridge_command_completion`, and `on_bridge_command_error`. ([#1916](https://github.com/Pycord-Development/pycord/pull/1916)) - Added the `@client.once()` decorator, which serves as a one-time event listener. ([#1940](https://github.com/Pycord-Development/pycord/pull/1940)) - Added support for text-related features in `StageChannel`. ([#1936](https://github.com/Pycord-Development/pycord/pull/1936)) -- Added support for one-time event listeners in `@client.listen()`. +- Added support for one-time event listeners in `Client.listen`. ([#1957](https://github.com/Pycord-Development/pycord/pull/1957)) - Added `current_page` argument to `Paginator.update()`. ([#1983](https://github.com/Pycord-Development/pycord/pull/1983)) -- Added new `application_auto_moderation_rule_create_badge` to `ApplicationFlags`. +- Added application flag `application_auto_moderation_rule_create_badge`. ([#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. +- Added support for recording silence via new `sync_start` argument in + `VoiceClient.start_recording()`. ([#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 +- Added `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. @@ -50,21 +159,20 @@ These changes are available on the `master` branch, but have not yet been releas - Added `view.parent` which is set when the view is 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. +- Added methods `bridge.Bot.walk_bridge_commands` and + `BridgeCommandGroup.walk_commands`. ([#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`. +- Added `icon` and `unicode_emoji` arguments to `Guild.create_role`. ([#2086](https://github.com/Pycord-Development/pycord/pull/2086)) -- Added `cooldown` and `max_concurrency` to `SlashCommandGroup`. +- Added `cooldown` and `max_concurrency` attributes to `SlashCommandGroup`. ([#2091](https://github.com/Pycord-Development/pycord/pull/2091)) -- Added new embedded activities, Gartic Phone and Jamspace. +- Added 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. +- Added `bridge.Context` type as a `Union` of subclasses. ([#2106](https://github.com/Pycord-Development/pycord/pull/2106)) -- Added Annotated forms support for type-hinting slash command options. +- Added support for type-hinting slash command options with `typing.Annotated`. ([#2124](https://github.com/Pycord-Development/pycord/pull/2124)) - Added `suppress` and `allowed_mentions` parameters to `Webhook` and `InteractionResponse` edit methods. @@ -79,18 +187,34 @@ These changes are available on the `master` branch, but have not yet been releas ([#2112](https://github.com/Pycord-Development/pycord/pull/2112)) - Added `ForumChannel.default_reaction_emoji` attribute. ([#2178](https://github.com/Pycord-Development/pycord/pull/2178)) -- Added `default_reaction_emoji` parameter to `Guild.create_forum_channel()` and - `ForumChannel.edit()` methods. +- Added `default_reaction_emoji` parameter to `Guild.create_forum_channel` and + `ForumChannel.edit` methods. ([#2178](https://github.com/Pycord-Development/pycord/pull/2178)) -- Added `applied_tags` parameter to `Webhook.send()` method. +- Added `applied_tags` parameter to `Webhook.send` method. ([#2322](https://github.com/Pycord-Development/pycord/pull/2322)) +- Added `User.avatar_decoration`. + ([#2131](https://github.com/Pycord-Development/pycord/pull/2131)) +- Added support for guild onboarding related features. + ([#2127](https://github.com/Pycord-Development/pycord/pull/2127)) +- Added support for monetization related objects and events. + ([#2273](https://github.com/Pycord-Development/pycord/pull/2273)) +- Added `AttachmentFlags` and attachment attributes `expires_at`, `issued_at` and `hm`. + ([#2342](https://github.com/Pycord-Development/pycord/pull/2342)) +- Added `invitable` and `slowmode_delay` to `Thread` creation methods. + ([#2350](https://github.com/Pycord-Development/pycord/pull/2350)) +- Added support for voice channel statuses. + ([#2368](https://github.com/Pycord-Development/pycord/pull/2368)) +- Added `enforce_nonce` parameter for message sending. + ([#2370](https://github.com/Pycord-Development/pycord/pull/2370)) +- Added audit log support for voice channel status. + ([#2373](https://github.com/Pycord-Development/pycord/pull/2373)) ### Changed -- Changed default for all `name_localizations` & `description_localizations` attributes - from being `None` to being `MISSING`. +- Changed default for all `name_localizations` and `description_localizations` + attributes from being `None` to being `MISSING`. ([#1866](https://github.com/Pycord-Development/pycord/pull/1866)) -- Changed FFMPEG output suppression when recording voice channels. +- Changed `ffmpeg` output suppression 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)) @@ -101,28 +225,29 @@ These changes are available on the `master` branch, but have not yet been releas ([#2025](https://github.com/Pycord-Development/pycord/pull/2025)) - Changed `DMChannel.recipients` to potentially be `None`. ([#2025](https://github.com/Pycord-Development/pycord/pull/2025)) -- Changed the behavior to store `view.message` when receiving an Interaction for a - component. ([#2036](https://github.com/Pycord-Development/pycord/pull/2036)) +- Changed the behavior to store `view.message` when receiving a component interaction, + while also changing `view.message` not to be set when sending view through + `InteractionResponse.send_message`. + ([#2036](https://github.com/Pycord-Development/pycord/pull/2036)) - Changed the fetching of attributes shared between text-based and Slash Commands in Bridge Commands to be dynamic. ([#1867](https://github.com/Pycord-Development/pycord/pull/1867)) -- Changed `discord.Embed` attributes (such as author, footer, etc.) now return `None` - when not set, and return their respective classes when set. +- `discord.Embed` attributes (such as author, footer, etc.) now return instances of + their respective classes when set and `None` otherwise. ([#2063](https://github.com/Pycord-Development/pycord/pull/2063)) -- Changed `default_avatar` behavior depending on the user's username migration status. +- Changed `default_avatar` behavior to depend on the user's username migration status. ([#2087](https://github.com/Pycord-Development/pycord/pull/2087)) -- Changed type-hinted `command_prefix` and `help_command` arguments properly. +- Changed type hints of `command_prefix` and `help_command` arguments to be accurate. ([#2099](https://github.com/Pycord-Development/pycord/pull/2099)) -- Changed the support from `orjson` to `msgspec` in the codebase. +- Replaced `orjson` features with `msgspec` in the codebase. ([#2170](https://github.com/Pycord-Development/pycord/pull/2170)) +- `BridgeOption` must now be used for arguments in bridge commands. + ([#2252](https://github.com/Pycord-Development/pycord/pull/2252)) ### Removed -- Removed `@client.once()` in favour of `@client.listen(once=True)`. +- Removed `Client.once` in favour of `once` argument in `Client.listen`. ([#1957](https://github.com/Pycord-Development/pycord/pull/1957)) -- 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)) @@ -131,8 +256,8 @@ These changes are available on the `master` branch, but have not yet been releas - Fixed `AttributeError` caused by [#1957](https://github.com/Pycord-Development/pycord/pull/1957) when using listeners in cogs. ([#1989](https://github.com/Pycord-Development/pycord/pull/1989)) -- Editing a webhook message if the thread is a forum post or if the thread is a private - thread ([#1981](https://github.com/Pycord-Development/pycord/pull/1981)) +- Fixed an issue in editing webhook messages in forum posts and private threads. + ([#1981](https://github.com/Pycord-Development/pycord/pull/1981)). - Fixed `View.message` not being set when view is sent using webhooks, including `Interaction.followup.send` or when a message is edited. ([#1997](https://github.com/Pycord-Development/pycord/pull/1997)) @@ -142,7 +267,7 @@ These changes are available on the `master` branch, but have not yet been releas ([#1998](https://github.com/Pycord-Development/pycord/pull/1998)) - Fixed boolean converter breaking for Bridge Commands. ([#1999](https://github.com/Pycord-Development/pycord/pull/1999)) -- Fixed Bridge Command Options not working. +- Fixed bridge command options not 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)) @@ -151,9 +276,9 @@ These changes are available on the `master` branch, but have not yet been releas - Fixed the functionality to override the default `on_application_command_error` behavior using listeners. ([#2044](https://github.com/Pycord-Development/pycord/pull/2044)) -- Fixed unloading of cogs having Bridge Commands. +- Fixed unloading of cogs with bridge commands. ([#2048](https://github.com/Pycord-Development/pycord/pull/2048)) -- Fixed the Slash Command synchronization method `individual`. +- Fixed the `individual` slash command synchronization method. ([#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)) @@ -171,7 +296,7 @@ These changes are available on the `master` branch, but have not yet been releas ([#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 +- Fixed type hinting of `author` property of `ApplicationContext` to include type-hinting of `User` or `Member`. ([#2148](https://github.com/Pycord-Development/pycord/pull/2148)) - Fixed missing `delete_after` parameter in overload type-hinting for `Webhook.send()`. @@ -180,16 +305,16 @@ These changes are available on the `master` branch, but have not yet been releas ([#2162](https://github.com/Pycord-Development/pycord/pull/2162)) - Fixed `_bytes_to_base64_data` not defined. ([#2185](https://github.com/Pycord-Development/pycord/pull/2185)) -- Fixed type-hinting of `values` argument of `basic_autocomplete` to include - type-hinting of `Iterable[OptionChoice]`. +- Fixed inaccurate `Union` type hint of `values` argument of `basic_autocomplete` to + include `Iterable[OptionChoice]`. ([#2164](https://github.com/Pycord-Development/pycord/pull/2164)) - 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)) -- Fixed `DMChannel.recipient` being `None` and consequently `User.dm_channel` also being - `None`. ([#2219](https://github.com/Pycord-Development/pycord/pull/2219)) -- Fixed ffmpeg being terminated prematurely when piping audio stream. +- Fixed `DMChannel.recipient` and `User.dm_channel` being `None`. + ([#2219](https://github.com/Pycord-Development/pycord/pull/2219)) +- Fixed `ffmpeg` being terminated prematurely when piping audio stream. ([#2240](https://github.com/Pycord-Development/pycord/pull/2240)) - Fixed tasks looping infinitely when `tzinfo` is neither `None` nor UTC. ([#2196](https://github.com/Pycord-Development/pycord/pull/2196)) @@ -213,6 +338,26 @@ These changes are available on the `master` branch, but have not yet been releas ([#2319](https://github.com/Pycord-Development/pycord/pull/2319)) - Fixed `AttributeError` when comparing application commands with non-command objects. ([#2299](https://github.com/Pycord-Development/pycord/issues/2299)) +- Fixed `AttributeError` when copying groups on startup. + ([#2331](https://github.com/Pycord-Development/pycord/issues/2331)) +- Fixed application command options causing errors if declared through the option + decorator or kwarg. + ([#2332](https://github.com/Pycord-Development/pycord/issues/2332)) +- Fixed options declared using the parameter default value syntax always being optional. + ([#2333](https://github.com/Pycord-Development/pycord/issues/2333)) +- Fixed `BridgeContext` type hints raising an exception for unsupported option type. + ([#2337](https://github.com/Pycord-Development/pycord/pull/2337)) +- Fixed `TypeError` due to `(Sync)WebhookMessage._thread_id` being set to `None`. + ([#2343](https://github.com/Pycord-Development/pycord/pull/2343)) +- Fixed `AttributeError` due to `entitlements` not being included in + `Interaction.__slots__`. + ([#2345](https://github.com/Pycord-Development/pycord/pull/2345)) +- Fixed `Thread.me` being out of date and added the thread owner to `Thread.members` on + creation. ([#1296](https://github.com/Pycord-Development/pycord/issues/1296)) +- Fixed keyword argument wildcard of `bridge.has_permissions` having the wrong type + hint. ([#2364](https://github.com/Pycord-Development/pycord/pull/2364)) +- Fixed enum to support stringified annotations. + ([#2367](https://github.com/Pycord-Development/pycord/pull/2367)) ## [2.4.1] - 2023-03-20 @@ -742,7 +887,9 @@ These changes are available on the `master` branch, but have not yet been releas - Fix py3.10 UnionType checks issue. ([#1240](https://github.com/Pycord-Development/pycord/pull/1240)) -[unreleased]: https://github.com/Pycord-Development/pycord/compare/v2.4.1...HEAD +[unreleased]: https://github.com/Pycord-Development/pycord/compare/v2.6.0...HEAD +[2.6.0]: https://github.com/Pycord-Development/pycord/compare/v2.5.0...2.6.0 +[2.5.0]: https://github.com/Pycord-Development/pycord/compare/v2.4.1...v2.5.0 [2.4.1]: https://github.com/Pycord-Development/pycord/compare/v2.4.0...v2.4.1 [2.4.0]: https://github.com/Pycord-Development/pycord/compare/v2.3.3...v2.4.0 [2.3.3]: https://github.com/Pycord-Development/pycord/compare/v2.3.2...v2.3.3 diff --git a/README.rst b/README.rst index a65654c4e6..ae74c857e0 100644 --- a/README.rst +++ b/README.rst @@ -25,7 +25,7 @@ Key Features - Modern Pythonic API using ``async`` and ``await``. - Proper rate limit handling. - Optimised for both speed and memory usage. -- Full Application Command Support +- Full application API support. Installing ---------- diff --git a/discord/__init__.py b/discord/__init__.py index 1b73d1bafc..d6031ce3ac 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -52,10 +52,13 @@ from .member import * from .mentions import * from .message import * +from .monetization import * from .object import * +from .onboarding import * from .partial_emoji import * from .permissions import * from .player import * +from .poll import * from .raw_models import * from .reaction import * from .role import * diff --git a/discord/__main__.py b/discord/__main__.py index 12b5b5a1c3..ed34bdf42a 100644 --- a/discord/__main__.py +++ b/discord/__main__.py @@ -24,13 +24,13 @@ """ import argparse +import importlib.metadata import platform import sys from pathlib import Path from typing import Tuple import aiohttp -import pkg_resources import discord @@ -47,9 +47,9 @@ def show_version() -> None: "- py-cord v{0.major}.{0.minor}.{0.micro}-{0.releaselevel}".format(version_info) ) if version_info.releaselevel != "final": - pkg = pkg_resources.get_distribution("py-cord") - if pkg: - entries.append(f" - py-cord pkg_resources: v{pkg.version}") + version = importlib.metadata.version("py-cord") + if version: + entries.append(f" - py-cord importlib.metadata: v{version}") entries.append(f"- aiohttp v{aiohttp.__version__}") uname = platform.uname() diff --git a/discord/_typed_dict.py b/discord/_typed_dict.py index de90a285b5..dfd1e4e9eb 100644 --- a/discord/_typed_dict.py +++ b/discord/_typed_dict.py @@ -21,6 +21,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + import sys # PEP 655 Required and NotRequired were added in python 3.11. This file is simply a diff --git a/discord/_version.py b/discord/_version.py index c58c8baa91..c1bc153507 100644 --- a/discord/_version.py +++ b/discord/_version.py @@ -22,6 +22,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from __future__ import annotations import datetime diff --git a/discord/abc.py b/discord/abc.py index ce811492bc..d699f44702 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -89,6 +89,7 @@ from .guild import Guild from .member import Member from .message import Message, MessageReference, PartialMessage + from .poll import Poll from .state import ConnectionState from .threads import Thread from .types.channel import Channel as ChannelPayload @@ -115,7 +116,7 @@ async def _single_delete_strategy( async def _purge_messages_helper( - channel: TextChannel | Thread | VoiceChannel, + channel: TextChannel | StageChannel | Thread | VoiceChannel, *, limit: int | None = 100, check: Callable[[Message], bool] = MISSING, @@ -341,8 +342,7 @@ class GuildChannel: def __init__( self, *, state: ConnectionState, guild: Guild, data: dict[str, Any] - ): - ... + ): ... def __str__(self) -> str: return self.name @@ -526,9 +526,9 @@ async def _edit( "default_reaction_emoji must be of type: Emoji | int | str" ) - options[ - "default_reaction_emoji" - ] = default_reaction_emoji._to_forum_reaction_payload() + options["default_reaction_emoji"] = ( + default_reaction_emoji._to_forum_reaction_payload() + ) if options: return await self._state.http.edit_channel( @@ -853,8 +853,7 @@ async def set_permissions( *, overwrite: PermissionOverwrite | None = ..., reason: str | None = ..., - ) -> None: - ... + ) -> None: ... @overload async def set_permissions( @@ -863,8 +862,7 @@ async def set_permissions( *, reason: str | None = ..., **permissions: bool, - ) -> None: - ... + ) -> None: ... async def set_permissions( self, target, *, overwrite=MISSING, reason=None, **permissions @@ -1033,8 +1031,7 @@ async def move( category: Snowflake | None = MISSING, sync_permissions: bool = MISSING, reason: str | None = MISSING, - ) -> None: - ... + ) -> None: ... @overload async def move( @@ -1045,8 +1042,7 @@ async def move( category: Snowflake | None = MISSING, sync_permissions: bool = MISSING, reason: str = MISSING, - ) -> None: - ... + ) -> None: ... @overload async def move( @@ -1057,8 +1053,7 @@ async def move( category: Snowflake | None = MISSING, sync_permissions: bool = MISSING, reason: str = MISSING, - ) -> None: - ... + ) -> None: ... @overload async def move( @@ -1069,8 +1064,7 @@ async def move( category: Snowflake | None = MISSING, sync_permissions: bool = MISSING, reason: str = MISSING, - ) -> None: - ... + ) -> None: ... async def move(self, **kwargs) -> None: """|coro| @@ -1352,15 +1346,16 @@ async def send( file: File = ..., stickers: Sequence[GuildSticker | StickerItem] = ..., delete_after: float = ..., - nonce: str | int = ..., + nonce: int | str = ..., + enforce_nonce: bool = ..., allowed_mentions: AllowedMentions = ..., reference: Message | MessageReference | PartialMessage = ..., mention_author: bool = ..., view: View = ..., + poll: Poll = ..., suppress: bool = ..., silent: bool = ..., - ) -> Message: - ... + ) -> Message: ... @overload async def send( @@ -1372,15 +1367,16 @@ async def send( files: list[File] = ..., stickers: Sequence[GuildSticker | StickerItem] = ..., delete_after: float = ..., - nonce: str | int = ..., + nonce: int | str = ..., + enforce_nonce: bool = ..., allowed_mentions: AllowedMentions = ..., reference: Message | MessageReference | PartialMessage = ..., mention_author: bool = ..., view: View = ..., + poll: Poll = ..., suppress: bool = ..., silent: bool = ..., - ) -> Message: - ... + ) -> Message: ... @overload async def send( @@ -1392,15 +1388,16 @@ async def send( file: File = ..., stickers: Sequence[GuildSticker | StickerItem] = ..., delete_after: float = ..., - nonce: str | int = ..., + nonce: int | str = ..., + enforce_nonce: bool = ..., allowed_mentions: AllowedMentions = ..., reference: Message | MessageReference | PartialMessage = ..., mention_author: bool = ..., view: View = ..., + poll: Poll = ..., suppress: bool = ..., silent: bool = ..., - ) -> Message: - ... + ) -> Message: ... @overload async def send( @@ -1412,15 +1409,16 @@ async def send( files: list[File] = ..., stickers: Sequence[GuildSticker | StickerItem] = ..., delete_after: float = ..., - nonce: str | int = ..., + nonce: int | str = ..., + enforce_nonce: bool = ..., allowed_mentions: AllowedMentions = ..., reference: Message | MessageReference | PartialMessage = ..., mention_author: bool = ..., view: View = ..., + poll: Poll = ..., suppress: bool = ..., silent: bool = ..., - ) -> Message: - ... + ) -> Message: ... async def send( self, @@ -1434,10 +1432,12 @@ async def send( stickers=None, delete_after=None, nonce=None, + enforce_nonce=None, allowed_mentions=None, reference=None, mention_author=None, view=None, + poll=None, suppress=None, silent=None, ): @@ -1471,9 +1471,13 @@ async def send( The file to upload. files: List[:class:`~discord.File`] A list of files to upload. Must be a maximum of 10. - nonce: :class:`int` + nonce: Union[:class:`str`, :class:`int`] The nonce to use for sending this message. If the message was successfully sent, then the message will have a nonce with this value. + enforce_nonce: Optional[:class:`bool`] + Whether :attr:`nonce` is enforced to be validated. + + .. versionadded:: 2.5 delete_after: :class:`float` If provided, the number of seconds to wait in the background before deleting the message we just sent. If the deletion fails, @@ -1517,6 +1521,10 @@ async def send( Whether to suppress push and desktop notifications for the message. .. versionadded:: 2.4 + poll: :class:`Poll` + The poll to send. + + .. versionadded:: 2.6 Returns ------- @@ -1596,6 +1604,9 @@ async def send( else: components = None + if poll: + poll = poll.to_dict() + if file is not None and files is not None: raise InvalidArgument("cannot pass both file and files parameter to send()") @@ -1613,10 +1624,12 @@ async def send( embed=embed, embeds=embeds, nonce=nonce, + enforce_nonce=enforce_nonce, message_reference=reference, stickers=stickers, components=components, flags=flags, + poll=poll, ) finally: file.close() @@ -1638,11 +1651,13 @@ async def send( embed=embed, embeds=embeds, nonce=nonce, + enforce_nonce=enforce_nonce, allowed_mentions=allowed_mentions, message_reference=reference, stickers=stickers, components=components, flags=flags, + poll=poll, ) finally: for f in files: @@ -1655,11 +1670,13 @@ async def send( embed=embed, embeds=embeds, nonce=nonce, + enforce_nonce=enforce_nonce, allowed_mentions=allowed_mentions, message_reference=reference, stickers=stickers, components=components, flags=flags, + poll=poll, ) ret = state.create_message(channel=channel, data=data) diff --git a/discord/activity.py b/discord/activity.py index 96b20255a8..81128a5fb0 100644 --- a/discord/activity.py +++ b/discord/activity.py @@ -843,13 +843,11 @@ def __repr__(self) -> str: @overload -def create_activity(data: ActivityPayload) -> ActivityTypes: - ... +def create_activity(data: ActivityPayload) -> ActivityTypes: ... @overload -def create_activity(data: None) -> None: - ... +def create_activity(data: None) -> None: ... def create_activity(data: ActivityPayload | None) -> ActivityTypes | None: diff --git a/discord/asset.py b/discord/asset.py index fc11f89b72..103bc4ba7e 100644 --- a/discord/asset.py +++ b/discord/asset.py @@ -182,6 +182,23 @@ def _from_avatar(cls, state, user_id: int, avatar: str) -> Asset: animated=animated, ) + @classmethod + def _from_avatar_decoration( + cls, state, user_id: int, avatar_decoration: str + ) -> Asset: + animated = avatar_decoration.startswith("a_") + endpoint = ( + "avatar-decoration-presets" + # if avatar_decoration.startswith(("v3", "v2")) + # else f"avatar-decorations/{user_id}" + ) + return cls( + state, + url=f"{cls.BASE}/{endpoint}/{avatar_decoration}.png?size=1024", + key=avatar_decoration, + animated=animated, + ) + @classmethod def _from_guild_avatar( cls, state, guild_id: int, member_id: int, avatar: str diff --git a/discord/audit_logs.py b/discord/audit_logs.py index 27a4f740b2..4bac078c4f 100644 --- a/discord/audit_logs.py +++ b/discord/audit_logs.py @@ -193,7 +193,7 @@ def _transform_type( def _transform_actions( entry: AuditLogEntry, data: list[AutoModActionPayload] | None -) -> AutoModAction | None: +) -> list[AutoModAction] | None: if data is None: return None else: @@ -201,8 +201,8 @@ def _transform_actions( def _transform_trigger_metadata( - entry: AuditLogEntry, data: list[AutoModActionPayload] | None -) -> AutoModAction | None: + entry: AuditLogEntry, data: AutoModTriggerMetadataPayload | None +) -> AutoModTriggerMetadata | None: if data is None: return None else: @@ -222,11 +222,9 @@ def __repr__(self) -> str: if TYPE_CHECKING: - def __getattr__(self, item: str) -> Any: - ... + def __getattr__(self, item: str) -> Any: ... - def __setattr__(self, key: str, value: Any) -> Any: - ... + def __setattr__(self, key: str, value: Any) -> Any: ... Transformer = Callable[["AuditLogEntry", Any], Any] @@ -311,7 +309,7 @@ def __init__( "$add_allow_list", ]: self._handle_trigger_metadata( - self.before, self.after, entry, elem["new_value"], attr + self.before, self.after, entry, elem["new_value"], attr # type: ignore ) continue elif attr in [ @@ -320,7 +318,7 @@ def __init__( "$remove_allow_list", ]: self._handle_trigger_metadata( - self.after, self.before, entry, elem["new_value"], attr + self.after, self.before, entry, elem["new_value"], attr # type: ignore ) continue diff --git a/discord/backoff.py b/discord/backoff.py index 009df69983..819c7d3cee 100644 --- a/discord/backoff.py +++ b/discord/backoff.py @@ -71,16 +71,13 @@ def __init__(self, base: int = 1, *, integral: T = False): self._randfunc: Callable[..., int | float] = rand.randrange if integral else rand.uniform # type: ignore @overload - def delay(self: ExponentialBackoff[Literal[False]]) -> float: - ... + def delay(self: ExponentialBackoff[Literal[False]]) -> float: ... @overload - def delay(self: ExponentialBackoff[Literal[True]]) -> int: - ... + def delay(self: ExponentialBackoff[Literal[True]]) -> int: ... @overload - def delay(self: ExponentialBackoff[bool]) -> int | float: - ... + def delay(self: ExponentialBackoff[bool]) -> int | float: ... def delay(self) -> int | float: """Compute the next delay diff --git a/discord/bot.py b/discord/bot.py index b107896db8..a81825b494 100644 --- a/discord/bot.py +++ b/discord/bot.py @@ -48,7 +48,7 @@ UserCommand, command, ) -from .enums import InteractionType +from .enums import IntegrationType, InteractionContextType, InteractionType from .errors import CheckFailure, DiscordException from .interactions import Interaction from .shard import AutoShardedClient @@ -108,7 +108,7 @@ def application_commands(self) -> list[ApplicationCommand]: return list(self._application_commands.values()) def add_application_command(self, command: ApplicationCommand) -> None: - """Adds a :class:`.ApplicationCommand` into the internal list of commands. + """Adds an :class:`.ApplicationCommand` into the internal list of commands. This is usually not called, instead the :meth:`command` or other shortcut decorators are used instead. @@ -125,6 +125,13 @@ def add_application_command(self, command: ApplicationCommand) -> None: if self._bot.debug_guilds and command.guild_ids is None: command.guild_ids = self._bot.debug_guilds + if self._bot.default_command_contexts and command.contexts is None: + command.contexts = self._bot.default_command_contexts + if ( + self._bot.default_command_integration_types + and command.integration_types is None + ): + command.integration_types = self._bot.default_command_integration_types for cmd in self.pending_application_commands: if cmd == command: @@ -136,7 +143,7 @@ def add_application_command(self, command: ApplicationCommand) -> None: def remove_application_command( self, command: ApplicationCommand ) -> ApplicationCommand | None: - """Remove a :class:`.ApplicationCommand` from the internal list + """Remove an :class:`.ApplicationCommand` from the internal list of commands. .. versionadded:: 2.0 @@ -149,16 +156,15 @@ def remove_application_command( Returns ------- Optional[:class:`.ApplicationCommand`] - The command that was removed. If the name is not valid then + The command that was removed. If the command has not been added, ``None`` is returned instead. """ - if command.id is None: - try: - index = self._pending_application_commands.index(command) - except ValueError: - return None - return self._pending_application_commands.pop(index) - return self._application_commands.pop(command.id, None) + if command.id: + self._application_commands.pop(command.id, None) + + if command in self._pending_application_commands: + self._pending_application_commands.remove(command) + return command @property def get_command(self): @@ -178,7 +184,7 @@ def get_application_command( guild_ids: list[int] | None = None, type: type[ApplicationCommand] = ApplicationCommand, ) -> ApplicationCommand | None: - """Get a :class:`.ApplicationCommand` from the internal list + """Get an :class:`.ApplicationCommand` from the internal list of commands. .. versionadded:: 2.0 @@ -271,7 +277,6 @@ def _check_command(cmd: ApplicationCommand, match: Mapping[str, Any]) -> bool: else: as_dict = cmd.to_dict() to_check = { - "dm_permission": None, "nsfw": None, "default_member_permissions": None, "name": None, @@ -287,6 +292,8 @@ def _check_command(cmd: ApplicationCommand, match: Mapping[str, Any]) -> bool: "name_localizations", "description_localizations", ], + "contexts": None, + "integration_types": None, } for check, value in to_check.items(): if type(to_check[check]) == list: @@ -680,7 +687,7 @@ async def sync_commands( register all commands. By default, this coroutine is called inside the :func:`.on_connect` event. If you choose to override the - :func:`.on_connect` event, then you should invoke this coroutine as well such as the follwing: + :func:`.on_connect` event, then you should invoke this coroutine as well such as the following: .. code-block:: python @@ -1137,8 +1144,7 @@ async def invoke_application_command(self, ctx: ApplicationContext) -> None: @property @abstractmethod - def _bot(self) -> Bot | AutoShardedBot: - ... + def _bot(self) -> Bot | AutoShardedBot: ... class BotBase(ApplicationCommandMixin, CogMixin, ABC): @@ -1158,6 +1164,21 @@ def __init__(self, description=None, *args, **options): self.auto_sync_commands = options.get("auto_sync_commands", True) self.debug_guilds = options.pop("debug_guilds", None) + self.default_command_contexts = options.pop( + "default_command_contexts", + { + InteractionContextType.guild, + InteractionContextType.bot_dm, + InteractionContextType.private_channel, + }, + ) + + self.default_command_integration_types = options.pop( + "default_command_integration_types", + { + IntegrationType.guild_install, + }, + ) if self.owner_id and self.owner_ids: raise TypeError("Both owner_id and owner_ids are set.") @@ -1168,6 +1189,20 @@ def __init__(self, description=None, *args, **options): raise TypeError( f"owner_ids must be a collection not {self.owner_ids.__class__!r}" ) + if not isinstance(self.default_command_contexts, collections.abc.Collection): + raise TypeError( + f"default_command_contexts must be a collection not {self.default_command_contexts.__class__!r}" + ) + if not isinstance( + self.default_command_integration_types, collections.abc.Collection + ): + raise TypeError( + f"default_command_integration_types must be a collection not {self.default_command_integration_types.__class__!r}" + ) + self.default_command_contexts = set(self.default_command_contexts) + self.default_command_integration_types = set( + self.default_command_integration_types + ) self._checks = [] self._check_once = [] @@ -1448,6 +1483,17 @@ class Bot(BotBase, Client): :attr:`.process_application_commands` if the command is not found. Defaults to ``True``. .. versionadded:: 2.0 + default_command_contexts: Collection[:class:`InteractionContextType`] + The default context types that the bot will use for commands. + Defaults to a set containing :attr:`InteractionContextType.guild`, :attr:`InteractionContextType.bot_dm`, and + :attr:`InteractionContextType.private_channel`. + + .. versionadded:: 2.6 + default_command_integration_types: Collection[:class:`IntegrationType`]] + The default integration types that the bot will use for commands. + Defaults to a set containing :attr:`IntegrationType.guild_install`. + + .. versionadded:: 2.6 """ @property diff --git a/discord/channel.py b/discord/channel.py index 93925b29a9..7608e1757d 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -780,12 +780,10 @@ async def edit( default_thread_slowmode_delay: int = ..., type: ChannelType = ..., overwrites: Mapping[Role | Member | Snowflake, PermissionOverwrite] = ..., - ) -> TextChannel | None: - ... + ) -> TextChannel | None: ... @overload - async def edit(self) -> TextChannel | None: - ... + async def edit(self) -> TextChannel | None: ... async def edit(self, *, reason=None, **options): """|coro| @@ -868,6 +866,8 @@ async def create_thread( message: Snowflake | None = None, auto_archive_duration: ThreadArchiveDuration = MISSING, type: ChannelType | None = None, + slowmode_delay: int | None = None, + invitable: bool | None = None, reason: str | None = None, ) -> Thread: """|coro| @@ -894,6 +894,12 @@ async def create_thread( The type of thread to create. If a ``message`` is passed then this parameter is ignored, as a thread created with a message is always a public thread. By default, this creates a private thread if this is ``None``. + slowmode_delay: Optional[:class:`int`] + Specifies the slowmode rate limit for users in this thread, in seconds. + A value of ``0`` disables slowmode. The maximum value possible is ``21600``. + invitable: Optional[:class:`bool`] + Whether non-moderators can add other non-moderators to this thread. + Only available for private threads, where it defaults to True. reason: :class:`str` The reason for creating a new thread. Shows up on the audit log. @@ -920,6 +926,8 @@ async def create_thread( auto_archive_duration=auto_archive_duration or self.default_auto_archive_duration, type=type.value, + rate_limit_per_user=slowmode_delay or 0, + invitable=invitable, reason=reason, ) else: @@ -929,6 +937,7 @@ async def create_thread( name=name, auto_archive_duration=auto_archive_duration or self.default_auto_archive_duration, + rate_limit_per_user=slowmode_delay or 0, reason=reason, ) @@ -1079,12 +1088,10 @@ 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): """|coro| @@ -1211,7 +1218,7 @@ async def create_thread( A list of stickers to upload. Must be a maximum of 3. delete_message_after: :class:`int` The time to wait before deleting the thread. - nonce: :class:`int` + nonce: Union[:class:`str`, :class:`int`] The nonce to use for sending this message. If the message was successfully sent, then the message will have a nonce with this value. allowed_mentions: :class:`~discord.AllowedMentions` @@ -1332,7 +1339,7 @@ async def create_thread( f.close() ret = Thread(guild=self.guild, state=self._state, data=data) - msg = ret.get_partial_message(data["last_message_id"]) + msg = ret.get_partial_message(int(data["last_message_id"])) if view: state.store_view(view, msg.id) @@ -1512,6 +1519,10 @@ class VoiceChannel(discord.abc.Messageable, VocalGuildChannel): Bots and users with :attr:`~Permissions.manage_channels` or :attr:`~Permissions.manage_messages` bypass slowmode. + .. versionadded:: 2.5 + status: Optional[:class:`str`] + The channel's status, if set. + .. versionadded:: 2.5 flags: :class:`ChannelFlags` Extra features of the channel. @@ -1519,13 +1530,26 @@ class VoiceChannel(discord.abc.Messageable, VocalGuildChannel): .. versionadded:: 2.0 """ + def __init__( + self, + *, + state: ConnectionState, + guild: Guild, + data: VoiceChannelPayload, + ): + self.status: str | None = None + super().__init__(state=state, guild=guild, data=data) + def _update(self, guild: Guild, data: VoiceChannelPayload): super()._update(guild, data) + if data.get("status"): + self.status = data.get("status") def __repr__(self) -> str: attrs = [ ("id", self.id), ("name", self.name), + ("status", self.status), ("rtc_region", self.rtc_region), ("position", self.position), ("bitrate", self.bitrate), @@ -1823,12 +1847,10 @@ async def edit( video_quality_mode: VideoQualityMode = ..., slowmode_delay: int = ..., reason: str | None = ..., - ) -> VoiceChannel | None: - ... + ) -> VoiceChannel | None: ... @overload - async def edit(self) -> VoiceChannel | None: - ... + async def edit(self) -> VoiceChannel | None: ... async def edit(self, *, reason=None, **options): """|coro| @@ -1950,6 +1972,31 @@ async def create_activity_invite( **kwargs, ) + async def set_status( + self, status: str | None, *, reason: str | None = None + ) -> None: + """|coro| + + Sets the status of the voice channel. + + You must have the :attr:`~Permissions.set_voice_channel_status` permission to use this. + + Parameters + ---------- + status: Union[:class:`str`, None] + The new status. + reason: Optional[:class:`str`] + The reason for setting the status. Shows up on the audit log. + + Raises + ------ + Forbidden + You do not have proper permissions to set the status. + HTTPException + Setting the status failed. + """ + await self._state.http.set_voice_channel_status(self.id, status, reason=reason) + class StageChannel(discord.abc.Messageable, VocalGuildChannel): """Represents a Discord guild stage channel. @@ -2448,12 +2495,10 @@ async def edit( rtc_region: VoiceRegion | None = ..., video_quality_mode: VideoQualityMode = ..., reason: str | None = ..., - ) -> StageChannel | None: - ... + ) -> StageChannel | None: ... @overload - async def edit(self) -> StageChannel | None: - ... + async def edit(self) -> StageChannel | None: ... async def edit(self, *, reason=None, **options): """|coro| @@ -2627,12 +2672,10 @@ async def edit( nsfw: bool = ..., overwrites: Mapping[Role | Member, PermissionOverwrite] = ..., reason: str | None = ..., - ) -> CategoryChannel | None: - ... + ) -> CategoryChannel | None: ... @overload - async def edit(self) -> CategoryChannel | None: - ... + async def edit(self) -> CategoryChannel | None: ... async def edit(self, *, reason=None, **options): """|coro| diff --git a/discord/client.py b/discord/client.py index 3144011967..4cfaef59cf 100644 --- a/discord/client.py +++ b/discord/client.py @@ -49,8 +49,9 @@ from .guild import Guild from .http import HTTPClient from .invite import Invite -from .iterators import GuildIterator +from .iterators import EntitlementIterator, GuildIterator from .mentions import AllowedMentions +from .monetization import SKU, Entitlement from .object import Object from .stage_instance import StageInstance from .state import ConnectionState @@ -69,6 +70,7 @@ from .channel import DMChannel from .member import Member from .message import Message + from .poll import Poll from .voice_client import VoiceProtocol __all__ = ("Client",) @@ -217,9 +219,9 @@ def __init__( self.loop: asyncio.AbstractEventLoop = ( asyncio.get_event_loop() if loop is None else loop ) - self._listeners: dict[ - str, list[tuple[asyncio.Future, Callable[..., bool]]] - ] = {} + self._listeners: dict[str, list[tuple[asyncio.Future, Callable[..., bool]]]] = ( + {} + ) self.shard_id: int | None = options.get("shard_id") self.shard_count: int | None = options.get("shard_count") @@ -337,6 +339,14 @@ def stickers(self) -> list[GuildSticker]: """ return self._connection.stickers + @property + def polls(self) -> list[Poll]: + """The polls that the connected client has. + + .. versionadded:: 2.6 + """ + return self._connection.polls + @property def cached_messages(self) -> Sequence[Message]: """Read-only list of messages the connected client has cached. @@ -1009,6 +1019,21 @@ def get_sticker(self, id: int, /) -> GuildSticker | None: """ return self._connection.get_sticker(id) + def get_poll(self, id: int, /) -> Poll | None: + """Returns a poll attached to the given message ID. + + Parameters + ---------- + id: :class:`int` + The message ID of the poll to search for. + + Returns + ------- + Optional[:class:`.Poll`] + The poll or ``None`` if not found. + """ + return self._connection.get_poll(id) + def get_all_channels(self) -> Generator[GuildChannel, None, None]: """A generator that retrieves every :class:`.abc.GuildChannel` the client can 'access'. @@ -2002,3 +2027,99 @@ async def update_role_connection_metadata_records( self.application_id, payload ) return [ApplicationRoleConnectionMetadata.from_dict(r) for r in data] + + async def fetch_skus(self) -> list[SKU]: + """|coro| + + Fetches the bot's SKUs. + + .. versionadded:: 2.5 + + Returns + ------- + List[:class:`.SKU`] + The bot's SKUs. + """ + data = await self._connection.http.list_skus(self.application_id) + return [SKU(data=s) for s in data] + + def entitlements( + self, + user: Snowflake | None = None, + skus: list[Snowflake] | None = None, + before: SnowflakeTime | None = None, + after: SnowflakeTime | None = None, + limit: int | None = 100, + guild: Snowflake | None = None, + exclude_ended: bool = False, + ) -> EntitlementIterator: + """Returns an :class:`.AsyncIterator` that enables fetching the application's entitlements. + + .. versionadded:: 2.6 + + Parameters + ---------- + user: :class:`.abc.Snowflake` | None + Limit the fetched entitlements to entitlements owned by this user. + skus: list[:class:`.abc.Snowflake`] | None + Limit the fetched entitlements to entitlements that are for these SKUs. + before: :class:`.abc.Snowflake` | :class:`datetime.datetime` | None + Retrieves guilds before this date or object. + If a datetime is provided, it is recommended to use a UTC-aware datetime. + If the datetime is naive, it is assumed to be local time. + after: :class:`.abc.Snowflake` | :class:`datetime.datetime` | None + Retrieve guilds after this date or object. + If a datetime is provided, it is recommended to use a UTC-aware datetime. + If the datetime is naive, it is assumed to be local time. + limit: Optional[:class:`int`] + The number of entitlements to retrieve. + If ``None``, retrieves every entitlement, which may be slow. + Defaults to ``100``. + guild: :class:`.abc.Snowflake` | None + Limit the fetched entitlements to entitlements owned by this guild. + exclude_ended: :class:`bool` + Whether to limit the fetched entitlements to those that have not ended. + Defaults to ``False``. + + Yields + ------ + :class:`.Entitlement` + The application's entitlements. + + Raises + ------ + :exc:`HTTPException` + Retrieving the entitlements failed. + + Examples + -------- + + Usage :: + + async for entitlement in client.entitlements(): + print(entitlement.user_id) + + Flattening into a list :: + + entitlements = await user.entitlements().flatten() + + All parameters are optional. + """ + return EntitlementIterator( + self._connection, + user_id=user.id if user else None, + sku_ids=[sku.id for sku in skus] if skus else None, + before=before, + after=after, + limit=limit, + guild_id=guild.id if guild else None, + exclude_ended=exclude_ended, + ) + + @property + def store_url(self) -> str: + """:class:`str`: The URL that leads to the application's store page for monetization. + + .. versionadded:: 2.6 + """ + return f"https://discord.com/application-directory/{self.application_id}/store" diff --git a/discord/cog.py b/discord/cog.py index 686575db6f..4f064edb26 100644 --- a/discord/cog.py +++ b/discord/cog.py @@ -22,6 +22,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from __future__ import annotations import importlib @@ -201,9 +202,9 @@ def __new__(cls: type[CogMeta], *args: Any, **kwargs: Any) -> CogMeta: commands[f"app_{elem}"] = value.slash_variant commands[elem] = value for cmd in getattr(value, "subcommands", []): - commands[ - f"ext_{cmd.ext_variant.qualified_name}" - ] = cmd.ext_variant + commands[f"ext_{cmd.ext_variant.qualified_name}"] = ( + cmd.ext_variant + ) if inspect.iscoroutinefunction(value): try: @@ -378,7 +379,9 @@ def _get_overridden_method(cls, method: FuncT) -> FuncT | None: ) @classmethod - def listener(cls, name: str = MISSING) -> Callable[[FuncT], FuncT]: + def listener( + cls, name: str = MISSING, once: bool = False + ) -> Callable[[FuncT], FuncT]: """A decorator that marks a function as a listener. This is the cog equivalent of :meth:`.Bot.listen`. @@ -388,6 +391,9 @@ def listener(cls, name: str = MISSING) -> Callable[[FuncT], FuncT]: name: :class:`str` The name of the event being listened to. If not provided, it defaults to the function's name. + once: :class:`bool` + If this listener should only be called once after each cog load. + Defaults to false. Raises ------ @@ -410,6 +416,7 @@ def decorator(func: FuncT) -> FuncT: raise TypeError("Listener function must be a coroutine function.") actual.__cog_listener__ = True to_assign = name or actual.__name__ + actual._once = once try: actual.__cog_listener_names__.append(to_assign) except AttributeError: @@ -808,8 +815,7 @@ def load_extension( *, package: str | None = None, recursive: bool = False, - ) -> list[str]: - ... + ) -> list[str]: ... @overload def load_extension( @@ -819,8 +825,7 @@ def load_extension( package: str | None = None, recursive: bool = False, store: bool = False, - ) -> dict[str, Exception | bool] | list[str] | None: - ... + ) -> dict[str, Exception | bool] | list[str] | None: ... def load_extension( self, name, *, package=None, recursive=False, store=False @@ -941,8 +946,7 @@ def load_extensions( *names: str, package: str | None = None, recursive: bool = False, - ) -> list[str]: - ... + ) -> list[str]: ... @overload def load_extensions( @@ -951,8 +955,7 @@ def load_extensions( package: str | None = None, recursive: bool = False, store: bool = False, - ) -> dict[str, Exception | bool] | list[str] | None: - ... + ) -> dict[str, Exception | bool] | list[str] | None: ... def load_extensions( self, *names, package=None, recursive=False, store=False diff --git a/discord/colour.py b/discord/colour.py index 4472f028bf..0ec77d5786 100644 --- a/discord/colour.py +++ b/discord/colour.py @@ -22,6 +22,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from __future__ import annotations import colorsys diff --git a/discord/commands/context.py b/discord/commands/context.py index 576a804494..27c3b0acba 100644 --- a/discord/commands/context.py +++ b/discord/commands/context.py @@ -22,6 +22,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from __future__ import annotations from typing import TYPE_CHECKING, Any, TypeVar @@ -36,7 +37,7 @@ import discord from .. import Bot from ..state import ConnectionState - from ..voice_client import VoiceProtocol + from ..voice_client import VoiceClient from .core import ApplicationCommand, Option from ..interactions import InteractionChannel @@ -210,7 +211,7 @@ def user(self) -> Member | User: author: Member | User = user @property - def voice_client(self) -> VoiceProtocol | None: + def voice_client(self) -> VoiceClient | None: """Returns the voice client associated with this context's command. Shorthand for :attr:`Interaction.guild.voice_client<~discord.Guild.voice_client>`, if applicable. """ diff --git a/discord/commands/core.py b/discord/commands/core.py index 6a9825b384..c1093351d9 100644 --- a/discord/commands/core.py +++ b/discord/commands/core.py @@ -45,14 +45,21 @@ Union, ) -from ..channel import _threaded_guild_channel_factory +from ..channel import PartialMessageable, _threaded_guild_channel_factory from ..enums import Enum as DiscordEnum -from ..enums import MessageType, SlashCommandOptionType, try_enum +from ..enums import ( + IntegrationType, + InteractionContextType, + MessageType, + SlashCommandOptionType, + try_enum, +) from ..errors import ( ApplicationCommandError, ApplicationCommandInvokeError, CheckFailure, ClientException, + InvalidArgument, ValidationError, ) from ..member import Member @@ -61,7 +68,7 @@ from ..role import Role from ..threads import Thread from ..user import User -from ..utils import MISSING, async_all, find, maybe_coroutine, utcnow +from ..utils import MISSING, async_all, find, maybe_coroutine, utcnow, warn_deprecated from .context import ApplicationContext, AutocompleteContext from .options import Option, OptionChoice @@ -226,11 +233,36 @@ def __init__(self, func: Callable, **kwargs) -> None: "__default_member_permissions__", kwargs.get("default_member_permissions", None), ) - self.guild_only: bool | None = getattr( - func, "__guild_only__", kwargs.get("guild_only", None) - ) self.nsfw: bool | None = getattr(func, "__nsfw__", kwargs.get("nsfw", None)) + integration_types = getattr( + func, "__integration_types__", kwargs.get("integration_types", None) + ) + contexts = getattr(func, "__contexts__", kwargs.get("contexts", None)) + guild_only = getattr(func, "__guild_only__", kwargs.get("guild_only", MISSING)) + if guild_only is not MISSING: + warn_deprecated( + "guild_only", + "contexts", + "2.6", + reference="https://discord.com/developers/docs/change-log#userinstallable-apps-preview", + ) + if contexts and guild_only: + raise InvalidArgument( + "cannot pass both 'contexts' and 'guild_only' to ApplicationCommand" + ) + if self.guild_ids and ( + (contexts is not None) or guild_only or integration_types + ): + raise InvalidArgument( + "the 'contexts' and 'integration_types' parameters are not available for guild commands" + ) + + if guild_only: + contexts = {InteractionContextType.guild} + self.contexts: set[InteractionContextType] | None = contexts + self.integration_types: set[IntegrationType] | None = integration_types + def __repr__(self) -> str: return f"" @@ -274,6 +306,33 @@ def callback( unwrap = unwrap_function(function) self.module = unwrap.__module__ + @property + def guild_only(self) -> bool: + warn_deprecated( + "guild_only", + "contexts", + "2.6", + reference="https://discord.com/developers/docs/change-log#userinstallable-apps-preview", + ) + return InteractionContextType.guild in self.contexts and len(self.contexts) == 1 + + @guild_only.setter + def guild_only(self, value: bool) -> None: + warn_deprecated( + "guild_only", + "contexts", + "2.6", + reference="https://discord.com/developers/docs/change-log#userinstallable-apps-preview", + ) + if value: + self.contexts = {InteractionContextType.guild} + else: + self.contexts = { + InteractionContextType.guild, + InteractionContextType.bot_dm, + InteractionContextType.private_channel, + } + def _prepare_cooldowns(self, ctx: ApplicationContext): if self._buckets.valid: current = datetime.datetime.now().timestamp() @@ -328,7 +387,7 @@ def is_on_cooldown(self, ctx: ApplicationContext) -> bool: if not self._buckets.valid: return False - bucket = self._buckets.get_bucket(ctx) + bucket = self._buckets.get_bucket(ctx) # type: ignore current = utcnow().timestamp() return bucket.get_tokens(current) == 0 @@ -363,7 +422,7 @@ def get_cooldown_retry_after(self, ctx: ApplicationContext) -> float: If this is ``0.0`` then the command isn't on cooldown. """ if self._buckets.valid: - bucket = self._buckets.get_bucket(ctx) + bucket = self._buckets.get_bucket(ctx) # type: ignore current = utcnow().timestamp() return bucket.get_retry_after(current) @@ -460,7 +519,7 @@ def before_invoke(self, coro): called. This makes it a useful function to set up database connections or any type of set up required. - This pre-invoke hook takes a sole parameter, a :class:`.ApplicationContext`. + This pre-invoke hook takes a sole parameter, an :class:`.ApplicationContext`. See :meth:`.Bot.before_invoke` for more info. Parameters @@ -485,7 +544,7 @@ def after_invoke(self, coro): called. This makes it a useful function to clean-up database connections or any type of clean up required. - This post-invoke hook takes a sole parameter, a :class:`.ApplicationContext`. + This post-invoke hook takes a sole parameter, an :class:`.ApplicationContext`. See :meth:`.Bot.after_invoke` for more info. Parameters @@ -631,6 +690,9 @@ class SlashCommand(ApplicationCommand): Returns a string that allows you to mention the slash command. guild_only: :class:`bool` Whether the command should only be usable inside a guild. + + .. deprecated:: 2.6 + Use the :attr:`contexts` parameter instead. nsfw: :class:`bool` Whether the command should be restricted to 18+ channels and users. Apps intending to be listed in the App Directory cannot have NSFW commands. @@ -654,7 +716,14 @@ class SlashCommand(ApplicationCommand): description_localizations: Dict[:class:`str`, :class:`str`] The description localizations for this command. The values of this should be ``"locale": "description"``. See `here `_ for a list of valid locales. + integration_types: Set[:class:`IntegrationType`] + The type of installation this command should be available to. For instance, if set to + :attr:`IntegrationType.user_install`, the command will only be available to users with + the application installed on their account. Unapplicable for guild commands. + contexts: Set[:class:`InteractionContextType`] + The location where this command can be used. Cannot be set if this is a guild command. """ + type = 1 def __new__(cls, *args, **kwargs) -> SlashCommand: @@ -688,7 +757,8 @@ def __init__(self, func: Callable, *args, **kwargs) -> None: self.attached_to_group: bool = False - self.options: list[Option] = kwargs.get("options", []) + self._options_kwargs = kwargs.get("options", []) + self.options: list[Option] = [] self._validate_parameters() try: @@ -704,7 +774,7 @@ def __init__(self, func: Callable, *args, **kwargs) -> None: def _validate_parameters(self): params = self._get_signature_parameters() - if kwop := self.options: + if kwop := self._options_kwargs: self.options = self._match_option_param_names(params, kwop) else: self.options = self._parse_options(params) @@ -727,6 +797,8 @@ def _check_required_params(self, params): def _parse_options(self, params, *, check_params: bool = True) -> list[Option]: if check_params: params = self._check_required_params(params) + else: + params = iter(params.items()) final_options = [] for p_name, p_obj in params: @@ -757,23 +829,21 @@ def _parse_options(self, params, *, check_params: bool = True) -> list[Option]: if not isinstance(option, Option): if isinstance(p_obj.default, Option): - p_obj.default.input_type = SlashCommandOptionType.from_datatype( - option - ) + if p_obj.default.input_type is None: + p_obj.default.input_type = SlashCommandOptionType.from_datatype( + option + ) option = p_obj.default else: option = Option(option) if option.default is None and not p_obj.default == inspect.Parameter.empty: - if isinstance(p_obj.default, type) and issubclass( + if isinstance(p_obj.default, Option): + pass + elif isinstance(p_obj.default, type) and issubclass( p_obj.default, (DiscordEnum, Enum) ): option = Option(p_obj.default) - elif ( - isinstance(p_obj.default, Option) - and not (default := p_obj.default.default) is None - ): - option.default = default else: option.default = p_obj.default option.required = False @@ -790,6 +860,7 @@ def _parse_options(self, params, *, check_params: bool = True) -> list[Option]: return final_options def _match_option_param_names(self, params, options): + options = list(options) params = self._check_required_params(params) check_annotations: list[Callable[[Option, type], bool]] = [ @@ -855,8 +926,7 @@ def cog(self, value): or value is None and old_cog is not None ): - params = self._get_signature_parameters() - self.options = self._parse_options(params) + self._validate_parameters() @property def is_subcommand(self) -> bool: @@ -879,16 +949,17 @@ def to_dict(self) -> dict: if self.is_subcommand: as_dict["type"] = SlashCommandOptionType.sub_command.value - if self.guild_only is not None: - as_dict["dm_permission"] = not self.guild_only - if self.nsfw is not None: as_dict["nsfw"] = self.nsfw if self.default_member_permissions is not None: - as_dict[ - "default_member_permissions" - ] = self.default_member_permissions.value + as_dict["default_member_permissions"] = ( + self.default_member_permissions.value + ) + + if not self.guild_ids and not self.is_subcommand: + as_dict["integration_types"] = [it.value for it in self.integration_types] + as_dict["contexts"] = [ctx.value for ctx in self.contexts] return as_dict @@ -1098,6 +1169,9 @@ class SlashCommandGroup(ApplicationCommand): isn't one. guild_only: :class:`bool` Whether the command should only be usable inside a guild. + + .. deprecated:: 2.6 + Use the :attr:`contexts` parameter instead. nsfw: :class:`bool` Whether the command should be restricted to 18+ channels and users. Apps intending to be listed in the App Directory cannot have NSFW commands. @@ -1116,7 +1190,14 @@ class SlashCommandGroup(ApplicationCommand): description_localizations: Dict[:class:`str`, :class:`str`] The description localizations for this command. The values of this should be ``"locale": "description"``. See `here `_ for a list of valid locales. + integration_types: Set[:class:`IntegrationType`] + The type of installation this command should be available to. For instance, if set to + :attr:`IntegrationType.user_install`, the command will only be available to users with + the application installed on their account. Unapplicable for guild commands. + contexts: Set[:class:`InteractionContextType`] + The location where this command can be used. Unapplicable for guild commands. """ + __initial_commands__: list[SlashCommand | SlashCommandGroup] type = 1 @@ -1157,9 +1238,9 @@ def __init__( validate_chat_input_name(self.name) validate_chat_input_description(self.description) self.input_type = SlashCommandOptionType.sub_command_group - self.subcommands: list[ - SlashCommand | SlashCommandGroup - ] = self.__initial_commands__ + self.subcommands: list[SlashCommand | SlashCommandGroup] = ( + self.__initial_commands__ + ) self.guild_ids = guild_ids self.parent = parent self.attached_to_group: bool = False @@ -1174,9 +1255,30 @@ def __init__( self.default_member_permissions: Permissions | None = kwargs.get( "default_member_permissions", None ) - self.guild_only: bool | None = kwargs.get("guild_only", None) self.nsfw: bool | None = kwargs.get("nsfw", None) + integration_types = kwargs.get("integration_types", None) + contexts = kwargs.get("contexts", None) + guild_only = kwargs.get("guild_only", MISSING) + if guild_only is not MISSING: + warn_deprecated("guild_only", "contexts", "2.6") + if contexts and guild_only: + raise InvalidArgument( + "cannot pass both 'contexts' and 'guild_only' to ApplicationCommand" + ) + if self.guild_ids and ( + (contexts is not None) or guild_only or integration_types + ): + raise InvalidArgument( + "the 'contexts' and 'integration_types' parameters are not available for guild commands" + ) + + # These are set to None and their defaults are then set when added to the bot + self.contexts: set[InteractionContextType] | None = contexts + if guild_only: + self.guild_only: bool | None = guild_only + self.integration_types: set[IntegrationType] | None = integration_types + self.name_localizations: dict[str, str] = kwargs.get( "name_localizations", MISSING ) @@ -1215,6 +1317,23 @@ def __init__( def module(self) -> str | None: return self.__module__ + @property + def guild_only(self) -> bool: + warn_deprecated("guild_only", "contexts", "2.6") + return InteractionContextType.guild in self.contexts and len(self.contexts) == 1 + + @guild_only.setter + def guild_only(self, value: bool) -> None: + warn_deprecated("guild_only", "contexts", "2.6") + if value: + self.contexts = {InteractionContextType.guild} + else: + self.contexts = { + InteractionContextType.guild, + InteractionContextType.bot_dm, + InteractionContextType.private_channel, + } + def to_dict(self) -> dict: as_dict = { "name": self.name, @@ -1229,20 +1348,21 @@ def to_dict(self) -> dict: if self.parent is not None: as_dict["type"] = self.input_type.value - if self.guild_only is not None: - as_dict["dm_permission"] = not self.guild_only - if self.nsfw is not None: as_dict["nsfw"] = self.nsfw if self.default_member_permissions is not None: - as_dict[ - "default_member_permissions" - ] = self.default_member_permissions.value + as_dict["default_member_permissions"] = ( + self.default_member_permissions.value + ) + + if not self.guild_ids and self.parent is None: + as_dict["integration_types"] = [it.value for it in self.integration_types] + as_dict["contexts"] = [ctx.value for ctx in self.contexts] return as_dict - def add_command(self, command: SlashCommand) -> None: + def add_command(self, command: SlashCommand | SlashCommandGroup) -> None: if command.cog is None and self.cog is not None: command.cog = self.cog @@ -1305,8 +1425,7 @@ def create_subgroup( """ if self.parent is not None: - # TODO: Improve this error message - raise Exception("a subgroup cannot have a subgroup") + raise Exception("A subcommand group cannot be added to a subcommand group") sub_command_group = SlashCommandGroup( name, description, guild_ids, parent=self, **kwargs @@ -1444,7 +1563,7 @@ def _update_copy(self, kwargs: dict[str, Any]): if kwargs: kw = kwargs.copy() kw.update(self.__original_kwargs__) - copy = self.__class__(self.callback, **kw) + copy = self.__class__(**kw) return self._ensure_assignment_on_copy(copy) else: return self.copy() @@ -1473,6 +1592,9 @@ class ContextMenuCommand(ApplicationCommand): The ids of the guilds where this command will be registered. guild_only: :class:`bool` Whether the command should only be usable inside a guild. + + .. deprecated:: 2.6 + Use the ``contexts`` parameter instead. nsfw: :class:`bool` Whether the command should be restricted to 18+ channels and users. Apps intending to be listed in the App Directory cannot have NSFW commands. @@ -1493,6 +1615,10 @@ class ContextMenuCommand(ApplicationCommand): 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. + integration_types: Set[:class:`IntegrationType`] + The installation contexts where this command is available. Unapplicable for guild commands. + contexts: Set[:class:`InteractionContextType`] + The interaction contexts where this command is available. Unapplicable for guild commands. """ def __new__(cls, *args, **kwargs) -> ContextMenuCommand: @@ -1572,16 +1698,17 @@ def to_dict(self) -> dict[str, str | int]: "type": self.type, } - if self.guild_only is not None: - as_dict["dm_permission"] = not self.guild_only + if not self.guild_ids: + as_dict["integration_types"] = [it.value for it in self.integration_types] + as_dict["contexts"] = [ctx.value for ctx in self.contexts] if self.nsfw is not None: as_dict["nsfw"] = self.nsfw if self.default_member_permissions is not None: - as_dict[ - "default_member_permissions" - ] = self.default_member_permissions.value + as_dict["default_member_permissions"] = ( + self.default_member_permissions.value + ) if self.name_localizations: as_dict["name_localizations"] = self.name_localizations @@ -1603,7 +1730,17 @@ class UserCommand(ContextMenuCommand): The coroutine that is executed when the command is called. guild_ids: Optional[List[:class:`int`]] The ids of the guilds where this command will be registered. - cog: Optional[:class:`.Cog`] + guild_only: :class:`bool` + Whether the command should only be usable inside a guild. + + .. deprecated:: 2.6 + Use the ``contexts`` parameter instead. + nsfw: :class:`bool` + Whether the command should be restricted to 18+ channels and users. + Apps intending to be listed in the App Directory cannot have NSFW commands. + default_member_permissions: :class:`~discord.Permissions` + The default permissions a member needs to be able to run the command. + cog: Optional[:class:`Cog`] The cog that this command belongs to. ``None`` if there isn't one. checks: List[Callable[[:class:`.ApplicationContext`], :class:`bool`]] A list of predicates that verifies if the command could be executed @@ -1612,7 +1749,18 @@ class UserCommand(ContextMenuCommand): :exc:`.ApplicationCommandError` should be used. Note that if the checks fail then :exc:`.CheckFailure` exception is raised to the :func:`.on_application_command_error` event. + cooldown: Optional[:class:`~discord.ext.commands.Cooldown`] + The cooldown applied when the command is invoked. ``None`` if the command + doesn't have a cooldown. + name_localizations: Dict[:class:`str`, :class:`str`] + The name localizations for this command. The values of this should be ``"locale": "name"``. See + `here `_ for a list of valid locales. + integration_types: Set[:class:`IntegrationType`] + The installation contexts where this command is available. Unapplicable for guild commands. + contexts: Set[:class:`InteractionContextType`] + The interaction contexts where this command is available. Unapplicable for guild commands. """ + type = 2 def __new__(cls, *args, **kwargs) -> UserCommand: @@ -1701,7 +1849,17 @@ class MessageCommand(ContextMenuCommand): The coroutine that is executed when the command is called. guild_ids: Optional[List[:class:`int`]] The ids of the guilds where this command will be registered. - cog: Optional[:class:`.Cog`] + guild_only: :class:`bool` + Whether the command should only be usable inside a guild. + + .. deprecated:: 2.6 + Use the ``contexts`` parameter instead. + nsfw: :class:`bool` + Whether the command should be restricted to 18+ channels and users. + Apps intending to be listed in the App Directory cannot have NSFW commands. + default_member_permissions: :class:`~discord.Permissions` + The default permissions a member needs to be able to run the command. + cog: Optional[:class:`Cog`] The cog that this command belongs to. ``None`` if there isn't one. checks: List[Callable[[:class:`.ApplicationContext`], :class:`bool`]] A list of predicates that verifies if the command could be executed @@ -1710,7 +1868,18 @@ class MessageCommand(ContextMenuCommand): :exc:`.ApplicationCommandError` should be used. Note that if the checks fail then :exc:`.CheckFailure` exception is raised to the :func:`.on_application_command_error` event. + cooldown: Optional[:class:`~discord.ext.commands.Cooldown`] + The cooldown applied when the command is invoked. ``None`` if the command + doesn't have a cooldown. + name_localizations: Dict[:class:`str`, :class:`str`] + The name localizations for this command. The values of this should be ``"locale": "name"``. See + `here `_ for a list of valid locales. + integration_types: Set[:class:`IntegrationType`] + The installation contexts where this command is available. Unapplicable for guild commands. + contexts: Set[:class:`InteractionContextType`] + The interaction contexts where this command is available. Unapplicable for guild commands. """ + type = 3 def __new__(cls, *args, **kwargs) -> MessageCommand: @@ -1724,20 +1893,12 @@ async def _invoke(self, ctx: ApplicationContext): for i, v in _data.items(): v["id"] = int(i) message = v - channel = ctx.interaction._state.get_channel(int(message["channel_id"])) - if channel is None: - author_id = int(message["author"]["id"]) - self_or_system_message: bool = ctx.bot.user.id == author_id or try_enum( - MessageType, message["type"] - ) not in ( - MessageType.default, - MessageType.reply, - MessageType.application_command, - MessageType.thread_starter_message, + channel = ctx.interaction.channel + if channel.id != int(message["channel_id"]): + # we got weird stuff going on, make up a channel + channel = PartialMessageable( + state=ctx.interaction._state, id=int(message["channel_id"]) ) - user_id = ctx.author.id if self_or_system_message else author_id - data = await ctx.interaction._state.http.start_private_message(user_id) - channel = ctx.interaction._state.add_dm_channel(data) target = Message(state=ctx.interaction._state, channel=channel, data=message) diff --git a/discord/commands/options.py b/discord/commands/options.py index 73cda77dc9..9c56e3aeee 100644 --- a/discord/commands/options.py +++ b/discord/commands/options.py @@ -25,6 +25,7 @@ from __future__ import annotations import inspect +import logging from enum import Enum from typing import TYPE_CHECKING, Literal, Optional, Type, Union @@ -41,7 +42,7 @@ from ..enums import ChannelType from ..enums import Enum as DiscordEnum from ..enums import SlashCommandOptionType -from ..utils import MISSING +from ..utils import MISSING, basic_autocomplete if TYPE_CHECKING: from ..ext.commands import Converter @@ -86,6 +87,8 @@ DMChannel: ChannelType.private, } +_log = logging.getLogger(__name__) + class ThreadOption: """Represents a class that can be passed as the ``input_type`` for an :class:`Option` class. @@ -115,12 +118,13 @@ class Option: input_type: Union[Type[:class:`str`], Type[:class:`bool`], Type[:class:`int`], Type[:class:`float`], Type[:class:`.abc.GuildChannel`], Type[:class:`Thread`], Type[:class:`Member`], Type[:class:`User`], Type[:class:`Attachment`], Type[:class:`Role`], Type[:class:`.abc.Mentionable`], :class:`SlashCommandOptionType`, Type[:class:`.ext.commands.Converter`], Type[:class:`enums.Enum`], Type[:class:`Enum`]] The type of input that is expected for this option. This can be a :class:`SlashCommandOptionType`, an associated class, a channel type, a :class:`Converter`, a converter class or an :class:`enum.Enum`. + If a :class:`enum.Enum` is used and it has up to 25 values, :attr:`choices` will be automatically filled. If the :class:`enum.Enum` has more than 25 values, :attr:`autocomplete` will be implemented with :func:`discord.utils.basic_autocomplete` instead. name: :class:`str` The name of this option visible in the UI. Inherits from the variable name if not provided as a parameter. description: Optional[:class:`str`] The description of this option. - Must be 100 characters or fewer. + Must be 100 characters or fewer. If :attr:`input_type` is a :class:`enum.Enum` and :attr:`description` is not specified, :attr:`input_type`'s docstring will be used. choices: Optional[List[Union[:class:`Any`, :class:`OptionChoice`]]] The list of available choices for this option. Can be a list of values or :class:`OptionChoice` objects (which represent a name:value pair). @@ -141,10 +145,11 @@ class Option: max_length: Optional[:class:`int`] 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` 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` or :class:`OptionChoice`. + autocomplete: Optional[Callable[[:class:`.AutocompleteContext`], Awaitable[Union[Iterable[:class:`.OptionChoice`], Iterable[:class:`str`], Iterable[:class:`int`], Iterable[:class:`float`]]]]] + The autocomplete handler for the option. Accepts a callable (sync or async) + that takes a single argument of :class:`AutocompleteContext`. + The callable must return an iterable of :class:`str` or :class:`OptionChoice`. + Alternatively, :func:`discord.utils.basic_autocomplete` may be used in place of the callable. .. note:: @@ -194,7 +199,14 @@ def __init__( input_type_is_class = isinstance(input_type, type) if input_type_is_class and issubclass(input_type, (Enum, DiscordEnum)): if description is None: - description = inspect.getdoc(input_type) + description = inspect.cleandoc(input_type.__doc__) + if description and len(description) > 100: + description = description[:97] + "..." + _log.warning( + "Option %s's description was truncated due to Enum %s's docstring exceeding 100 characters.", + self.name, + input_type, + ) enum_choices = [OptionChoice(e.name, e.value) for e in input_type] value_class = enum_choices[0].value.__class__ if all(isinstance(elem.value, value_class) for elem in enum_choices): @@ -249,10 +261,19 @@ def __init__( kwargs.pop("required", True) if "default" not in kwargs else False ) self.default = kwargs.pop("default", None) - self.choices: list[OptionChoice] = enum_choices or [ - o if isinstance(o, OptionChoice) else OptionChoice(o) - for o in kwargs.pop("choices", []) - ] + + self.autocomplete = kwargs.pop("autocomplete", None) + if len(enum_choices) > 25: + self.choices: list[OptionChoice] = [] + for e in enum_choices: + e.value = str(e.value) + self.autocomplete = basic_autocomplete(enum_choices) + self.input_type = SlashCommandOptionType.string + else: + self.choices: list[OptionChoice] = enum_choices or [ + o if isinstance(o, OptionChoice) else OptionChoice(o) + for o in kwargs.pop("choices", []) + ] if self.input_type == SlashCommandOptionType.integer: minmax_types = (int, type(None)) @@ -322,13 +343,14 @@ def __init__( if self.max_length < 1 or self.max_length > 6000: raise AttributeError("max_length must between 1 and 6000 (inclusive)") - self.autocomplete = kwargs.pop("autocomplete", None) - self.name_localizations = kwargs.pop("name_localizations", MISSING) self.description_localizations = kwargs.pop( "description_localizations", MISSING ) + if input_type is None: + raise TypeError("input_type cannot be NoneType.") + def to_dict(self) -> dict: as_dict = { "name": self.name, @@ -394,7 +416,7 @@ def to_dict(self) -> dict[str, str | int | float]: return as_dict -def option(name, type=None, **kwargs): +def option(name, input_type=None, **kwargs): """A decorator that can be used instead of typehinting :class:`.Option`. .. versionadded:: 2.0 @@ -407,12 +429,13 @@ def option(name, type=None, **kwargs): """ def decorator(func): - nonlocal type - type = type or func.__annotations__.get(name, str) - if parameter := kwargs.get("parameter_name"): - func.__annotations__[parameter] = Option(type, name=name, **kwargs) - else: - func.__annotations__[name] = Option(type, **kwargs) + resolved_name = kwargs.pop("parameter_name", None) or name + itype = ( + kwargs.pop("type", None) + or input_type + or func.__annotations__.get(resolved_name, str) + ) + func.__annotations__[resolved_name] = Option(itype, name=name, **kwargs) return func return decorator diff --git a/discord/commands/permissions.py b/discord/commands/permissions.py index df951ae001..daf633b05a 100644 --- a/discord/commands/permissions.py +++ b/discord/commands/permissions.py @@ -25,6 +25,7 @@ from typing import Callable +from ..enums import InteractionContextType from ..permissions import Permissions from .core import ApplicationCommand @@ -32,7 +33,7 @@ def default_permissions(**perms: bool) -> Callable: - """A decorator that limits the usage of a slash command to members with certain + """A decorator that limits the usage of an application command to members with certain permissions. The permissions passed in must be exactly like the properties shown under @@ -80,7 +81,7 @@ def inner(command: Callable): def guild_only() -> Callable: - """A decorator that limits the usage of a slash command to guild contexts. + """A decorator that limits the usage of an application command to guild contexts. The command won't be able to be used in private message channels. Example @@ -98,9 +99,9 @@ async def test(ctx): def inner(command: Callable): if isinstance(command, ApplicationCommand): - command.guild_only = True + command.contexts = {InteractionContextType.guild} else: - command.__guild_only__ = True + command.__contexts__ = {InteractionContextType.guild} return command @@ -108,7 +109,7 @@ def inner(command: Callable): def is_nsfw() -> Callable: - """A decorator that limits the usage of a slash command to 18+ channels and users. + """A decorator that limits the usage of an application command to 18+ channels and users. In guilds, the command will only be able to be used in channels marked as NSFW. In DMs, users must have opted into age-restricted commands via privacy settings. diff --git a/discord/components.py b/discord/components.py index eb360f7250..d85fc4b07c 100644 --- a/discord/components.py +++ b/discord/components.py @@ -234,6 +234,8 @@ class Button(Component): The label of the button, if any. emoji: Optional[:class:`PartialEmoji`] The emoji of the button, if available. + sku_id: Optional[:class:`int`] + The ID of the SKU this button refers to. """ __slots__: tuple[str, ...] = ( @@ -243,6 +245,7 @@ class Button(Component): "disabled", "label", "emoji", + "sku_id", ) __repr_info__: ClassVar[tuple[str, ...]] = __slots__ @@ -259,6 +262,7 @@ def __init__(self, data: ButtonComponentPayload): self.emoji = PartialEmoji.from_dict(data["emoji"]) except KeyError: self.emoji = None + self.sku_id: str | None = data.get("sku_id") def to_dict(self) -> ButtonComponentPayload: payload = { @@ -276,6 +280,9 @@ def to_dict(self) -> ButtonComponentPayload: if self.emoji: payload["emoji"] = self.emoji.to_dict() + if self.sku_id: + payload["sku_id"] = self.sku_id + return payload # type: ignore diff --git a/discord/context_managers.py b/discord/context_managers.py index c9d930b5e4..9f1a282b3f 100644 --- a/discord/context_managers.py +++ b/discord/context_managers.py @@ -38,7 +38,7 @@ __all__ = ("Typing",) -def _typing_done_callback(fut: asyncio.Future) -> None: +def _typing_done_callback(fut: asyncio.Task) -> None: # just retrieve any exception and call it a day try: fut.exception() diff --git a/discord/enums.py b/discord/enums.py index f2bb6d3452..14aa54d460 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -22,6 +22,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from __future__ import annotations import types @@ -67,7 +68,14 @@ "AutoModActionType", "AutoModKeywordPresetType", "ApplicationRoleConnectionMetadataType", + "PromptType", + "OnboardingMode", "ReactionType", + "SKUType", + "EntitlementType", + "EntitlementOwnerType", + "IntegrationType", + "InteractionContextType", ) @@ -428,6 +436,13 @@ class AuditLogAction(Enum): auto_moderation_user_communication_disabled = 145 creator_monetization_request_created = 150 creator_monetization_terms_accepted = 151 + onboarding_question_create = 163 + onboarding_question_update = 164 + onboarding_update = 167 + server_guide_create = 190 + server_guide_update = 191 + voice_channel_status_update = 192 + voice_channel_status_delete = 193 @property def category(self) -> AuditLogActionCategory | None: @@ -490,6 +505,13 @@ def category(self) -> AuditLogActionCategory | None: AuditLogAction.auto_moderation_user_communication_disabled: None, AuditLogAction.creator_monetization_request_created: None, AuditLogAction.creator_monetization_terms_accepted: None, + AuditLogAction.onboarding_question_create: AuditLogActionCategory.create, + AuditLogAction.onboarding_question_update: AuditLogActionCategory.update, + AuditLogAction.onboarding_update: AuditLogActionCategory.update, + AuditLogAction.server_guide_create: AuditLogActionCategory.create, + AuditLogAction.server_guide_update: AuditLogActionCategory.update, + AuditLogAction.voice_channel_status_update: AuditLogActionCategory.update, + AuditLogAction.voice_channel_status_delete: AuditLogActionCategory.delete, } return lookup[self] @@ -530,6 +552,14 @@ def target_type(self) -> str | None: return "application_command_permission" elif v < 146: return "auto_moderation_rule" + elif v < 152: + return "monetization" + elif v < 168: + return "onboarding" + elif v < 192: + return "server_guide" + elif v < 194: + return "voice_channel_status" class UserFlags(Enum): @@ -656,6 +686,7 @@ class InteractionResponseType(Enum): message_update = 7 # for components auto_complete_result = 8 # for autocomplete interactions modal = 9 # for modal dialogs + premium_required = 10 class VideoQualityMode(Enum): @@ -693,6 +724,7 @@ class ButtonStyle(Enum): success = 3 danger = 4 link = 5 + premium = 6 # Aliases blurple = 1 @@ -777,9 +809,13 @@ def from_datatype(cls, datatype): # Type checking fails for this case, so ignore it. return cls.from_datatype(datatype.__args__) # type: ignore - if datatype.__name__ in ["Member", "User"]: + if isinstance(datatype, str): + datatype_name = datatype + else: + datatype_name = datatype.__name__ + if datatype_name in ["Member", "User"]: return cls.user - if datatype.__name__ in [ + if datatype_name in [ "GuildChannel", "TextChannel", "VoiceChannel", @@ -791,14 +827,14 @@ def from_datatype(cls, datatype): "DMChannel", ]: return cls.channel - if datatype.__name__ == "Role": + if datatype_name == "Role": return cls.role - if datatype.__name__ == "Attachment": + if datatype_name == "Attachment": return cls.attachment - if datatype.__name__ == "Mentionable": + if datatype_name == "Mentionable": return cls.mentionable - if issubclass(datatype, str): + if isinstance(datatype, str) or issubclass(datatype, str): return cls.string if issubclass(datatype, bool): return cls.boolean @@ -808,9 +844,10 @@ def from_datatype(cls, datatype): return cls.number from .commands.context import ApplicationContext + from .ext.bridge import BridgeContext if not issubclass( - datatype, ApplicationContext + datatype, (ApplicationContext, BridgeContext) ): # TODO: prevent ctx being passed here in cog commands raise TypeError( f"Invalid class {datatype} used as an input type for an Option" @@ -945,6 +982,20 @@ class ApplicationRoleConnectionMetadataType(Enum): boolean_not_equal = 8 +class PromptType(Enum): + """Guild Onboarding Prompt Type""" + + multiple_choice = 0 + dropdown = 1 + + +class OnboardingMode(Enum): + """Guild Onboarding Mode""" + + default = 0 + advanced = 1 + + class ReactionType(Enum): """The reaction type""" @@ -952,6 +1003,56 @@ class ReactionType(Enum): burst = 1 +class SKUType(Enum): + """The SKU type""" + + durable = 2 + consumable = 3 + subscription = 5 + subscription_group = 6 + + +class EntitlementType(Enum): + """The entitlement type""" + + purchase = 1 + premium_subscription = 2 + developer_gift = 3 + test_mode_purchase = 4 + free_purchase = 5 + user_gift = 6 + premium_purchase = 7 + application_subscription = 8 + + +class EntitlementOwnerType(Enum): + """The entitlement owner type""" + + guild = 1 + user = 2 + + +class IntegrationType(Enum): + """The application's integration type""" + + guild_install = 0 + user_install = 1 + + +class InteractionContextType(Enum): + """The interaction's context type""" + + guild = 0 + bot_dm = 1 + private_channel = 2 + + +class PollLayoutType(Enum): + """The poll's layout type.""" + + default = 1 + + T = TypeVar("T") diff --git a/discord/ext/bridge/bot.py b/discord/ext/bridge/bot.py index e44927e735..9e2c619fa6 100644 --- a/discord/ext/bridge/bot.py +++ b/discord/ext/bridge/bot.py @@ -22,6 +22,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from __future__ import annotations from abc import ABC diff --git a/discord/ext/bridge/context.py b/discord/ext/bridge/context.py index 033fdd6caf..8e7f9414f6 100644 --- a/discord/ext/bridge/context.py +++ b/discord/ext/bridge/context.py @@ -22,6 +22,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from __future__ import annotations from abc import ABC, abstractmethod @@ -66,22 +67,20 @@ async def example(ctx: BridgeContext): """ @abstractmethod - async def _respond(self, *args, **kwargs) -> Interaction | WebhookMessage | Message: - ... + async def _respond( + self, *args, **kwargs + ) -> Interaction | WebhookMessage | Message: ... @abstractmethod - async def _defer(self, *args, **kwargs) -> None: - ... + async def _defer(self, *args, **kwargs) -> None: ... @abstractmethod - async def _edit(self, *args, **kwargs) -> InteractionMessage | Message: - ... + async def _edit(self, *args, **kwargs) -> InteractionMessage | Message: ... @overload async def invoke( self, command: BridgeSlashCommand | BridgeExtCommand, *args, **kwargs - ) -> None: - ... + ) -> None: ... async def respond(self, *args, **kwargs) -> Interaction | WebhookMessage | Message: """|coro| diff --git a/discord/ext/bridge/core.py b/discord/ext/bridge/core.py index 427bd0269f..384e9ab37a 100644 --- a/discord/ext/bridge/core.py +++ b/discord/ext/bridge/core.py @@ -22,6 +22,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from __future__ import annotations import inspect @@ -39,7 +40,7 @@ SlashCommandOptionType, ) -from ...utils import MISSING, find, get +from ...utils import MISSING, find, get, warn_deprecated from ..commands import BadArgument from ..commands import Bot as ExtBot from ..commands import ( @@ -62,10 +63,12 @@ "BridgeCommandGroup", "bridge_command", "bridge_group", + "bridge_option", "BridgeExtCommand", "BridgeSlashCommand", "BridgeExtGroup", "BridgeSlashGroup", + "BridgeOption", "map_to", "guild_only", "has_permissions", @@ -93,6 +96,28 @@ class BridgeExtCommand(Command): def __init__(self, func, **kwargs): super().__init__(func, **kwargs) + # TODO: v2.7: Remove backwards support for Option in bridge commands. + for name, option in self.params.items(): + if isinstance(option.annotation, Option) and not isinstance( + option.annotation, BridgeOption + ): + # Warn not to do this + warn_deprecated( + "Using Option for bridge commands", + "BridgeOption", + "2.5", + "2.7", + reference="https://github.com/Pycord-Development/pycord/pull/2417", + stacklevel=6, + ) + # Override the convert method of the parameter's annotated Option. + # We can use the convert method from BridgeOption, and bind "self" + # using a manual invocation of the descriptor protocol. + # Definitely not a good approach, but gets the job done until removal. + self.params[name].annotation.convert = BridgeOption.convert.__get__( + self.params[name].annotation + ) + async def dispatch_error(self, ctx: BridgeExtContext, error: Exception) -> None: await super().dispatch_error(ctx, error) ctx.bot.dispatch("bridge_command_error", ctx, error) @@ -523,7 +548,7 @@ def predicate(func: Callable | ApplicationCommand): return predicate -def has_permissions(**perms: dict[str, bool]): +def has_permissions(**perms: bool): r"""Intended to work with :class:`.SlashCommand` and :class:`BridgeCommand`, adds a :func:`~ext.commands.check` that locks the command to be run by people with certain permissions inside guilds, and also registers the command as locked behind said permissions. @@ -591,6 +616,10 @@ async def convert(self, ctx, arg: bool): class BridgeOption(Option, Converter): + """A subclass of :class:`discord.Option` which represents a selectable slash + command option and a prefixed command argument for bridge commands. + """ + async def convert(self, ctx, argument: str) -> Any: try: if self.converter is not None: @@ -623,5 +652,26 @@ async def convert(self, ctx, argument: str) -> Any: raise BadArgument() from exc -discord.commands.options.Option = BridgeOption -discord.Option = BridgeOption +def bridge_option(name, input_type=None, **kwargs): + """A decorator that can be used instead of typehinting :class:`.BridgeOption`. + + .. versionadded:: 2.6 + + 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): + resolved_name = kwargs.pop("parameter_name", None) or name + itype = ( + kwargs.pop("type", None) + or input_type + or func.__annotations__.get(resolved_name, str) + ) + func.__annotations__[resolved_name] = BridgeOption(itype, name=name, **kwargs) + return func + + return decorator diff --git a/discord/ext/commands/_types.py b/discord/ext/commands/_types.py index 7f86ac6d47..d3f0336471 100644 --- a/discord/ext/commands/_types.py +++ b/discord/ext/commands/_types.py @@ -23,7 +23,6 @@ DEALINGS IN THE SOFTWARE. """ - from typing import TYPE_CHECKING, Any, Callable, Coroutine, TypeVar, Union if TYPE_CHECKING: diff --git a/discord/ext/commands/bot.py b/discord/ext/commands/bot.py index cd70a2393f..d348b6c536 100644 --- a/discord/ext/commands/bot.py +++ b/discord/ext/commands/bot.py @@ -115,12 +115,14 @@ class BotBase(GroupMixin, discord.cog.CogMixin): def __init__( self, - command_prefix: str - | Iterable[str] - | Callable[ - [Bot | AutoShardedBot, Message], - str | Iterable[str] | Coroutine[Any, Any, str | Iterable[str]], - ] = when_mentioned, + 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, ): diff --git a/discord/ext/commands/cog.py b/discord/ext/commands/cog.py index aa61fb24d0..570609d9a2 100644 --- a/discord/ext/commands/cog.py +++ b/discord/ext/commands/cog.py @@ -22,6 +22,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from __future__ import annotations from typing import TYPE_CHECKING, Any, Callable, Generator, TypeVar diff --git a/discord/ext/commands/context.py b/discord/ext/commands/context.py index 08d0f9f092..c140586faa 100644 --- a/discord/ext/commands/context.py +++ b/discord/ext/commands/context.py @@ -22,6 +22,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from __future__ import annotations import inspect diff --git a/discord/ext/commands/converter.py b/discord/ext/commands/converter.py index 7a4bbabf46..3ed53fa1d0 100644 --- a/discord/ext/commands/converter.py +++ b/discord/ext/commands/converter.py @@ -1166,7 +1166,7 @@ async def _actual_conversion( async def run_converters( - ctx: Context, converter, argument: str, param: inspect.Parameter + ctx: Context, converter, argument: str | None, param: inspect.Parameter ): """|coro| @@ -1182,7 +1182,7 @@ async def run_converters( The invocation context to run the converters under. converter: Any The converter to run, this corresponds to the annotation in the function. - argument: :class:`str` + argument: Optional[:class:`str`] The argument to convert to. param: :class:`inspect.Parameter` The parameter being converted. This is mainly for error reporting. diff --git a/discord/ext/commands/core.py b/discord/ext/commands/core.py index c2282d28bf..1f876f175d 100644 --- a/discord/ext/commands/core.py +++ b/discord/ext/commands/core.py @@ -22,6 +22,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from __future__ import annotations import asyncio @@ -297,6 +298,7 @@ class Command(_BaseCommand, Generic[CogT, P, T]): .. versionadded:: 2.0 """ + __original_kwargs__: dict[str, Any] def __new__(cls: type[CommandT], *args: Any, **kwargs: Any) -> CommandT: @@ -1395,8 +1397,7 @@ def command( ) ], Command[CogT, P, T], - ]: - ... + ]: ... @overload def command( @@ -1405,8 +1406,7 @@ def command( cls: type[CommandT] = ..., *args: Any, **kwargs: Any, - ) -> Callable[[Callable[Concatenate[ContextT, P], Coro[Any]]], CommandT]: - ... + ) -> Callable[[Callable[Concatenate[ContextT, P], Coro[Any]]], CommandT]: ... def command( self, @@ -1447,8 +1447,7 @@ def group( ) ], Group[CogT, P, T], - ]: - ... + ]: ... @overload def group( @@ -1457,8 +1456,7 @@ def group( cls: type[GroupT] = ..., *args: Any, **kwargs: Any, - ) -> Callable[[Callable[Concatenate[ContextT, P], Coro[Any]]], GroupT]: - ... + ) -> Callable[[Callable[Concatenate[ContextT, P], Coro[Any]]], GroupT]: ... def group( self, @@ -1615,8 +1613,7 @@ def command( ) ], Command[CogT, P, T], -]: - ... +]: ... @overload @@ -1632,8 +1629,7 @@ def command( ) ], Command[CogT, P, T], -]: - ... +]: ... @overload @@ -1649,8 +1645,7 @@ def command( ) ], CommandT, -]: - ... +]: ... def command( @@ -1722,8 +1717,7 @@ def group( ) ], Group[CogT, P, T], -]: - ... +]: ... @overload @@ -1739,8 +1733,7 @@ def group( ) ], GroupT, -]: - ... +]: ... def group( diff --git a/discord/ext/commands/help.py b/discord/ext/commands/help.py index 90899ef6a8..25b44388bd 100644 --- a/discord/ext/commands/help.py +++ b/discord/ext/commands/help.py @@ -90,7 +90,13 @@ class Paginator: .. versionadded:: 1.7 """ - def __init__(self, prefix="```", suffix="```", max_size=2000, linesep="\n"): + def __init__( + self, + prefix: str | None = "```", + suffix: str | None = "```", + max_size: int = 2000, + linesep: str = "\n", + ): self.prefix = prefix self.suffix = suffix self.max_size = max_size diff --git a/discord/ext/pages/pagination.py b/discord/ext/pages/pagination.py index f9f08b965f..d3f2d32800 100644 --- a/discord/ext/pages/pagination.py +++ b/discord/ext/pages/pagination.py @@ -21,13 +21,18 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from __future__ import annotations from typing import List import discord +from discord.errors import DiscordException from discord.ext.bridge import BridgeContext from discord.ext.commands import Context +from discord.file import File +from discord.member import Member +from discord.user import User __all__ = ( "PaginatorButton", @@ -100,26 +105,25 @@ async def callback(self, interaction: discord.Interaction): interaction: :class:`discord.Interaction` The interaction created by clicking the navigation button. """ + new_page = self.paginator.current_page if self.button_type == "first": - self.paginator.current_page = 0 + new_page = 0 elif self.button_type == "prev": if self.paginator.loop_pages and self.paginator.current_page == 0: - self.paginator.current_page = self.paginator.page_count + new_page = self.paginator.page_count else: - self.paginator.current_page -= 1 + new_page -= 1 elif self.button_type == "next": if ( self.paginator.loop_pages and self.paginator.current_page == self.paginator.page_count ): - self.paginator.current_page = 0 + new_page = 0 else: - self.paginator.current_page += 1 + new_page += 1 elif self.button_type == "last": - self.paginator.current_page = self.paginator.page_count - await self.paginator.goto_page( - page_number=self.paginator.current_page, interaction=interaction - ) + new_page = self.paginator.page_count + await self.paginator.goto_page(page_number=new_page, interaction=interaction) class Page: @@ -271,7 +275,7 @@ class PageGroup: def __init__( self, - pages: (list[str] | list[Page] | list[list[discord.Embed] | discord.Embed]), + pages: list[str] | list[Page] | list[list[discord.Embed] | discord.Embed], label: str, description: str | None = None, emoji: str | discord.Emoji | discord.PartialEmoji = None, @@ -291,7 +295,7 @@ def __init__( self.label = label self.description: str | None = description self.emoji: str | discord.Emoji | discord.PartialEmoji = emoji - self.pages: (list[str] | list[list[discord.Embed] | discord.Embed]) = pages + self.pages: list[str] | list[list[discord.Embed] | discord.Embed] = pages self.default: bool | None = default self.show_disabled = show_disabled self.show_indicator = show_indicator @@ -444,8 +448,7 @@ def __init__( async def update( self, - pages: None - | ( + pages: None | ( list[PageGroup] | list[Page] | list[str] @@ -654,6 +657,20 @@ async def cancel( else: await self.message.edit(view=self) + def _goto_page(self, page_number: int = 0) -> tuple[Page, list[File] | None]: + self.current_page = page_number + self.update_buttons() + + page = self.pages[page_number] + page = self.get_page_content(page) + + if page.custom_view: + self.update_custom_view(page.custom_view) + + files = page.update_files() + + return page, files + async def goto_page( self, page_number: int = 0, *, interaction: discord.Interaction | None = None ) -> None: @@ -678,42 +695,34 @@ async def goto_page( :class:`~discord.Message` The message associated with the paginator. """ - self.update_buttons() - self.current_page = page_number - if self.show_indicator: - try: - self.buttons["page_indicator"][ - "object" - ].label = f"{self.current_page + 1}/{self.page_count + 1}" - except KeyError: - pass - - page = self.pages[page_number] - page = self.get_page_content(page) + old_page = self.current_page + page, files = self._goto_page(page_number) - if page.custom_view: - self.update_custom_view(page.custom_view) - - files = page.update_files() + try: + if interaction: + await interaction.response.defer() # needed to force webhook message edit route for files kwarg support + await interaction.followup.edit_message( + message_id=self.message.id, + content=page.content, + embeds=page.embeds, + attachments=[], + files=files or [], + view=self, + ) + else: + await self.message.edit( + content=page.content, + embeds=page.embeds, + attachments=[], + files=files or [], + view=self, + ) + except DiscordException: + # Something went wrong, and the paginator couldn't be updated. + # Revert our changes and propagate the error. + self._goto_page(old_page) + raise - if interaction: - await interaction.response.defer() # needed to force webhook message edit route for files kwarg support - await interaction.followup.edit_message( - message_id=self.message.id, - content=page.content, - embeds=page.embeds, - attachments=[], - files=files or [], - view=self, - ) - else: - await self.message.edit( - content=page.content, - embeds=page.embeds, - attachments=[], - files=files or [], - view=self, - ) if self.trigger_on_display: await self.page_action(interaction=interaction) @@ -932,8 +941,9 @@ async def send( ctx: Context, target: discord.abc.Messageable | None = None, target_message: str | None = None, - reference: None - | (discord.Message | discord.MessageReference | discord.PartialMessage) = None, + reference: None | ( + discord.Message | discord.MessageReference | discord.PartialMessage + ) = None, allowed_mentions: discord.AllowedMentions | None = None, mention_author: bool | None = None, delete_after: float | None = None, @@ -1034,6 +1044,7 @@ async def edit( suppress: bool | None = None, allowed_mentions: discord.AllowedMentions | None = None, delete_after: float | None = None, + user: User | Member | None = None, ) -> discord.Message | None: """Edits an existing message to replace it with the paginator contents. @@ -1059,6 +1070,8 @@ async def edit( are used instead. delete_after: Optional[:class:`float`] If set, deletes the paginator after the specified time. + user: Optional[Union[:class:`~discord.User`, :class:`~discord.Member`]] + If set, changes the user that this paginator belongs to. Returns ------- @@ -1078,7 +1091,7 @@ async def edit( if page_content.custom_view: self.update_custom_view(page_content.custom_view) - self.user = message.author + self.user = user or self.user try: self.message = await message.edit( diff --git a/discord/flags.py b/discord/flags.py index 9f687ff9bc..8a1ac8fd7e 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -32,11 +32,15 @@ __all__ = ( "SystemChannelFlags", "MessageFlags", + "AttachmentFlags", "PublicUserFlags", "Intents", "MemberCacheFlags", "ApplicationFlags", "ChannelFlags", + "SKUFlags", + "RoleFlags", + "MemberFlags", ) FV = TypeVar("FV", bound="flag_value") @@ -49,12 +53,10 @@ def __init__(self, func: Callable[[Any], int]): self.__doc__ = func.__doc__ @overload - def __get__(self: FV, instance: None, owner: type[BF]) -> FV: - ... + def __get__(self: FV, instance: None, owner: type[BF]) -> FV: ... @overload - def __get__(self, instance: BF, owner: type[BF]) -> bool: - ... + def __get__(self, instance: BF, owner: type[BF]) -> bool: ... def __get__(self, instance: BF | None, owner: type[BF]) -> Any: if instance is None: @@ -830,6 +832,11 @@ def voice_states(self): - :attr:`VoiceChannel.members` - :attr:`VoiceChannel.voice_states` + - :attr:`StageChannel.members` + - :attr:`StageChannel.speakers` + - :attr:`StageChannel.listeners` + - :attr:`StageChannel.moderators` + - :attr:`StageChannel.voice_states` - :attr:`Member.voice` .. note:: @@ -880,6 +887,8 @@ def messages(self): - :class:`Message` - :attr:`Client.cached_messages` - :meth:`Client.get_message` + - :attr:`Client.polls` + - :meth:`Client.get_poll` Note that due to an implicit relationship this also corresponds to the following events: @@ -912,6 +921,8 @@ def guild_messages(self): - :class:`Message` - :attr:`Client.cached_messages` (only for guilds) - :meth:`Client.get_message` (only for guilds) + - :attr:`Client.polls` (only for guilds) + - :meth:`Client.get_poll` (only for guilds) Note that due to an implicit relationship this also corresponds to the following events: @@ -926,6 +937,7 @@ def guild_messages(self): - :attr:`Message.embeds` - :attr:`Message.attachments` - :attr:`Message.components` + - :attr:`Message.poll` For more information go to the :ref:`message content intent documentation `. """ @@ -950,6 +962,8 @@ def dm_messages(self): - :class:`Message` - :attr:`Client.cached_messages` (only for DMs) - :meth:`Client.get_message` (only for DMs) + - :attr:`Client.polls` (only for DMs) + - :meth:`Client.get_poll` (only for DMs) Note that due to an implicit relationship this also corresponds to the following events: @@ -1074,6 +1088,7 @@ def message_content(self): - :attr:`Message.embeds` - :attr:`Message.attachments` - :attr:`Message.components` + - :attr:`Message.poll` These attributes will still be available for messages received from interactions, the bot's own messages, messages the bot was mentioned in, and DMs. @@ -1132,6 +1147,66 @@ def auto_moderation_execution(self): """ return 1 << 21 + @flag_value + def guild_polls(self): + """:class:`bool`: Whether poll-related events in guilds are enabled. + + See also :attr:`dm_polls` for DMs or :attr:`polls` for both. + + This corresponds to the following events: + + - :func:`on_poll_vote_add` (only for guilds) + - :func:`on_poll_vote_remove` (only for guilds) + - :func:`on_raw_poll_vote_add` (only for guilds) + - :func:`on_raw_poll_vote_remove` (only for guilds) + + This also corresponds to the following attributes and classes in terms of cache: + + - :attr:`PollAnswer.count` (only for guild polls) + - :attr:`PollResults.answer_counts` (only for guild polls) + """ + return 1 << 24 + + @flag_value + def dm_polls(self): + """:class:`bool`: Whether poll-related events in direct messages are enabled. + + See also :attr:`guild_polls` for guilds or :attr:`polls` for both. + + This corresponds to the following events: + + - :func:`on_poll_vote_add` (only for DMs) + - :func:`on_poll_vote_remove` (only for DMs) + - :func:`on_raw_poll_vote_add` (only for DMs) + - :func:`on_raw_poll_vote_remove` (only for DMs) + + This also corresponds to the following attributes and classes in terms of cache: + + - :attr:`PollAnswer.count` (only for DM polls) + - :attr:`PollResults.answer_counts` (only for DM polls) + """ + return 1 << 25 + + @alias_flag_value + def polls(self): + """:class:`bool`: Whether poll-related events in guilds and direct messages are enabled. + + This is a shortcut to set or get both :attr:`guild_polls` and :attr:`dm_polls`. + + This corresponds to the following events: + + - :func:`on_poll_vote_add` (both guilds and DMs) + - :func:`on_poll_vote_remove` (both guilds and DMs) + - :func:`on_raw_poll_vote_add` (both guilds and DMs) + - :func:`on_raw_poll_vote_remove` (both guilds and DMs) + + This also corresponds to the following attributes and classes in terms of cache: + + - :attr:`PollAnswer.count` (both guild and DM polls) + - :attr:`PollResults.answer_counts` (both guild and DM polls) + """ + return (1 << 24) | (1 << 25) + @fill_with_flags() class MemberCacheFlags(BaseFlags): @@ -1485,3 +1560,256 @@ def require_tag(self): .. versionadded:: 2.2 """ return 1 << 4 + + +@fill_with_flags() +class AttachmentFlags(BaseFlags): + r"""Wraps up the Discord Attachment flags. + + .. container:: operations + + .. describe:: x == y + + Checks if two flags are equal. + .. describe:: x != y + + Checks if two flags are not equal. + .. describe:: x + y + + Adds two flags together. Equivalent to ``x | y``. + .. describe:: x - y + + Subtracts two flags from each other. + .. describe:: x | y + + Returns the union of two flags. Equivalent to ``x + y``. + .. describe:: x & y + + Returns the intersection of two flags. + .. describe:: ~x + + Returns the inverse of a flag. + .. describe:: hash(x) + + Return the flag's hash. + .. describe:: iter(x) + + Returns an iterator of ``(name, value)`` pairs. This allows it + to be, for example, constructed as a dict or a list of pairs. + Note that aliases are not shown. + + .. versionadded:: 2.5 + + Attributes + ----------- + value: :class:`int` + The raw value. You should query flags via the properties + rather than using this raw value. + """ + + __slots__ = () + + @flag_value + def is_clip(self): + """:class:`bool`: Returns ``True`` if the attachment is a clip.""" + return 1 << 0 + + @flag_value + def is_thumbnail(self): + """:class:`bool`: Returns ``True`` if the attachment is a thumbnail.""" + return 1 << 1 + + @flag_value + def is_remix(self): + """:class:`bool`: Returns ``True`` if the attachment has been remixed.""" + return 1 << 2 + + +@fill_with_flags() +class SKUFlags(BaseFlags): + r"""Wraps up the Discord SKU flags. + + .. container:: operations + + .. describe:: x == y + + Checks if two SKUFlags are equal. + .. describe:: x != y + + Checks if two SKUFlags are not equal. + .. describe:: x + y + + Adds two flags together. Equivalent to ``x | y``. + .. describe:: x - y + + Subtracts two flags from each other. + .. describe:: x | y + + Returns the union of two flags. Equivalent to ``x + y``. + .. describe:: x & y + + Returns the intersection of two flags. + .. describe:: ~x + + Returns the inverse of a flag. + .. describe:: hash(x) + + Return the flag's hash. + .. describe:: iter(x) + + Returns an iterator of ``(name, value)`` pairs. This allows it + to be, for example, constructed as a dict or a list of pairs. + Note that aliases are not shown. + + .. versionadded:: 2.5 + + Attributes + ----------- + value: :class:`int` + The raw value. You should query flags via the properties + rather than using this raw value. + """ + + __slots__ = () + + @flag_value + def available(self): + """:class:`bool`: Returns ``True`` if the SKU is available for purchase.""" + return 1 << 2 + + @flag_value + def guild_subscription(self): + """:class:`bool`: Returns ``True`` if the SKU is a guild subscription.""" + return 1 << 7 + + @flag_value + def user_subscription(self): + """:class:`bool`: Returns ``True`` if the SKU is a user subscription.""" + return 1 << 8 + + +@fill_with_flags() +class MemberFlags(BaseFlags): + r"""Wraps up the Discord Member flags. + + .. container:: operations + + .. describe:: x == y + + Checks if two MemberFlags are equal. + .. describe:: x != y + + Checks if two MemberFlags are not equal. + .. describe:: x + y + + Adds two flags together. Equivalent to ``x | y``. + .. describe:: x - y + + Subtracts two flags from each other. + .. describe:: x | y + + Returns the union of two flags. Equivalent to ``x + y``. + .. describe:: x & y + + Returns the intersection of two flags. + .. describe:: ~x + + Returns the inverse of a flag. + .. describe:: hash(x) + + Return the flag's hash. + .. describe:: iter(x) + + Returns an iterator of ``(name, value)`` pairs. This allows it + to be, for example, constructed as a dict or a list of pairs. + Note that aliases are not shown. + + .. versionadded:: 2.6 + + Attributes + ----------- + value: :class:`int` + The raw value. You should query flags via the properties + rather than using this raw value. + """ + + __slots__ = () + + @flag_value + def did_rejoin(self): + """:class:`bool`: Returns ``True`` if the member left and rejoined the guild.""" + return 1 << 0 + + @flag_value + def completed_onboarding(self): + """:class:`bool`: Returns ``True`` if the member has completed onboarding.""" + return 1 << 1 + + @flag_value + def bypasses_verification(self): + """:class:`bool`: Returns ``True`` if the member is exempt from verification requirements. + + .. note:: + + This can be edited through :func:`~discord.Member.edit`. + """ + return 1 << 2 + + @flag_value + def started_onboarding(self): + """:class:`bool`: Returns ``True`` if the member has started onboarding.""" + return 1 << 3 + + +@fill_with_flags() +class RoleFlags(BaseFlags): + r"""Wraps up the Discord Role flags. + + .. container:: operations + + .. describe:: x == y + + Checks if two RoleFlags are equal. + .. describe:: x != y + + Checks if two RoleFlags are not equal. + .. describe:: x + y + + Adds two flags together. Equivalent to ``x | y``. + .. describe:: x - y + + Subtracts two flags from each other. + .. describe:: x | y + + Returns the union of two flags. Equivalent to ``x + y``. + .. describe:: x & y + + Returns the intersection of two flags. + .. describe:: ~x + + Returns the inverse of a flag. + .. describe:: hash(x) + + Return the flag's hash. + .. describe:: iter(x) + + Returns an iterator of ``(name, value)`` pairs. This allows it + to be, for example, constructed as a dict or a list of pairs. + Note that aliases are not shown. + + .. versionadded:: 2.6 + + Attributes + ----------- + value: :class:`int` + The raw value. This value is a bit array field of a 53-bit integer + representing the currently available flags. You should query + flags via the properties rather than using this raw value. + """ + + __slots__ = () + + @flag_value + def in_prompt(self): + """:class:`bool`: Returns ``True`` if the role is selectable in one of the guild's :class:`~discord.OnboardingPrompt`.""" + return 1 << 0 diff --git a/discord/gateway.py b/discord/gateway.py index bd13f22e46..7a765405af 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -22,6 +22,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from __future__ import annotations import asyncio @@ -602,13 +603,11 @@ async def poll_event(self): await self.received_message(msg.data) elif msg.type is aiohttp.WSMsgType.BINARY: await self.received_message(msg.data) - elif msg.type is aiohttp.WSMsgType.ERROR: - _log.debug("Received %s", msg) - raise msg.data elif msg.type in ( aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSING, aiohttp.WSMsgType.CLOSE, + aiohttp.WSMsgType.ERROR, ): _log.debug("Received %s", msg) raise WebSocketClosure diff --git a/discord/guild.py b/discord/guild.py index f2ef9aa8a0..eda67a2e13 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -32,7 +32,6 @@ Any, ClassVar, List, - Literal, NamedTuple, Optional, Sequence, @@ -54,6 +53,7 @@ AutoModTriggerType, ChannelType, ContentFilter, + EntitlementOwnerType, NotificationLevel, NSFWLevel, ScheduledEventLocationType, @@ -68,9 +68,16 @@ from .flags import SystemChannelFlags from .integrations import Integration, _integration_factory from .invite import Invite -from .iterators import AuditLogIterator, BanIterator, MemberIterator +from .iterators import ( + AuditLogIterator, + BanIterator, + EntitlementIterator, + MemberIterator, +) from .member import Member, VoiceState from .mixins import Hashable +from .monetization import Entitlement +from .onboarding import Onboarding from .permissions import PermissionOverwrite from .role import Role from .scheduled_events import ScheduledEvent, ScheduledEventLocation @@ -105,7 +112,7 @@ from .types.member import Member as MemberPayload from .types.threads import Thread as ThreadPayload from .types.voice import GuildVoiceState - from .voice_client import VoiceProtocol + from .voice_client import VoiceClient from .webhook import Webhook VocalGuildChannel = Union[VoiceChannel, StageChannel] @@ -536,7 +543,7 @@ def _from_data(self, guild: GuildPayload) -> None: ) self.owner_id: int | None = utils._get_as_snowflake(guild, "owner_id") - self.afk_channel: VocalGuildChannel | None = self.get_channel( + self.afk_channel: VoiceChannel | None = self.get_channel( utils._get_as_snowflake(guild, "afk_channel_id") ) # type: ignore @@ -645,8 +652,8 @@ def me(self) -> Member: return self.get_member(self_id) # type: ignore @property - def voice_client(self) -> VoiceProtocol | None: - """Returns the :class:`VoiceProtocol` associated with this guild, if any.""" + def voice_client(self) -> VoiceClient | None: + """Returns the :class:`VoiceClient` associated with this guild, if any.""" return self._state._get_voice_client(self.id) @property @@ -820,14 +827,16 @@ def sticker_limit(self) -> int: ) @property - def bitrate_limit(self) -> float: + def bitrate_limit(self) -> int: """The maximum bitrate for voice channels this guild can have.""" vip_guild = ( self._PREMIUM_GUILD_LIMITS[1].bitrate if "VIP_REGIONS" in self.features else 96e3 ) - return max(vip_guild, self._PREMIUM_GUILD_LIMITS[self.premium_tier].bitrate) + return int( + max(vip_guild, self._PREMIUM_GUILD_LIMITS[self.premium_tier].bitrate) + ) @property def filesize_limit(self) -> int: @@ -1506,9 +1515,9 @@ async def create_forum_channel( "default_reaction_emoji must be of type: Emoji | int | str" ) - options[ - "default_reaction_emoji" - ] = default_reaction_emoji._to_forum_reaction_payload() + options["default_reaction_emoji"] = ( + default_reaction_emoji._to_forum_reaction_payload() + ) data = await self._create_channel( name, @@ -1645,7 +1654,6 @@ async def edit( default_notifications: NotificationLevel = MISSING, verification_level: VerificationLevel = MISSING, explicit_content_filter: ContentFilter = MISSING, - vanity_code: str = MISSING, system_channel: TextChannel | None = MISSING, system_channel_flags: SystemChannelFlags = MISSING, preferred_locale: str = MISSING, @@ -1711,8 +1719,6 @@ async def edit( The new default notification level for the guild. explicit_content_filter: :class:`ContentFilter` The new explicit content filter for the guild. - vanity_code: :class:`str` - The new vanity code for the guild. system_channel: Optional[:class:`TextChannel`] The new channel that is used for the system channel. Could be ``None`` for no system channel. system_channel_flags: :class:`SystemChannelFlags` @@ -1755,9 +1761,6 @@ async def edit( http = self._state.http - if vanity_code is not MISSING: - await http.change_vanity_code(self.id, vanity_code, reason=reason) - fields: dict[str, Any] = {} if name is not MISSING: fields["name"] = name @@ -2022,6 +2025,36 @@ def fetch_members( return MemberIterator(self, limit=limit, after=after) + async def search_members(self, query: str, *, limit: int = 1000) -> list[Member]: + """Search for guild members whose usernames or nicknames start with the query string. Unlike :meth:`fetch_members`, this does not require :meth:`Intents.members`. + + .. note:: + + This method is an API call. For general usage, consider filtering :attr:`members` instead. + + .. versionadded:: 2.6 + + Parameters + ---------- + query: :class:`str` + Searches for usernames and nicknames that start with this string, case-insensitive. + limit: :class:`int` + The maximum number of members to retrieve, up to 1000. + + Returns + ------- + List[:class:`Member`] + The list of members that have matched the query. + + Raises + ------ + HTTPException + Getting the members failed. + """ + + data = await self._state.http.search_members(self.id, query, limit) + return [Member(data=m, guild=self, state=self._state) for m in data] + async def fetch_member(self, member_id: int, /) -> Member: """|coro| @@ -2826,8 +2859,7 @@ async def create_role( mentionable: bool = ..., icon: bytes | None = MISSING, unicode_emoji: str | None = MISSING, - ) -> Role: - ... + ) -> Role: ... @overload async def create_role( @@ -2841,8 +2873,7 @@ async def create_role( mentionable: bool = ..., icon: bytes | None = ..., unicode_emoji: str | None = ..., - ) -> Role: - ... + ) -> Role: ... async def create_role( self, @@ -3043,7 +3074,6 @@ async def ban( user: Snowflake, *, delete_message_seconds: int | None = None, - delete_message_days: Literal[0, 1, 2, 3, 4, 5, 6, 7] | None = None, reason: str | None = None, ) -> None: """|coro| @@ -3063,9 +3093,6 @@ async def ban( The number of seconds worth of messages to delete from the user in the guild. The minimum is 0 and the maximum is 604800 (i.e. 7 days). The default is 0. - delete_message_days: Optional[:class:`int`] - ***Deprecated parameter***, same as ``delete_message_seconds`` but - is used for days instead. reason: Optional[:class:`str`] The reason the user got banned. @@ -3076,11 +3103,67 @@ async def ban( HTTPException Banning failed. """ - if delete_message_seconds and delete_message_days: + + if delete_message_seconds is not None and not ( + 0 <= delete_message_seconds <= 604800 + ): raise TypeError( - "delete_message_seconds and delete_message_days are mutually exclusive." + "delete_message_seconds must be between 0 and 604800 seconds." ) + await self._state.http.ban( + user.id, self.id, delete_message_seconds, reason=reason + ) + + async def bulk_ban( + self, + *users: Snowflake, + delete_message_seconds: int | None = None, + reason: str | None = None, + ) -> tuple[list[Snowflake], list[Snowflake]]: + r"""|coro| + + Bulk ban users from the guild. + + The users must meet the :class:`abc.Snowflake` abc. + + You must have the :attr:`~Permissions.ban_members` permission to + do this. + + Example Usage: :: + + # Ban multiple users + successes, failures = await guild.bulk_ban(user1, user2, user3, ..., reason="Raid") + + # Ban a list of users + successes, failures = await guild.bulk_ban(*users) + + Parameters + ---------- + \*users: :class:`abc.Snowflake` + An argument list of users to ban from the guild, up to 200. + delete_message_seconds: Optional[:class:`int`] + The number of seconds worth of messages to delete from + the user in the guild. The minimum is 0 and the maximum + is 604800 (i.e. 7 days). The default is 0. + reason: Optional[:class:`str`] + The reason the users were banned. + + Returns + ------- + Tuple[List[:class:`abc.Snowflake`], List[:class:`abc.Snowflake`]] + Returns two lists: the first contains members that were successfully banned, while the second is members that could not be banned. + + Raises + ------ + ValueError + You tried to ban more than 200 users. + Forbidden + You do not have the proper permissions to ban. + HTTPException + No users were banned. + """ + if delete_message_seconds is not None and not ( 0 <= delete_message_seconds <= 604800 ): @@ -3088,9 +3171,20 @@ async def ban( "delete_message_seconds must be between 0 and 604800 seconds." ) - await self._state.http.ban( - user.id, self.id, delete_message_seconds, delete_message_days, reason=reason + if len(users) > 200 or len(users) < 1: + raise ValueError( + "The number of users to be banned must be between 1 and 200." + ) + + data = await self._state.http.bulk_ban( + [u.id for u in users], + self.id, + delete_message_seconds, + reason=reason, ) + banned = [u for u in users if str(u.id) in data["banned_users"]] + failed = [u for u in users if str(u.id) in data["failed_users"]] + return banned, failed async def unban(self, user: Snowflake, *, reason: str | None = None) -> None: """|coro| @@ -3166,7 +3260,6 @@ def audit_logs( limit: int | None = 100, before: SnowflakeTime | None = None, after: SnowflakeTime | None = None, - oldest_first: bool | None = None, user: Snowflake = None, action: AuditLogAction = None, ) -> AuditLogIterator: @@ -3174,6 +3267,9 @@ def audit_logs( You must have the :attr:`~Permissions.view_audit_log` permission to use this. + See `API documentation `_ + for more information about the `before` and `after` parameters. + Parameters ---------- limit: Optional[:class:`int`] @@ -3186,9 +3282,6 @@ def audit_logs( Retrieve entries after this date or entry. 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. - oldest_first: :class:`bool` - If set to ``True``, return entries in oldest->newest order. Defaults to ``True`` if - ``after`` is specified, otherwise ``False``. user: :class:`abc.Snowflake` The moderator to filter entries from. action: :class:`AuditLogAction` @@ -3233,7 +3326,6 @@ def audit_logs( before=before, after=after, limit=limit, - oldest_first=oldest_first, user_id=user_id, action_type=action, ) @@ -3328,7 +3420,7 @@ async def query_members( self, query: str | None = None, *, - limit: int = 5, + limit: int | None = 5, user_ids: list[int] | None = None, presences: bool = False, cache: bool = True, @@ -3346,10 +3438,14 @@ async def query_members( ---------- query: Optional[:class:`str`] The string that the username's start with. - limit: :class:`int` - The maximum number of members to send back. This must be - a number between 5 and 100. - presences: :class:`bool` + user_ids: Optional[List[:class:`int`]] + List of user IDs to search for. If the user ID is not in the guild then it won't be returned. + + .. versionadded:: 1.4 + limit: Optional[:class:`int`] + The maximum number of members to send back. If no query is passed, passing ``None`` returns all members. + If a ``query`` or ``user_ids`` is passed, must be between 1 and 100. Defaults to 5. + presences: Optional[:class:`bool`] Whether to request for presences to be provided. This defaults to ``False``. @@ -3357,11 +3453,7 @@ async def query_members( cache: :class:`bool` Whether to cache the members internally. This makes operations - such as :meth:`get_member` work for those that matched. - user_ids: Optional[List[:class:`int`]] - List of user IDs to search for. If the user ID is not in the guild then it won't be returned. - - .. versionadded:: 1.4 + such as :meth:`get_member` work for those that matched. Defaults to ``True``. Returns ------- @@ -3381,12 +3473,18 @@ async def query_members( if presences and not self._state._intents.presences: raise ClientException("Intents.presences must be enabled to use this.") - if query is None: - if query == "": - raise ValueError("Cannot pass empty query string.") + if not limit or limit > 100 or limit < 1: + if query or user_ids: + raise ValueError( + "limit must be between 1 and 100 when using query or user_ids" + ) + if not limit: + query = "" + limit = 0 + if query is None: if user_ids is None: - raise ValueError("Must pass either query or user_ids") + raise ValueError("Must pass query or user_ids, or set limit to None") if user_ids is not None and query is not None: raise ValueError("Cannot pass both query and user_ids") @@ -3394,7 +3492,6 @@ async def query_members( if user_ids is not None and not user_ids: raise ValueError("user_ids must contain at least 1 value") - limit = min(100, limit or 5) return await self._state.query_members( self, query=query, @@ -3419,7 +3516,7 @@ async def change_voice_state( Parameters ---------- - channel: Optional[:class:`VoiceChannel`] + channel: Optional[Union[:class:`VoiceChannel`, :class:`StageChannel`]] Channel the client wants to join. Use ``None`` to disconnect. self_mute: :class:`bool` Indicates if the client should be self-muted. @@ -3465,12 +3562,10 @@ async def edit_welcome_screen( description: str | None = ..., welcome_channels: list[WelcomeScreenChannel] | None = ..., enabled: bool | None = ..., - ) -> WelcomeScreen: - ... + ) -> WelcomeScreen: ... @overload - async def edit_welcome_screen(self) -> None: - ... + async def edit_welcome_screen(self) -> None: ... async def edit_welcome_screen(self, **options): """|coro| @@ -3842,6 +3937,88 @@ async def create_auto_moderation_rule( ) return AutoModRule(state=self._state, data=data) + async def onboarding(self): + """|coro| + + Returns the :class:`Onboarding` flow for the guild. + + .. versionadded:: 2.5 + + Returns + ------- + :class:`Onboarding` + The onboarding flow for the guild. + + Raises + ------ + HTTPException + Retrieving the onboarding flow failed somehow. + """ + data = await self._state.http.get_onboarding(self.id) + return Onboarding(data=data, guild=self) + + async def edit_onboarding( + self, + *, + prompts: list[OnboardingPrompt] | None = MISSING, + default_channels: list[Snowflake] | None = MISSING, + enabled: bool | None = MISSING, + mode: OnboardingMode | None = MISSING, + reason: str | None = MISSING, + ) -> Onboarding: + """|coro| + + A shorthand for :attr:`Onboarding.edit` without fetching the onboarding flow. + + You must have the :attr:`~Permissions.manage_guild` and :attr:`~Permissions.manage_roles` permissions in the + guild to do this. + + Parameters + ---------- + + prompts: Optional[List[:class:`OnboardingPrompt`]] + The new list of prompts for this flow. + default_channels: Optional[List[:class:`Snowflake`]] + The new default channels that users are opted into. + enabled: Optional[:class:`bool`] + Whether onboarding should be enabled. Setting this to ``True`` requires + the guild to have ``COMMUNITY`` in :attr:`~Guild.features` and at + least 7 ``default_channels``. + mode: Optional[:class:`OnboardingMode`] + The new onboarding mode. + reason: Optional[:class:`str`] + The reason that shows up on Audit log. + + Returns + ------- + :class:`Onboarding` + The updated onboarding flow. + + Raises + ------ + + HTTPException + Editing the onboarding flow failed somehow. + Forbidden + You don't have permissions to edit the onboarding flow. + """ + + fields: dict[str, Any] = {} + if prompts is not MISSING: + fields["prompts"] = [prompt.to_dict() for prompt in prompts] + + if default_channels is not MISSING: + fields["default_channel_ids"] = [channel.id for channel in default_channels] + + if enabled is not MISSING: + fields["enabled"] = enabled + + if mode is not MISSING: + fields["mode"] = mode.value + + new = await self._state.http.edit_onboarding(self.id, fields, reason=reason) + return Onboarding(data=new, guild=self) + async def delete_auto_moderation_rule( self, id: int, @@ -3867,3 +4044,80 @@ async def delete_auto_moderation_rule( """ await self._state.http.delete_auto_moderation_rule(self.id, id, reason=reason) + + async def create_test_entitlement(self, sku: Snowflake) -> Entitlement: + """|coro| + + Creates a test entitlement for the guild. + + Parameters + ---------- + sku: :class:`Snowflake` + The SKU to create a test entitlement for. + + Returns + ------- + :class:`Entitlement` + The created entitlement. + """ + payload = { + "sku_id": sku.id, + "owner_id": self.id, + "owner_type": EntitlementOwnerType.guild.value, + } + data = await self._state.http.create_test_entitlement(self.id, payload) + return Entitlement(data=data, state=self._state) + + def entitlements( + self, + skus: list[Snowflake] | None = None, + before: SnowflakeTime | None = None, + after: SnowflakeTime | None = None, + limit: int | None = 100, + exclude_ended: bool = False, + ) -> EntitlementIterator: + """Returns an :class:`.AsyncIterator` that enables fetching the guild's entitlements. + + This is identical to :meth:`Client.entitlements` with the ``guild`` parameter. + + .. versionadded:: 2.6 + + Parameters + ---------- + skus: list[:class:`.abc.Snowflake`] | None + Limit the fetched entitlements to entitlements that are for these SKUs. + before: :class:`.abc.Snowflake` | :class:`datetime.datetime` | None + Retrieves guilds before this date or object. + If a datetime is provided, it is recommended to use a UTC-aware datetime. + If the datetime is naive, it is assumed to be local time. + after: :class:`.abc.Snowflake` | :class:`datetime.datetime` | None + Retrieve guilds after this date or object. + If a datetime is provided, it is recommended to use a UTC-aware datetime. + If the datetime is naive, it is assumed to be local time. + limit: Optional[:class:`int`] + The number of entitlements to retrieve. + If ``None``, retrieves every entitlement, which may be slow. + Defaults to ``100``. + exclude_ended: :class:`bool` + Whether to limit the fetched entitlements to those that have not ended. + Defaults to ``False``. + + Yields + ------ + :class:`.Entitlement` + The application's entitlements. + + Raises + ------ + :exc:`HTTPException` + Retrieving the entitlements failed. + """ + return EntitlementIterator( + self._state, + sku_ids=[sku.id for sku in skus] if skus else None, + before=before, + after=after, + limit=limit, + guild_id=self.id, + exclude_ended=exclude_ended, + ) diff --git a/discord/http.py b/discord/http.py index 87b07e9942..61bd0cc38a 100644 --- a/discord/http.py +++ b/discord/http.py @@ -70,6 +70,9 @@ invite, member, message, + monetization, + onboarding, + poll, role, scheduled_events, sticker, @@ -376,8 +379,8 @@ async def request( continue - # we've received a 500, 502, or 504, unconditional retry - if response.status in {500, 502, 504}: + # we've received a 500, 502, 503, or 504, unconditional retry + if response.status in {500, 502, 503, 504}: await asyncio.sleep(1 + tries * 2) continue @@ -490,12 +493,14 @@ def send_message( tts: bool = False, embed: embed.Embed | None = None, embeds: list[embed.Embed] | None = None, - nonce: str | None = None, + nonce: int | str | None = None, + enforce_nonce: bool | None = None, allowed_mentions: message.AllowedMentions | None = None, message_reference: message.MessageReference | None = None, stickers: list[sticker.StickerItem] | None = None, components: list[components.Component] | None = None, flags: int | None = None, + poll: poll.Poll | None = None, ) -> Response[message.Message]: r = Route("POST", "/channels/{channel_id}/messages", channel_id=channel_id) payload = {} @@ -515,6 +520,9 @@ def send_message( if nonce: payload["nonce"] = nonce + if enforce_nonce: + payload["enforce_nonce"] = enforce_nonce + if allowed_mentions: payload["allowed_mentions"] = allowed_mentions @@ -530,6 +538,9 @@ def send_message( if flags: payload["flags"] = flags + if poll: + payload["poll"] = poll + return self.request(r, json=payload) def send_typing(self, channel_id: Snowflake) -> Response[None]: @@ -546,12 +557,14 @@ def send_multipart_helper( tts: bool = False, embed: embed.Embed | None = None, embeds: Iterable[embed.Embed | None] | None = None, - nonce: str | None = None, + nonce: int | str | None = None, + enforce_nonce: bool | None = None, allowed_mentions: message.AllowedMentions | None = None, message_reference: message.MessageReference | None = None, stickers: list[sticker.StickerItem] | None = None, components: list[components.Component] | None = None, flags: int | None = None, + poll: poll.Poll | None = None, ) -> Response[message.Message]: form = [] @@ -564,6 +577,8 @@ def send_multipart_helper( payload["embeds"] = embeds if nonce: payload["nonce"] = nonce + if enforce_nonce: + payload["enforce_nonce"] = enforce_nonce if allowed_mentions: payload["allowed_mentions"] = allowed_mentions if message_reference: @@ -574,6 +589,8 @@ def send_multipart_helper( payload["sticker_ids"] = stickers if flags: payload["flags"] = flags + if poll: + payload["poll"] = poll attachments = [] form.append({"name": "payload_json"}) @@ -606,12 +623,14 @@ def send_files( tts: bool = False, embed: embed.Embed | None = None, embeds: list[embed.Embed] | None = None, - nonce: str | None = None, + nonce: int | str | None = None, + enforce_nonce: bool | None = None, allowed_mentions: message.AllowedMentions | None = None, message_reference: message.MessageReference | None = None, stickers: list[sticker.StickerItem] | None = None, components: list[components.Component] | None = None, flags: int | None = None, + poll: poll.Poll | None = None, ) -> Response[message.Message]: r = Route("POST", "/channels/{channel_id}/messages", channel_id=channel_id) return self.send_multipart_helper( @@ -622,11 +641,13 @@ def send_files( embed=embed, embeds=embeds, nonce=nonce, + enforce_nonce=enforce_nonce, allowed_mentions=allowed_mentions, message_reference=message_reference, stickers=stickers, components=components, flags=flags, + poll=poll, ) def edit_multipart_helper( @@ -924,7 +945,6 @@ def ban( user_id: Snowflake, guild_id: Snowflake, delete_message_seconds: int = None, - delete_message_days: int = None, reason: str | None = None, ) -> Response[None]: r = Route( @@ -937,17 +957,29 @@ def ban( if delete_message_seconds: params["delete_message_seconds"] = delete_message_seconds - elif delete_message_days: - warn_deprecated( - "delete_message_days", - "delete_message_seconds", - "2.2", - reference="https://github.com/discord/discord-api-docs/pull/5219", - ) - params["delete_message_days"] = delete_message_days return self.request(r, params=params, reason=reason) + def bulk_ban( + self, + user_ids: list[Snowflake], + guild_id: Snowflake, + delete_message_seconds: int = None, + reason: str | None = None, + ) -> Response[guild.GuildBulkBan]: + r = Route( + "POST", + "/guilds/{guild_id}/bulk-ban", + guild_id=guild_id, + ) + payload = { + "user_ids": user_ids, + } + if delete_message_seconds: + payload["delete_message_seconds"] = delete_message_seconds + + return self.request(r, json=payload, reason=reason) + def unban( self, user_id: Snowflake, guild_id: Snowflake, *, reason: str | None = None ) -> Response[None]: @@ -1156,11 +1188,13 @@ def start_thread_with_message( *, name: str, auto_archive_duration: threads.ThreadArchiveDuration, + rate_limit_per_user: int, reason: str | None = None, ) -> Response[threads.Thread]: payload = { "name": name, "auto_archive_duration": auto_archive_duration, + "rate_limit_per_user": rate_limit_per_user, } route = Route( @@ -1178,13 +1212,15 @@ def start_thread_without_message( name: str, auto_archive_duration: threads.ThreadArchiveDuration, type: threads.ThreadType, - invitable: bool = True, + rate_limit_per_user: int, + invitable: bool, reason: str | None = None, ) -> Response[threads.Thread]: payload = { "name": name, "auto_archive_duration": auto_archive_duration, "type": type, + "rate_limit_per_user": rate_limit_per_user, "invitable": invitable, } @@ -1205,7 +1241,7 @@ def start_forum_thread( files: Sequence[File] | None = None, embed: embed.Embed | None = None, embeds: list[embed.Embed] | None = None, - nonce: str | None = None, + nonce: int | str | None = None, allowed_mentions: message.AllowedMentions | None = None, stickers: list[sticker.StickerItem] | None = None, components: list[components.Component] | None = None, @@ -1620,16 +1656,6 @@ def get_vanity_code(self, guild_id: Snowflake) -> Response[invite.VanityInvite]: Route("GET", "/guilds/{guild_id}/vanity-url", guild_id=guild_id) ) - def change_vanity_code( - self, guild_id: Snowflake, code: str, *, reason: str | None = None - ) -> Response[None]: - payload: dict[str, Any] = {"code": code} - return self.request( - Route("PATCH", "/guilds/{guild_id}/vanity-url", guild_id=guild_id), - json=payload, - reason=reason, - ) - def get_all_guild_channels( self, guild_id: Snowflake ) -> Response[list[guild.GuildChannel]]: @@ -1649,6 +1675,20 @@ def get_members( r = Route("GET", "/guilds/{guild_id}/members", guild_id=guild_id) return self.request(r, params=params) + def search_members( + self, + guild_id: Snowflake, + query: str, + limit: int, + ) -> Response[list[member.MemberWithUser]]: + params: dict[str, Any] = { + "query": query, + "limit": limit, + } + + r = Route("GET", "/guilds/{guild_id}/members/search", guild_id=guild_id) + return self.request(r, params=params) + def get_member( self, guild_id: Snowflake, member_id: Snowflake ) -> Response[member.MemberWithUser]: @@ -1934,9 +1974,9 @@ def get_audit_logs( action_type: AuditLogAction | None = None, ) -> Response[audit_log.AuditLog]: params: dict[str, Any] = {"limit": limit} - if before: + if before is not None: params["before"] = before - if after: + if after is not None: params["after"] = after if user_id: params["user_id"] = user_id @@ -2139,8 +2179,8 @@ def edit_channel_permissions( self, channel_id: Snowflake, target: Snowflake, - allow: str, - deny: str, + allow: int | str, + deny: int | str, type: channel.OverwriteType, *, reason: str | None = None, @@ -2207,6 +2247,13 @@ def move_member( guild_id=guild_id, user_id=user_id, channel_id=channel_id, reason=reason ) + def set_voice_channel_status( + self, channel_id: Snowflake, status: str | None, *, reason: str | None = None + ) -> Response[None]: + payload = {"status": status} + r = Route("PUT", "/channels/{channel_id}/voice-status", channel_id=channel_id) + return self.request(r, json=payload, reason=reason) + # Stage instance management def get_stage_instance( @@ -2912,6 +2959,152 @@ def update_application_role_connection_metadata_records( ) return self.request(r, json=payload) + # Monetization + + def list_skus( + self, + application_id: Snowflake, + ) -> Response[list[monetization.SKU]]: + r = Route( + "GET", + "/applications/{application_id}/skus", + application_id=application_id, + ) + return self.request(r) + + def list_entitlements( + self, + application_id: Snowflake, + *, + user_id: Snowflake | None = None, + sku_ids: list[Snowflake] | None = None, + before: Snowflake | None = None, + after: Snowflake | None = None, + limit: int | None = None, + guild_id: Snowflake | None = None, + exclude_ended: bool | None = None, + ) -> Response[list[monetization.Entitlement]]: + params: dict[str, Any] = {} + if user_id is not None: + params["user_id"] = user_id + if sku_ids is not None: + params["sku_ids"] = ",".join(sku_ids) + if before is not None: + params["before"] = before + if after is not None: + params["after"] = after + if limit is not None: + params["limit"] = limit + if guild_id is not None: + params["guild_id"] = guild_id + if exclude_ended is not None: + params["exclude_ended"] = int(exclude_ended) + + r = Route( + "GET", + "/applications/{application_id}/entitlements", + application_id=application_id, + ) + return self.request(r, params=params) + + def consume_entitlement( + self, + application_id: Snowflake, + entitlement_id: Snowflake, + ) -> Response[None]: + r = Route( + "POST", + "/applications/{application_id}/entitlements/{entitlement_id}/consume", + application_id=application_id, + entitlement_id=entitlement_id, + ) + return self.request(r) + + def create_test_entitlement( + self, + application_id: Snowflake, + payload: monetization.CreateTestEntitlementPayload, + ) -> Response[monetization.Entitlement]: + r = Route( + "POST", + "/applications/{application_id}/entitlements", + application_id=application_id, + ) + return self.request(r, json=payload) + + def delete_test_entitlement( + self, + application_id: Snowflake, + entitlement_id: Snowflake, + ) -> Response[None]: + r = Route( + "DELETE", + "/applications/{application_id}/entitlements/{entitlement_id}", + application_id=application_id, + entitlement_id=entitlement_id, + ) + return self.request(r) + + # Onboarding + + def get_onboarding(self, guild_id: Snowflake) -> Response[onboarding.Onboarding]: + return self.request( + Route("GET", "/guilds/{guild_id}/onboarding", guild_id=guild_id) + ) + + def edit_onboarding( + self, guild_id: Snowflake, payload: Any, *, reason: str | None = None + ) -> Response[onboarding.Onboarding]: + keys = ( + "prompts", + "default_channel_ids", + "enabled", + "mode", + ) + payload = {key: val for key, val in payload.items() if key in keys} + return self.request( + Route("PUT", "/guilds/{guild_id}/onboarding", guild_id=guild_id), + json=payload, + reason=reason, + ) + + # Polls + + def expire_poll( + self, channel_id: Snowflake, message_id: Snowflake + ) -> Response[message.Message]: + return self.request( + Route( + "POST", + "/channels/{channel_id}/polls/{message_id}/expire", + channel_id=channel_id, + message_id=message_id, + ) + ) + + def get_answer_voters( + self, + channel_id: Snowflake, + message_id: Snowflake, + answer_id: int, + limit: int, + after: Snowflake | None = None, + ) -> Response[list[user.User]]: + r = Route( + "GET", + "/channels/{channel_id}/polls/{message_id}/answers/{answer_id}", + channel_id=channel_id, + message_id=message_id, + answer_id=answer_id, + ) + + params: dict[str, Any] = { + "limit": limit, + } + if after: + params["after"] = after + return self.request(r, params=params) + # Misc def application_info(self) -> Response[appinfo.AppInfo]: diff --git a/discord/interactions.py b/discord/interactions.py index 19b5363082..e7d7fade3e 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -30,12 +30,19 @@ from . import utils from .channel import ChannelType, PartialMessageable, _threaded_channel_factory -from .enums import InteractionResponseType, InteractionType, try_enum +from .enums import ( + InteractionContextType, + InteractionResponseType, + InteractionType, + try_enum, +) from .errors import ClientException, InteractionResponded, InvalidArgument from .file import File from .flags import MessageFlags +from .guild import Guild from .member import Member from .message import Attachment, Message +from .monetization import Entitlement from .object import Object from .permissions import Permissions from .user import User @@ -51,6 +58,8 @@ "InteractionMessage", "InteractionResponse", "MessageInteraction", + "InteractionMetadata", + "AuthorizingIntegrationOwners", ) if TYPE_CHECKING: @@ -68,12 +77,13 @@ from .client import Client from .commands import OptionChoice from .embeds import Embed - from .guild import Guild from .mentions import AllowedMentions + from .poll import Poll from .state import ConnectionState from .threads import Thread from .types.interactions import Interaction as InteractionPayload from .types.interactions import InteractionData + from .types.interactions import InteractionMetadata as InteractionMetadataPayload from .types.interactions import MessageInteraction as MessageInteractionPayload from .ui.modal import Modal from .ui.view import View @@ -97,7 +107,7 @@ class Interaction: """Represents a Discord interaction. An interaction happens when a user does an action that needs to - be notified. Current examples are slash commands and components. + be notified. Current examples are application commands, components, and modals. .. versionadded:: 2.0 @@ -130,6 +140,18 @@ class Interaction: The guilds preferred locale, if invoked in a guild. custom_id: Optional[:class:`str`] The custom ID for the interaction. + entitlements: list[:class:`Entitlement`] + Entitlements that apply to the invoking user, showing access to premium SKUs. + + .. versionadded:: 2.5 + authorizing_integration_owners: :class:`AuthorizingIntegrationOwners` + Contains the entities (users or guilds) that authorized this interaction. + + .. versionadded:: 2.6 + context: Optional[:class:`InteractionContextType`] + The context in which this command was executed. + + .. versionadded:: 2.6 """ __slots__: tuple[str, ...] = ( @@ -147,6 +169,9 @@ class Interaction: "token", "version", "custom_id", + "entitlements", + "context", + "authorizing_integration_owners", "_channel_data", "_message_data", "_guild_data", @@ -183,6 +208,21 @@ def _from_data(self, data: InteractionPayload): self.data.get("custom_id") if self.data is not None else None ) self._app_permissions: int = int(data.get("app_permissions", 0)) + self.entitlements: list[Entitlement] = [ + Entitlement(data=e, state=self._state) for e in data.get("entitlements", []) + ] + self.authorizing_integration_owners: AuthorizingIntegrationOwners = ( + AuthorizingIntegrationOwners( + data=data["authorizing_integration_owners"], state=self._state + ) + if "authorizing_integration_owners" in data + else AuthorizingIntegrationOwners(data={}, state=self._state) + ) + self.context: InteractionContextType | None = ( + try_enum(InteractionContextType, data["context"]) + if "context" in data + else None + ) self.message: Message | None = None self.channel = None @@ -193,7 +233,7 @@ def _from_data(self, data: InteractionPayload): self._guild: Guild | None = None self._guild_data = data.get("guild") if self.guild is None and self._guild_data: - self._guild = Guild(data=self._guild_data, state=self) + self._guild = Guild(data=self._guild_data, state=self._state) # TODO: there's a potential data loss here if self.guild_id: @@ -798,6 +838,7 @@ async def send_message( allowed_mentions: AllowedMentions = None, file: File = None, files: list[File] = None, + poll: Poll = None, delete_after: float = None, ) -> Interaction: """|coro| @@ -832,6 +873,10 @@ async def send_message( The file to upload. files: List[:class:`File`] A list of files to upload. Must be a maximum of 10. + poll: :class:`Poll` + The poll to send. + + .. versionadded:: 2.6 Returns ------- @@ -876,6 +921,9 @@ async def send_message( if view is not None: payload["components"] = view.to_components() + if poll is not None: + payload["poll"] = poll.to_dict() + state = self._parent._state if allowed_mentions is None: @@ -1185,7 +1233,44 @@ async def send_modal(self, modal: Modal) -> Interaction: self._parent._state.store_modal(modal, self._parent.user.id) return self._parent - async def _locked_response(self, coro: Coroutine[Any]): + @utils.deprecated("a button with type ButtonType.premium", "2.6") + async def premium_required(self) -> Interaction: + """|coro| + + Responds to this interaction by sending a premium required message. + + .. deprecated:: 2.6 + + A button with type :attr:`ButtonType.premium` should be used instead. + + Raises + ------ + HTTPException + Sending the message failed. + InteractionResponded + This interaction has already been responded to before. + """ + if self._responded: + raise InteractionResponded(self._parent) + + parent = self._parent + + adapter = async_context.get() + http = parent._state.http + await self._locked_response( + adapter.create_interaction_response( + parent.id, + parent.token, + session=parent._session, + proxy=http.proxy, + proxy_auth=http.proxy_auth, + type=InteractionResponseType.premium_required.value, + ) + ) + self._responded = True + return self._parent + + async def _locked_response(self, coro: Coroutine[Any, Any, Any]) -> None: """|coro| Wraps a response and makes sure that it's locked while executing. @@ -1357,6 +1442,10 @@ class MessageInteraction: .. versionadded:: 2.0 + .. deprecated:: 2.6 + + See :class:`InteractionMetadata`. + .. note:: Responses to message components do not include this property. @@ -1383,3 +1472,144 @@ def __init__(self, *, data: MessageInteractionPayload, state: ConnectionState): self.type: InteractionType = data["type"] self.name: str = data["name"] self.user: User = self._state.store_user(data["user"]) + + +class InteractionMetadata: + """Represents metadata about an interaction. + + This is sent on the message object when the message is related to an interaction + + .. versionadded:: 2.6 + + Attributes + ---------- + id: :class:`int` + The interaction's ID. + type: :class:`InteractionType` + The interaction type. + user: :class:`User` + The user that sent the interaction. + authorizing_integration_owners: :class:`AuthorizingIntegrationOwners` + The authorizing user or server for the installation(s) relevant to the interaction. + original_response_message_id: Optional[:class:`int`] + The ID of the original response message. Only present on interaction follow-up messages. + interacted_message_id: Optional[:class:`int`] + The ID of the message that triggered the interaction. Only present on interactions of type + :attr:`InteractionType.component`. + triggering_interaction_metadata: Optional[:class:`InteractionMetadata`] + The metadata of the interaction that opened the model. Only present on interactions of type + :attr:`InteractionType.modal_submit`. + """ + + __slots__: tuple[str, ...] = ( + "id", + "type", + "user", + "authorizing_integration_owners", + "original_response_message_id", + "interacted_message_id", + "triggering_interaction_metadata", + "_state", + "_cs_original_response_message", + "_cs_interacted_message", + ) + + def __init__(self, *, data: InteractionMetadataPayload, state: ConnectionState): + self._state = state + self.id: int = int(data["id"]) + self.type: InteractionType = try_enum(InteractionType, data["type"]) + self.user: User = User(state=state, data=data["user"]) + self.authorizing_integration_owners: AuthorizingIntegrationOwners = ( + AuthorizingIntegrationOwners(data["authorizing_integration_owners"], state) + ) + self.original_response_message_id: int | None = utils._get_as_snowflake( + data, "original_response_message_id" + ) + self.interacted_message_id: int | None = utils._get_as_snowflake( + data, "interacted_message_id" + ) + self.triggering_interaction_metadata: InteractionMetadata | None = None + if tim := data.get("triggering_interaction_metadata"): + self.triggering_interaction_metadata = InteractionMetadata( + data=tim, state=state + ) + + def __repr__(self): + return ( + f"" + ) + + @utils.cached_slot_property("_cs_original_response_message") + def original_response_message(self) -> Message | None: + """Optional[:class:`Message`]: The original response message. + Returns ``None`` if the message is not in cache, or if :attr:`original_response_message_id` is ``None``. + """ + if not self.original_response_message_id: + return None + return self._state._get_message(self.original_response_message_id) + + @utils.cached_slot_property("_cs_interacted_message") + def interacted_message(self) -> Message | None: + """Optional[:class:`Message`]: The message that triggered the interaction. + Returns ``None`` if the message is not in cache, or if :attr:`interacted_message_id` is ``None``. + """ + if not self.interacted_message_id: + return None + return self._state._get_message(self.interacted_message_id) + + +class AuthorizingIntegrationOwners: + """Contains details on the authorizing user or server for the installation(s) relevant to the interaction. + + .. versionadded:: 2.6 + + Attributes + ---------- + user_id: :class:`int` | None + The ID of the user that authorized the integration. + guild_id: :class:`int` | None + The ID of the guild that authorized the integration. + This will be ``0`` if the integration was triggered + from the user in the bot's DMs. + """ + + __slots__ = ("user_id", "guild_id", "_state", "_cs_user", "_cs_guild") + + def __init__(self, data: dict[str, Any], state: ConnectionState): + self._state = state + # keys are Application Integration Types as strings + self.user_id = int(uid) if (uid := data.get("1")) is not None else None + self.guild_id = ( + int(guild_id) if (guild_id := data.get("0", None)) is not None else None + ) + + def __repr__(self): + return f"" + + def __eq__(self, other): + return ( + isinstance(other, AuthorizingIntegrationOwners) + and self.user_id == other.user_id + and self.guild_id == other.guild_id + ) + + def __ne__(self, other): + return not self.__eq__(other) + + @utils.cached_slot_property("_cs_user") + def user(self) -> User | None: + """Optional[:class:`User`]: The user that authorized the integration. + Returns ``None`` if the user is not in cache, or if :attr:`user_id` is ``None``. + """ + if not self.user_id: + return None + return self._state.get_user(self.user_id) + + @utils.cached_slot_property("_cs_guild") + def guild(self) -> Guild | None: + """Optional[:class:`Guild`]: The guild that authorized the integration. + Returns ``None`` if the guild is not in cache, or if :attr:`guild_id` is ``0`` or ``None``. + """ + if not self.guild_id: + return None + return self._state._get_guild(self.guild_id) diff --git a/discord/invite.py b/discord/invite.py index 2560bf9996..12d1d0f28a 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -49,6 +49,7 @@ from .types.invite import GatewayInvite as GatewayInvitePayload from .types.invite import Invite as InvitePayload from .types.invite import InviteGuild as InviteGuildPayload + from .types.invite import VanityInvite as VanityInvitePayload from .types.scheduled_events import ScheduledEvent as ScheduledEventPayload from .user import User @@ -353,7 +354,7 @@ def __init__( self, *, state: ConnectionState, - data: InvitePayload, + data: InvitePayload | VanityInvitePayload, guild: PartialInviteGuild | Guild | None = None, channel: PartialInviteChannel | GuildChannel | None = None, ): diff --git a/discord/iterators.py b/discord/iterators.py index 7507cfd5d8..7404e790a4 100644 --- a/discord/iterators.py +++ b/discord/iterators.py @@ -40,6 +40,7 @@ from .audit_logs import AuditLogEntry from .errors import NoMoreItems +from .monetization import Entitlement from .object import Object from .utils import maybe_coroutine, snowflake_time, time_snowflake @@ -50,6 +51,7 @@ "GuildIterator", "MemberIterator", "ScheduledEventSubscribersIterator", + "EntitlementIterator", ) if TYPE_CHECKING: @@ -237,6 +239,61 @@ async def fill_users(self): await self.users.put(User(state=self.state, data=element)) +class VoteIterator(_AsyncIterator[Union["User", "Member"]]): + def __init__(self, message, answer, limit=100, after=None): + self.message = message + self.limit = limit + self.after = after + state = message._state + self.getter = state.http.get_answer_voters + self.state = state + self.answer = answer + self.guild = message.guild + self.channel_id = message.channel.id + self.users = asyncio.Queue() + + async def next(self) -> User | Member: + if self.users.empty(): + await self.fill_users() + + try: + return self.users.get_nowait() + except asyncio.QueueEmpty: + raise NoMoreItems() + + async def fill_users(self): + # import here to prevent circular imports + from .user import User + + if self.limit > 0: + retrieve = min(self.limit, 100) + + after = self.after.id if self.after else None + req = await self.getter( + self.channel_id, + self.message.id, + self.answer.id, + retrieve, + after=after, + ) + data: list[PartialUserPayload] = req.get("users", []) + + if data: + self.limit -= retrieve + self.after = Object(id=int(data[-1]["id"])) + + for element in reversed(data): + if self.guild is None or isinstance(self.guild, Object): + await self.users.put(User(state=self.state, data=element)) + else: + member_id = int(element["id"]) + member = self.guild.get_member(member_id) + if member is not None: + await self.users.put(member) + else: + await self.users.put(User(state=self.state, data=element)) + + class HistoryIterator(_AsyncIterator["Message"]): """Iterator for receiving a channel's message history. @@ -335,7 +392,7 @@ async def next(self) -> Message: except asyncio.QueueEmpty: raise NoMoreItems() - def _get_retrieve(self): + def _get_retrieve(self) -> bool: l = self.limit if l is None or l > 100: r = 100 @@ -366,11 +423,13 @@ async def fill_messages(self): self.state.create_message(channel=channel, data=element) ) - async def _retrieve_messages(self, retrieve) -> list[Message]: + async def _retrieve_messages(self, retrieve: int) -> list[MessagePayload]: """Retrieve messages and update next parameters.""" raise NotImplementedError - async def _retrieve_messages_before_strategy(self, retrieve): + async def _retrieve_messages_before_strategy( + self, retrieve: int + ) -> list[MessagePayload]: """Retrieve messages using before parameter.""" before = self.before.id if self.before else None data: list[MessagePayload] = await self.logs_from( @@ -382,7 +441,9 @@ async def _retrieve_messages_before_strategy(self, retrieve): self.before = Object(id=int(data[-1]["id"])) return data - async def _retrieve_messages_after_strategy(self, retrieve): + async def _retrieve_messages_after_strategy( + self, retrieve: int + ) -> list[MessagePayload]: """Retrieve messages using after parameter.""" after = self.after.id if self.after else None data: list[MessagePayload] = await self.logs_from( @@ -394,7 +455,9 @@ async def _retrieve_messages_after_strategy(self, retrieve): self.after = Object(id=int(data[0]["id"])) return data - async def _retrieve_messages_around_strategy(self, retrieve): + async def _retrieve_messages_around_strategy( + self, retrieve: int + ) -> list[MessagePayload]: """Retrieve messages using around parameter.""" if self.around: around = self.around.id if self.around else None @@ -413,7 +476,6 @@ def __init__( limit=None, before=None, after=None, - oldest_first=None, user_id=None, action_type=None, ): @@ -422,7 +484,6 @@ def __init__( if isinstance(after, datetime.datetime): after = Object(id=time_snowflake(after, high=True)) - self.reverse = after is not None if oldest_first is None else oldest_first self.guild = guild self.loop = guild._state.loop self.request = guild._state.http.get_audit_logs @@ -430,54 +491,34 @@ def __init__( self.before = before self.user_id = user_id self.action_type = action_type - self.after = after or OLDEST_OBJECT + self.after = after self._users = {} self._state = guild._state - - self._filter = None # entry dict -> bool - self.entries = asyncio.Queue() - if self.reverse: - self._strategy = self._after_strategy - if self.before: - self._filter = lambda m: int(m["id"]) < self.before.id - else: - self._strategy = self._before_strategy - if self.after and self.after != OLDEST_OBJECT: - self._filter = lambda m: int(m["id"]) > self.after.id - - async def _before_strategy(self, retrieve): + async def _retrieve_entries(self, retrieve): + if not self._get_retrieve(): + return before = self.before.id if self.before else None - data: AuditLogPayload = await self.request( - self.guild.id, - limit=retrieve, - user_id=self.user_id, - action_type=self.action_type, - before=before, - ) - - entries = data.get("audit_log_entries", []) - if len(data) and entries: - if self.limit is not None: - self.limit -= retrieve - self.before = Object(id=int(entries[-1]["id"])) - return data.get("users", []), entries - - async def _after_strategy(self, retrieve): after = self.after.id if self.after else None data: AuditLogPayload = await self.request( self.guild.id, limit=retrieve, user_id=self.user_id, action_type=self.action_type, + before=before, after=after, ) + entries = data.get("audit_log_entries", []) + # if ONLY after is passed and NOT before, Discord reverses the sort to oldest first. if len(data) and entries: if self.limit is not None: self.limit -= retrieve - self.after = Object(id=int(entries[0]["id"])) + if self.before or not self.after: + self.before = Object(id=int(entries[-1]["id"])) + if self.after and not self.before: + self.after = Object(id=int(entries[-1]["id"])) return data.get("users", []), entries async def next(self) -> AuditLogEntry: @@ -502,24 +543,15 @@ async def _fill(self): from .user import User if self._get_retrieve(): - users, data = await self._strategy(self.retrieve) + users, data = await self._retrieve_entries(self.retrieve) if len(data) < 100: self.limit = 0 # terminate the infinite loop - if self.reverse: - data = reversed(data) - if self._filter: - data = filter(self._filter, data) - for user in users: u = User(data=user, state=self._state) self._users[u.id] = u for element in data: - # TODO: remove this if statement later - if element["action_type"] is None: - continue - await self.entries.put( AuditLogEntry(data=element, users=self._users, guild=self.guild) ) @@ -846,10 +878,10 @@ class ScheduledEventSubscribersIterator(_AsyncIterator[Union["User", "Member"]]) def __init__( self, event: ScheduledEvent, - limit: int, + limit: int | None, with_member: bool = False, - before: datetime.datetime | int = None, - after: datetime.datetime | int = None, + before: datetime.datetime | int | None = None, + after: datetime.datetime | int | None = None, ): if isinstance(before, datetime.datetime): before = Object(id=time_snowflake(before, high=False)) @@ -903,6 +935,7 @@ def user_from_payload(self, data): async def fill_subs(self): if not self._get_retrieve(): return + before = self.before.id if self.before else None after = self.after.id if self.after else None data = await self.get_subscribers( @@ -913,11 +946,98 @@ async def fill_subs(self): before=before, after=after, ) - if data: - self.limit -= self.retrieve + + data_length = len(data) + if data_length < self.retrieve: + self.limit = 0 + elif data_length > 0: + if self.limit: + self.limit -= self.retrieve + self.after = Object(id=int(data[-1]["user_id"])) for element in reversed(data): if "member" in element: await self.subscribers.put(self.member_from_payload(element)) else: await self.subscribers.put(self.user_from_payload(element)) + + +class EntitlementIterator(_AsyncIterator["Entitlement"]): + def __init__( + self, + state, + user_id: int | None = None, + sku_ids: list[int] | None = None, + before: datetime.datetime | Object | None = None, + after: datetime.datetime | Object | None = None, + limit: int | None = None, + guild_id: int | None = None, + exclude_ended: bool | None = None, + ): + self.user_id = user_id + self.sku_ids = sku_ids + + if isinstance(before, datetime.datetime): + before = Object(id=time_snowflake(before, high=False)) + if isinstance(after, datetime.datetime): + after = Object(id=time_snowflake(after, high=True)) + + self.before = before + self.after = after + self.limit = limit + self.guild_id = guild_id + self.exclude_ended = exclude_ended + + self.state = state + self.get_entitlements = state.http.list_entitlements + self.entitlements = asyncio.Queue() + + async def next(self) -> BanEntry: + if self.entitlements.empty(): + await self.fill_entitlements() + + try: + return self.entitlements.get_nowait() + except asyncio.QueueEmpty: + raise NoMoreItems() + + def _get_retrieve(self): + l = self.limit + if l is None or l > 100: + r = 100 + else: + r = l + self.retrieve = r + return r > 0 + + async def fill_entitlements(self): + if not self._get_retrieve(): + return + + before = self.before.id if self.before else None + after = self.after.id if self.after else None + data = await self.get_entitlements( + self.state.application_id, + before=before, + after=after, + limit=self.retrieve, + user_id=self.user_id, + guild_id=self.guild_id, + sku_ids=self.sku_ids, + exclude_ended=self.exclude_ended, + ) + + if not data: + # no data, terminate + return + + if self.limit: + self.limit -= self.retrieve + + if len(data) < 100: + self.limit = 0 # terminate loop + + self.after = Object(id=int(data[-1]["id"])) + + for element in reversed(data): + await self.entitlements.put(Entitlement(data=element, state=self.state)) diff --git a/discord/member.py b/discord/member.py index 3e60fc1365..18ebf1267f 100644 --- a/discord/member.py +++ b/discord/member.py @@ -30,7 +30,7 @@ import itertools import sys from operator import attrgetter -from typing import TYPE_CHECKING, Any, Literal, TypeVar, Union +from typing import TYPE_CHECKING, Any, TypeVar, Union import discord.abc @@ -39,6 +39,7 @@ from .asset import Asset from .colour import Colour from .enums import Status, try_enum +from .flags import MemberFlags from .object import Object from .permissions import Permissions from .user import BaseUser, User, _UserTag @@ -62,6 +63,7 @@ from .types.member import MemberWithUser as MemberWithUserPayload from .types.member import UserWithMember as UserWithMemberPayload from .types.user import User as UserPayload + from .types.voice import GuildVoiceState as GuildVoiceStatePayload from .types.voice import VoiceState as VoiceStatePayload VocalGuildChannel = Union[VoiceChannel, StageChannel] @@ -125,12 +127,19 @@ class VoiceState: ) def __init__( - self, *, data: VoiceStatePayload, channel: VocalGuildChannel | None = None + self, + *, + data: VoiceStatePayload | GuildVoiceStatePayload, + channel: VocalGuildChannel | None = None, ): self.session_id: str = data.get("session_id") self._update(data, channel) - def _update(self, data: VoiceStatePayload, channel: VocalGuildChannel | None): + def _update( + self, + data: VoiceStatePayload | GuildVoiceStatePayload, + channel: VocalGuildChannel | None, + ): self.self_mute: bool = data.get("self_mute", False) self.self_deaf: bool = data.get("self_deaf", False) self.self_stream: bool = data.get("self_stream", False) @@ -261,6 +270,10 @@ class Member(discord.abc.Messageable, _UserTag): An aware datetime object that specifies the date and time in UTC when the member will be removed from timeout. .. versionadded:: 2.0 + flags: :class:`MemberFlags` + Extra attributes of the member. + + .. versionadded:: 2.6 """ __slots__ = ( @@ -276,6 +289,7 @@ class Member(discord.abc.Messageable, _UserTag): "_state", "_avatar", "communication_disabled_until", + "flags", ) if TYPE_CHECKING: @@ -317,6 +331,7 @@ def __init__( self.communication_disabled_until: datetime.datetime | None = utils.parse_time( data.get("communication_disabled_until") ) + self.flags: MemberFlags = MemberFlags._from_value(data.get("flags", 0)) def __str__(self) -> str: return str(self._user) @@ -392,6 +407,7 @@ def _copy(cls: type[M], member: M) -> M: self._state = member._state self._avatar = member._avatar self.communication_disabled_until = member.communication_disabled_until + self.flags = member.flags # Reference will not be copied unless necessary by PRESENCE_UPDATE # See below @@ -421,6 +437,7 @@ def _update(self, data: MemberPayload) -> None: self.communication_disabled_until = utils.parse_time( data.get("communication_disabled_until") ) + self.flags = MemberFlags._from_value(data.get("flags", 0)) def _presence_update( self, data: PartialPresenceUpdate, user: UserPayload @@ -684,7 +701,6 @@ async def ban( self, *, delete_message_seconds: int | None = None, - delete_message_days: Literal[0, 1, 2, 3, 4, 5, 6, 7] | None = None, reason: str | None = None, ) -> None: """|coro| @@ -695,7 +711,6 @@ async def ban( self, reason=reason, delete_message_seconds=delete_message_seconds, - delete_message_days=delete_message_days, ) async def unban(self, *, reason: str | None = None) -> None: @@ -723,6 +738,7 @@ async def edit( voice_channel: VocalGuildChannel | None = MISSING, reason: str | None = None, communication_disabled_until: datetime.datetime | None = MISSING, + bypass_verification: bool | None = MISSING, ) -> Member | None: """|coro| @@ -745,6 +761,18 @@ async def edit( +------------------------------+--------------------------------------+ | communication_disabled_until | :attr:`Permissions.moderate_members` | +------------------------------+--------------------------------------+ + | bypass_verification | See note below | + +------------------------------+--------------------------------------+ + + .. note:: + + `bypass_verification` may be edited under three scenarios: + + - Client has :attr:`Permissions.manage_guild` + + - Client has :attr:`Permissions.manage_roles` + + - Client has ALL THREE of :attr:`Permissions.moderate_members`, :attr:`Permissions.kick_members`, and :attr:`Permissions.ban_members` All parameters are optional. @@ -769,7 +797,7 @@ async def edit( roles: List[:class:`Role`] The member's new list of roles. This *replaces* the roles. - voice_channel: Optional[:class:`VoiceChannel`] + voice_channel: Optional[Union[:class:`VoiceChannel`, :class:`StageChannel`]] The voice channel to move the member to. Pass ``None`` to kick them from voice. reason: Optional[:class:`str`] @@ -779,6 +807,10 @@ async def edit( from timeout. .. versionadded:: 2.0 + bypass_verification: Optional[:class:`bool`] + Indicates if the member should bypass the guild's verification requirements. + + .. versionadded:: 2.6 Returns ------- @@ -824,9 +856,9 @@ async def edit( await http.edit_my_voice_state(guild_id, voice_state_payload) else: if not suppress: - voice_state_payload[ - "request_to_speak_timestamp" - ] = datetime.datetime.utcnow().isoformat() + voice_state_payload["request_to_speak_timestamp"] = ( + datetime.datetime.utcnow().isoformat() + ) await http.edit_voice_state(guild_id, self.id, voice_state_payload) if voice_channel is not MISSING: @@ -837,12 +869,17 @@ async def edit( if communication_disabled_until is not MISSING: if communication_disabled_until is not None: - payload[ - "communication_disabled_until" - ] = communication_disabled_until.isoformat() + payload["communication_disabled_until"] = ( + communication_disabled_until.isoformat() + ) else: payload["communication_disabled_until"] = communication_disabled_until + if bypass_verification is not MISSING: + flags = MemberFlags._from_value(self.flags.value) + flags.bypasses_verification = bypass_verification + payload["flags"] = flags.value + if payload: data = await http.edit_member(guild_id, self.id, reason=reason, **payload) return Member(data=data, guild=self.guild, state=self._state) @@ -958,7 +995,7 @@ async def request_to_speak(self) -> None: await self._state.http.edit_my_voice_state(self.guild.id, payload) async def move_to( - self, channel: VocalGuildChannel, *, reason: str | None = None + self, channel: VocalGuildChannel | None, *, reason: str | None = None ) -> None: """|coro| @@ -974,7 +1011,7 @@ async def move_to( Parameters ---------- - channel: Optional[:class:`VoiceChannel`] + channel: Optional[Union[:class:`VoiceChannel`, :class:`StageChannel`]] The new voice channel to move the member to. Pass ``None`` to kick them from voice. reason: Optional[:class:`str`] diff --git a/discord/message.py b/discord/message.py index c74f40c288..37ffd86f53 100644 --- a/discord/message.py +++ b/discord/message.py @@ -39,6 +39,7 @@ Union, overload, ) +from urllib.parse import parse_qs, urlparse from . import utils from .components import _component_factory @@ -47,11 +48,13 @@ from .enums import ChannelType, MessageType, try_enum from .errors import InvalidArgument from .file import File -from .flags import MessageFlags +from .flags import AttachmentFlags, MessageFlags from .guild import Guild from .member import Member from .mixins import Hashable +from .object import Object from .partial_emoji import PartialEmoji +from .poll import Poll from .reaction import Reaction from .sticker import StickerItem from .threads import Thread @@ -66,6 +69,7 @@ ) from .channel import TextChannel from .components import Component + from .interactions import MessageInteraction from .mentions import AllowedMentions from .role import Role from .state import ConnectionState @@ -77,8 +81,11 @@ from .types.message import Message as MessagePayload from .types.message import MessageActivity as MessageActivityPayload from .types.message import MessageApplication as MessageApplicationPayload + from .types.message import MessageCall as MessageCallPayload from .types.message import MessageReference as MessageReferencePayload from .types.message import Reaction as ReactionPayload + from .types.poll import Poll as PollPayload + from .types.snowflake import SnowflakeList from .types.threads import ThreadArchiveDuration from .types.user import User as UserPayload from .ui.view import View @@ -92,6 +99,7 @@ "Message", "PartialMessage", "MessageReference", + "MessageCall", "DeletedReferencedMessage", ) @@ -151,6 +159,11 @@ class Attachment(Hashable): The attachment's width, in pixels. Only applicable to images and videos. filename: :class:`str` The attachment's filename. + title: Optional[:class:`str`] + The attachment's title. This is equal to the original :attr:`filename` (without an extension) + if special characters were filtered from it. + + .. versionadded:: 2.6 url: :class:`str` The attachment URL. If the message this attachment was attached to is deleted, then this will 404. @@ -178,6 +191,16 @@ class Attachment(Hashable): waveform: Optional[:class:`str`] The base64 encoded bytearray representing a sampled waveform (currently for voice messages). + .. versionadded:: 2.5 + + flags: :class:`AttachmentFlags` + Extra attributes of the attachment. + + .. versionadded:: 2.5 + + hm: :class:`str` + The unique signature of this attachment's instance. + .. versionadded:: 2.5 """ @@ -195,6 +218,11 @@ class Attachment(Hashable): "description", "duration_secs", "waveform", + "flags", + "_ex", + "_is", + "hm", + "title", ) def __init__(self, *, data: AttachmentPayload, state: ConnectionState): @@ -203,6 +231,7 @@ def __init__(self, *, data: AttachmentPayload, state: ConnectionState): self.height: int | None = data.get("height") self.width: int | None = data.get("width") self.filename: str = data["filename"] + self.title: str | None = data.get("title") self.url: str = data.get("url") self.proxy_url: str = data.get("proxy_url") self._http = state.http @@ -211,6 +240,32 @@ def __init__(self, *, data: AttachmentPayload, state: ConnectionState): self.description: str | None = data.get("description") self.duration_secs: float | None = data.get("duration_secs") self.waveform: str | None = data.get("waveform") + self.flags: AttachmentFlags = AttachmentFlags._from_value(data.get("flags", 0)) + self._ex: str | None = None + self._is: str | None = None + self.hm: str | None = None + + query = urlparse(self.url).query + extras = ["_ex", "_is", "hm"] + if query_params := parse_qs(query): + for attr in extras: + value = "".join(query_params.get(attr.replace("_", ""), [])) + if value: + setattr(self, attr, value) + + @property + def expires_at(self) -> datetime.datetime | None: + """This attachment URL's expiry time in UTC.""" + if not self._ex: + return None + return datetime.datetime.utcfromtimestamp(int(self._ex, 16)) + + @property + def issued_at(self) -> datetime.datetime | None: + """The attachment URL's issue time in UTC.""" + if not self._is: + return None + return datetime.datetime.utcfromtimestamp(int(self._is, 16)) def is_spoiler(self) -> bool: """Whether this attachment contains a spoiler.""" @@ -549,6 +604,34 @@ def to_dict(self) -> MessageReferencePayload: to_message_reference_dict = to_dict +class MessageCall: + """Represents information about a call in a private channel. + + .. versionadded:: 2.6 + """ + + def __init__(self, state: ConnectionState, data: MessageCallPayload): + self._state: ConnectionState = state + self._participants: SnowflakeList = data.get("participants", []) + self._ended_timestamp: datetime.datetime | None = utils.parse_time( + data["ended_timestamp"] + ) + + @property + def participants(self) -> list[User | Object]: + """A list of :class:`User` that participated in this call. + + If a user is not found in the client's cache, + then it will be returned as an :class:`Object`. + """ + return [self._state.get_user(int(i)) or Object(i) for i in self._participants] + + @property + def ended_at(self) -> datetime.datetime | None: + """An aware timestamp of when the call ended.""" + return self._ended_timestamp + + def flatten_handlers(cls): prefix = len("_handle_") handlers = [ @@ -681,10 +764,26 @@ class Message(Hashable): The guild that the message belongs to, if applicable. interaction: Optional[:class:`MessageInteraction`] The interaction associated with the message, if applicable. + + .. deprecated:: 2.6 + + Use :attr:`interaction_metadata` instead. + interaction_metadata: Optional[:class:`InteractionMetadata`] + The interaction metadata associated with the message, if applicable. + + .. versionadded:: 2.6 thread: Optional[:class:`Thread`] The thread created from this message, if applicable. .. versionadded:: 2.0 + poll: Optional[:class:`Poll`] + The poll associated with this message, if applicable. + + .. versionadded:: 2.6 + call: Optional[:class:`MessageCall`] + The call information associated with this message, if applicable. + + .. versionadded:: 2.6 """ __slots__ = ( @@ -718,8 +817,11 @@ class Message(Hashable): "stickers", "components", "guild", - "interaction", + "_interaction", + "interaction_metadata", "thread", + "_poll", + "call", ) if TYPE_CHECKING: @@ -799,13 +901,28 @@ def __init__( # the channel will be the correct type here ref.resolved = self.__class__(channel=chan, data=resolved, state=state) # type: ignore - from .interactions import MessageInteraction + from .interactions import InteractionMetadata, MessageInteraction - self.interaction: MessageInteraction | None + self._interaction: MessageInteraction | None try: - self.interaction = MessageInteraction(data=data["interaction"], state=state) + self._interaction = MessageInteraction( + data=data["interaction"], state=state + ) + except KeyError: + self._interaction = None + try: + self.interaction_metadata = InteractionMetadata( + data=data["interaction_metadata"], state=state + ) except KeyError: - self.interaction = None + self.interaction_metadata = None + + self._poll: Poll | None + try: + self._poll = Poll.from_dict(data["poll"], self) + self._state.store_poll(self._poll, self.id) + except KeyError: + self._poll = None self.thread: Thread | None try: @@ -815,6 +932,12 @@ def __init__( except KeyError: self.thread = None + self.call: MessageCall | None + try: + self.call = MessageCall(state=self._state, data=data["call"]) + except KeyError: + self.call = None + for handler in ("author", "member", "mentions", "mention_roles"): try: getattr(self, f"_handle_{handler}")(data[handler]) @@ -942,6 +1065,10 @@ def _handle_embeds(self, value: list[EmbedPayload]) -> None: def _handle_nonce(self, value: str | int) -> None: self.nonce = value + def _handle_poll(self, value: PollPayload) -> None: + self._poll = Poll.from_dict(value, self) + self._state.store_poll(self._poll, self.id) + def _handle_author(self, author: UserPayload) -> None: self.author = self._state.store_user(author) if isinstance(self.guild, Guild): @@ -998,6 +1125,26 @@ def _rebind_cached_references( self.guild = new_guild self.channel = new_channel + @property + def interaction(self) -> MessageInteraction | None: + utils.warn_deprecated( + "interaction", + "interaction_metadata", + "2.6", + reference="https://discord.com/developers/docs/change-log#userinstallable-apps-preview", + ) + return self._interaction + + @interaction.setter + def interaction(self, value: MessageInteraction | None) -> None: + utils.warn_deprecated( + "interaction", + "interaction_metadata", + "2.6", + reference="https://discord.com/developers/docs/change-log#userinstallable-apps-preview", + ) + self._interaction = value + @utils.cached_slot_property("_cs_raw_mentions") def raw_mentions(self) -> list[int]: """A property that returns an array of user IDs matched with @@ -1097,6 +1244,10 @@ def jump_url(self) -> str: guild_id = getattr(self.guild, "id", "@me") return f"https://discord.com/channels/{guild_id}/{self.channel.id}/{self.id}" + @property + def poll(self) -> Poll | None: + return self._state._polls.get(self.id) + def is_system(self) -> bool: """Whether the message is a system message. @@ -1328,8 +1479,7 @@ async def edit( delete_after: float | None = ..., allowed_mentions: AllowedMentions | None = ..., view: View | None = ..., - ) -> Message: - ... + ) -> Message: ... async def edit( self, @@ -1689,7 +1839,11 @@ async def clear_reactions(self) -> None: await self._state.http.clear_reactions(self.channel.id, self.id) async def create_thread( - self, *, name: str, auto_archive_duration: ThreadArchiveDuration = MISSING + self, + *, + name: str, + auto_archive_duration: ThreadArchiveDuration = MISSING, + slowmode_delay: int = MISSING, ) -> Thread: """|coro| @@ -1706,9 +1860,12 @@ async def create_thread( ---------- name: :class:`str` The name of the thread. - auto_archive_duration: :class:`int` + auto_archive_duration: Optional[:class:`int`] The duration in minutes before a thread is automatically archived for inactivity. If not provided, the channel's default auto archive duration is used. + slowmode_delay: Optional[:class:`int`] + Specifies the slowmode rate limit for user in this thread, in seconds. + A value of ``0`` disables slowmode. The maximum value possible is ``21600``. Returns ------- @@ -1737,6 +1894,7 @@ async def create_thread( name=name, auto_archive_duration=auto_archive_duration or default_auto_archive_duration, + rate_limit_per_user=slowmode_delay or 0, ) self.thread = Thread(guild=self.guild, state=self._state, data=data) @@ -1768,6 +1926,34 @@ async def reply(self, content: str | None = None, **kwargs) -> Message: return await self.channel.send(content, reference=self, **kwargs) + async def end_poll(self) -> Message: + """|coro| + + Immediately ends the poll associated with this message. Only doable by the poll's owner. + + .. versionadded:: 2.6 + + Returns + ------- + :class:`Message` + The updated message. + + Raises + ------ + Forbidden + You do not have permissions to end this poll. + HTTPException + Ending this poll failed. + """ + + data = await self._state.http.expire_poll( + self.channel.id, + self.id, + ) + message = Message(state=self._state, channel=self.channel, data=data) + + return message + def to_reference(self, *, fail_if_not_exists: bool = True) -> MessageReference: """Creates a :class:`~discord.MessageReference` from the current message. @@ -1894,6 +2080,10 @@ def created_at(self) -> datetime.datetime: """The partial message's creation time in UTC.""" return utils.snowflake_time(self.id) + @property + def poll(self) -> Poll | None: + return self._state._polls.get(self.id) + @utils.cached_slot_property("_cs_guild") def guild(self) -> Guild | None: """The guild that the partial message belongs to, if applicable.""" @@ -1991,7 +2181,7 @@ async def edit(self, **fields: Any) -> Message | None: raise InvalidArgument("Cannot pass both embed and embeds parameters.") if embed is not MISSING: - fields["embeds"] = [embed.to_dict()] + fields["embeds"] = [] if embed is None else [embed.to_dict()] if embeds is not MISSING: fields["embeds"] = [embed.to_dict() for embed in embeds] @@ -2039,3 +2229,31 @@ async def edit(self, **fields: Any) -> Message | None: view.message = msg self._state.store_view(view, self.id) return msg + + async def end_poll(self) -> Message: + """|coro| + + Immediately ends the poll associated with this message. Only doable by the poll's owner. + + .. versionadded:: 2.6 + + Returns + ------- + :class:`Message` + The updated message. + + Raises + ------ + Forbidden + You do not have permissions to end this poll. + HTTPException + Ending this poll failed. + """ + + data = await self._state.http.expire_poll( + self.channel.id, + self.id, + ) + message = self._state.create_message(channel=self.channel, data=data) + + return message diff --git a/discord/monetization.py b/discord/monetization.py new file mode 100644 index 0000000000..f18ce0593b --- /dev/null +++ b/discord/monetization.py @@ -0,0 +1,207 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-2021 Rapptz +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .enums import EntitlementType, SKUType, try_enum +from .flags import SKUFlags +from .mixins import Hashable +from .utils import MISSING, _get_as_snowflake, parse_time + +if TYPE_CHECKING: + from datetime import datetime + + from .state import ConnectionState + from .types.monetization import SKU as SKUPayload + from .types.monetization import Entitlement as EntitlementPayload + + +__all__ = ( + "SKU", + "Entitlement", +) + + +class SKU(Hashable): + """Represents a Discord SKU (stock-keeping unit). + + .. versionadded:: 2.5 + + Attributes + ---------- + id: :class:`int` + The SKU's ID. + type: :class:`SKUType` + The type of SKU. + application_id: :class:`int` + The ID of the application this SKU belongs to. + name: :class:`str` + The name of the SKU. + slug: :class:`str` + The SKU's slug. + flags: :class:`SKUFlags` + The SKU's flags. + """ + + __slots__ = ( + "id", + "type", + "application_id", + "name", + "slug", + "flags", + ) + + def __init__(self, *, data: SKUPayload) -> None: + self.id: int = int(data["id"]) + self.type: SKUType = try_enum(SKUType, data["type"]) + self.application_id: int = int(data["application_id"]) + self.name: str = data["name"] + self.slug: str = data["slug"] + self.flags: SKUFlags = SKUFlags._from_value(data["flags"]) + + def __repr__(self) -> str: + return ( + f"" + ) + + def __str__(self) -> str: + return self.name + + def __eq__(self, other: object) -> bool: + return isinstance(other, self.__class__) and other.id == self.id + + @property + def url(self) -> str: + """:class:`str`: Returns the URL for the SKU.""" + return f"https://discord.com/application-directory/{self.application_id}/store/{self.id}" + + +class Entitlement(Hashable): + """Represents a Discord entitlement. + + .. versionadded:: 2.5 + + Attributes + ---------- + id: :class:`int` + The entitlement's ID. + sku_id: :class:`int` + The ID of the SKU this entitlement is for. + application_id: :class:`int` + The ID of the application this entitlement belongs to. + user_id: Union[:class:`int`, :class:`MISSING`] + The ID of the user that owns this entitlement. + type: :class:`EntitlementType` + The type of entitlement. + deleted: :class:`bool` + Whether the entitlement has been deleted. + starts_at: Union[:class:`datetime.datetime`, :class:`MISSING`] + When the entitlement starts. + ends_at: Union[:class:`datetime.datetime`, :class:`MISSING`] + When the entitlement expires. + guild_id: Union[:class:`int`, :class:`MISSING`] + The ID of the guild that owns this entitlement. + consumed: :class:`bool` + Whether or not this entitlement has been consumed. + This will always be ``False`` for entitlements that are not + of type :attr:`EntitlementType.consumable`. + """ + + __slots__ = ( + "_state", + "id", + "sku_id", + "application_id", + "user_id", + "type", + "deleted", + "starts_at", + "ends_at", + "guild_id", + "consumed", + ) + + def __init__(self, *, data: EntitlementPayload, state: ConnectionState) -> None: + self._state = state + self.id: int = int(data["id"]) + self.sku_id: int = int(data["sku_id"]) + self.application_id: int = int(data["application_id"]) + self.user_id: int | MISSING = _get_as_snowflake(data, "user_id") or MISSING + self.type: EntitlementType = try_enum(EntitlementType, data["type"]) + self.deleted: bool = data["deleted"] + self.starts_at: datetime | MISSING = ( + parse_time(data.get("starts_at")) or MISSING + ) + self.ends_at: datetime | MISSING = parse_time(data.get("ends_at")) or MISSING + self.guild_id: int | MISSING = _get_as_snowflake(data, "guild_id") or MISSING + self.consumed: bool = data.get("consumed", False) + + def __repr__(self) -> str: + return ( + f"" + ) + + def __eq__(self, other: object) -> bool: + return isinstance(other, self.__class__) and other.id == self.id + + async def consume(self) -> None: + """|coro| + + Consumes this entitlement. + + This can only be done on entitlements of type :attr:`EntitlementType.consumable`. + + Raises + ------ + TypeError + The entitlement is not consumable. + HTTPException + Consuming the entitlement failed. + """ + if self.type is not EntitlementType.consumable: + raise TypeError("Cannot consume non-consumable entitlement") + + await self._state.http.consume_entitlement(self._state.application_id, self.id) + self.consumed = True + + async def delete(self) -> None: + """|coro| + + Deletes a test entitlement. + + A test entitlement is an entitlement that was created using :meth:`Guild.create_test_entitlement` or :meth:`User.create_test_entitlement`. + + Raises + ------ + HTTPException + Deleting the entitlement failed. + """ + await self._state.http.delete_test_entitlement(self.application_id, self.id) diff --git a/discord/onboarding.py b/discord/onboarding.py new file mode 100644 index 0000000000..bd98bbfdde --- /dev/null +++ b/discord/onboarding.py @@ -0,0 +1,482 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from .enums import OnboardingMode, PromptType, try_enum +from .partial_emoji import PartialEmoji +from .utils import MISSING, cached_property, generate_snowflake, get + +if TYPE_CHECKING: + from .abc import Snowflake + from .channel import ForumChannel, TextChannel, VoiceChannel + from .emoji import Emoji + from .guild import Guild + from .object import Object + from .partial_emoji import PartialEmoji + from .types.onboarding import Onboarding as OnboardingPayload + from .types.onboarding import OnboardingPrompt as OnboardingPromptPayload + from .types.onboarding import PromptOption as PromptOptionPayload + +__all__ = ( + "Onboarding", + "OnboardingPrompt", + "PromptOption", +) + + +class PromptOption: + """Represents an onboarding prompt option displayed in :class:`OnboardingPrompt`. + + .. versionadded:: 2.5 + + Attributes + ---------- + + id: :class:`int` + The id of the prompt option. + channels: List[:class:`Snowflake`] + The channels assigned to the user when they select this option. + roles: List[:class:`Snowflake`] + The roles assigned to the user when they select this option. + emoji: Union[:class:`Emoji`, :class:`PartialEmoji`] + The emoji displayed with the option. + title: :class:`str` + The option's title. + description: Optional[:class:`str`] + The option's description. + """ + + def __init__( + self, + title: str, + channels: list[Snowflake] | None = None, + roles: list[Snowflake] | None = None, + description: str | None = None, + emoji: Emoji | PartialEmoji | None = None, + id: int | None = None, + ): + # ID is required when making edits, but it can be any snowflake that isn't already used by another prompt during edits + self.id: int = int(id) if id else generate_snowflake() + self.title: str = title + self.channels: list[Snowflake] = channels or [] + self.roles: list[Snowflake] = roles or [] + self.description: str | None = description or None + self.emoji: Emoji | PartialEmoji | None = emoji + + def __repr__(self): + return f"" + + def to_dict(self) -> PromptOptionPayload: + dict_: PromptOptionPayload = { + "title": self.title, + "description": self.description, + "channel_ids": [channel.id for channel in self.channels], + "role_ids": [role.id for role in self.roles], + "id": str(self.id), + } + if self.emoji: + dict_["emoji"] = { + "id": str(self.emoji.id) if self.emoji.id else None, + "name": self.emoji.name, + "animated": self.emoji.animated, + } + dict_["emoji_name"] = self.emoji.name + if self.emoji.id: + dict_["emoji_id"] = str(self.emoji.id) + dict_["emoji_animated"] = self.emoji.animated + + return dict_ + + @classmethod + def _from_dict(cls, data: PromptOptionPayload, guild: Guild) -> PromptOption: + id = data.get("id", 0) + channel_ids = [int(channel_id) for channel_id in data.get("channel_ids", [])] + channels = [guild.get_channel(channel_id) for channel_id in channel_ids] + role_ids = [int(role_id) for role_id in data.get("role_ids", [])] + roles = [guild.get_role(role_id) for role_id in role_ids] + title = data.get("title") + description = data.get("description") + + _emoji: dict[str, Any] = data.get("emoji") or {} + if "name" in _emoji: + # Emoji object is {'id': None, 'name': None, 'animated': False} ... + emoji = PartialEmoji.from_dict(_emoji) + if emoji.id: + emoji = get(guild.emojis, id=emoji.id) or emoji + else: + emoji = None + + return cls(channels=channels, roles=roles, title=title, description=description, emoji=emoji, id=id) # type: ignore + + +class OnboardingPrompt: + """Represents an onboarding prompt displayed in :class:`Onboarding`. + + .. versionadded:: 2.5 + + Attributes + ---------- + + id: :class:`int` + The id of the prompt. + type: :class:`PromptType` + The type of onboarding prompt. + title: :class:`str` + The prompt's title. + options: List[:class:`PromptOption`] + The list of options available in the prompt. + single_select: :class:`bool` + Whether the user is limited to selecting one option on this prompt. + required: :class:`bool` + Whether the user is required to answer this prompt. + in_onboarding: :class:`bool` + Whether this prompt is displayed in the initial onboarding flow. + """ + + def __init__( + self, + type: PromptType, + title: str, + options: list[PromptOption], + single_select: bool, + required: bool, + in_onboarding: bool, + id: int | None = None, # Currently optional as users can manually create these + ): + # ID is required when making edits, but it can be any snowflake that isn't already used by another prompt during edits + self.id: int = int(id) if id else generate_snowflake() + + self.type: PromptType = type + if isinstance(self.type, int): + self.type = try_enum(PromptType, self.type) + self.options: list[PromptOption] = options + self.title: str = title + self.single_select: bool = single_select + self.required: bool = required + self.in_onboarding: bool = in_onboarding + + def __repr__(self): + return f"" + + def to_dict(self) -> OnboardingPromptPayload: + dict_: OnboardingPromptPayload = { + "type": self.type.value, + "title": self.title, + "single_select": self.single_select, + "required": self.required, + "in_onboarding": self.in_onboarding, + "options": [option.to_dict() for option in self.options], + "id": self.id, + } + + return dict_ + + @classmethod + def _from_dict( + cls, data: OnboardingPromptPayload, guild: Guild + ) -> OnboardingPrompt: + id = data.get("id", 0) + type = try_enum(PromptType, data.get("type")) + title = data.get("title") + single_select = data.get("single_select") + required = data.get("required") + in_onboarding = data.get("in_onboarding") + options = [ + PromptOption._from_dict(option, guild) for option in data.get("options", []) + ] + + return cls(type=type, title=title, single_select=single_select, required=required, in_onboarding=in_onboarding, options=options, id=id) # type: ignore + + +class Onboarding: + """Represents the onboarding flow for a guild. + + .. versionadded:: 2.5 + + Attributes + ---------- + + prompts: List[:class:`OnboardingPrompt`] + A list of prompts displayed in the onboarding flow. + enabled: :class:`bool` + Whether onboarding is enabled in the guild. + mode: :class:`OnboardingMode` + The current onboarding mode. + """ + + def __init__(self, data: OnboardingPayload, guild: Guild): + self.guild = guild + self._update(data) + + def __repr__(self): + return f"" + + def _update(self, data: OnboardingPayload): + self.guild_id: Snowflake = data["guild_id"] + self.prompts: list[OnboardingPrompt] = [ + OnboardingPrompt._from_dict(prompt, self.guild) + for prompt in data.get("prompts", []) + ] + self.default_channel_ids: list[int] = [ + int(c) for c in data["default_channel_ids"] + ] + self.enabled: bool = data["enabled"] + self.mode: OnboardingMode = try_enum(OnboardingMode, data.get("mode")) + + @cached_property + def default_channels( + self, + ) -> list[TextChannel | ForumChannel | VoiceChannel | Object]: + """The channels that members are opted into by default. + + If a channel is not found in the guild's cache, + then it will be returned as an :class:`Object`. + """ + if self.guild is None: + return [Object(channel_id) for channel_id in self.default_channel_ids] + return [ + self.guild.get_channel(channel_id) or Object(channel_id) + for channel_id in self.default_channel_ids + ] + + async def edit( + self, + *, + prompts: list[OnboardingPrompt] | None = MISSING, + default_channels: list[Snowflake] | None = MISSING, + enabled: bool | None = MISSING, + mode: OnboardingMode | None = MISSING, + reason: str | None = MISSING, + ) -> Onboarding: + """|coro| + + Edits this onboarding flow. + + You must have the :attr:`~Permissions.manage_guild` and :attr:`~Permissions.manage_roles` permissions in the + guild to do this. + + Parameters + ---------- + + prompts: Optional[List[:class:`OnboardingPrompt`]] + The new list of prompts for this flow. + default_channels: Optional[List[:class:`Snowflake`]] + The new default channels that users are opted into. + enabled: Optional[:class:`bool`] + Whether onboarding should be enabled. Setting this to ``True`` requires + the guild to have ``COMMUNITY`` in :attr:`~Guild.features` and at + least 7 ``default_channels``. + mode: Optional[:class:`OnboardingMode`] + The new onboarding mode. + reason: Optional[:class:`str`] + The reason for editing this onboarding flow. Shows up on the audit log. + + Returns + ------- + :class:`Onboarding` + The updated onboarding flow. + + Raises + ------ + HTTPException + Editing the onboarding flow failed somehow. + Forbidden + You don't have permissions to edit the onboarding flow. + """ + + fields: dict[str, Any] = {} + if prompts is not MISSING: + fields["prompts"] = [prompt.to_dict() for prompt in prompts] + + if default_channels is not MISSING: + fields["default_channel_ids"] = [channel.id for channel in default_channels] + + if enabled is not MISSING: + fields["enabled"] = enabled + + if mode is not MISSING: + fields["mode"] = mode.value + + new = await self.guild._state.http.edit_onboarding( + self.guild.id, fields, reason=reason + ) + self._update(new) + return self + + async def add_prompt( + self, + type: PromptType, + title: str, + options: list[PromptOption], + single_select: bool, + required: bool, + in_onboarding: bool, + *, + reason: str | None = None, + ): + """|coro| + + Adds a new onboarding prompt. + + You must have the :attr:`~Permissions.manage_guild` and :attr:`~Permissions.manage_roles` permissions in the + guild to do this. + + Parameters + ---------- + type: :class:`PromptType` + The type of onboarding prompt. + title: :class:`str` + The prompt's title. + options: List[:class:`PromptOption`] + The list of options available in the prompt. + single_select: :class:`bool` + Whether the user is limited to selecting one option on this prompt. + required: :class:`bool` + Whether the user is required to answer this prompt. + in_onboarding: :class:`bool` + Whether this prompt is displayed in the initial onboarding flow. + reason: Optional[:class:`str`] + The reason for adding this prompt. Shows up on the audit log. + + Returns + ------- + :class:`Onboarding` + The updated onboarding flow. + + Raises + ------ + HTTPException + Editing the onboarding flow failed somehow. + Forbidden + You don't have permissions to edit the onboarding flow. + """ + + prompt = OnboardingPrompt( + type, title, options, single_select, required, in_onboarding + ) + prompts = self.prompts + [prompt] + return await self.edit(prompts=prompts, reason=reason) + + async def append_prompt( + self, + prompt: OnboardingPrompt, + *, + reason: str | None = None, + ): + """|coro| + + Append an onboarding prompt onto this flow. + + You must have the :attr:`~Permissions.manage_guild` and :attr:`~Permissions.manage_roles` permissions in the + guild to do this. + + Parameters + ---------- + prompt: :class:`OnboardingPrompt` + The onboarding prompt to append. + reason: Optional[:class:`str`] + The reason for appending this prompt. Shows up on the audit log. + + Returns + ------- + :class:`Onboarding` + The updated onboarding flow. + + Raises + ------ + HTTPException + Editing the onboarding flow failed somehow. + Forbidden + You don't have permissions to edit the onboarding flow. + """ + + prompts = self.prompts + [prompt] + return await self.edit(prompts=prompts, reason=reason) + + def get_prompt( + self, + id: int, + ): + """|coro| + + Get an onboarding prompt with the given ID. + + Parameters + ---------- + id: :class:`int` + The ID of the prompt to get. + + Returns + ------- + :class:`OnboardingPrompt` + The matching prompt, or None if it didn't exist. + """ + + return get(self.prompts, id=id) + + async def delete_prompt( + self, + id: int, + *, + reason: str | None = MISSING, + ): + """|coro| + + Delete an onboarding prompt with the given ID. + + You must have the :attr:`~Permissions.manage_guild` and :attr:`~Permissions.manage_roles` permissions in the + guild to do this. + + Parameters + ---------- + id: :class:`int` + The ID of the prompt to delete. + reason: Optional[:class:`str`] + The reason for deleting this prompt. Shows up on the audit log. + + Returns + ------- + :class:`Onboarding` + The updated onboarding flow. + + Raises + ------ + ValueError + No prompt with this ID exists. + HTTPException + Editing the onboarding flow failed somehow. + Forbidden + You don't have permissions to edit the onboarding flow. + """ + + to_delete = self.get_prompt(id) + if not to_delete: + raise ValueError("Prompt with the given ID was not found.") + + prompts = self.prompts[:] + prompts.remove(to_delete) + return await self.edit(prompts=prompts, reason=reason) diff --git a/discord/partial_emoji.py b/discord/partial_emoji.py index 171d6390c5..89009cc741 100644 --- a/discord/partial_emoji.py +++ b/discord/partial_emoji.py @@ -100,7 +100,9 @@ class PartialEmoji(_EmojiTag, AssetMixin): if TYPE_CHECKING: id: int | None - def __init__(self, *, name: str, animated: bool = False, id: int | None = None): + def __init__( + self, *, name: str | None, animated: bool = False, id: int | None = None + ): self.animated = animated self.name = name self.id = id diff --git a/discord/permissions.py b/discord/permissions.py index dd959c890c..fbee5a019e 100644 --- a/discord/permissions.py +++ b/discord/permissions.py @@ -180,7 +180,7 @@ def all(cls: type[P]) -> P: """A factory method that creates a :class:`Permissions` with all permissions set to ``True``. """ - return cls(0b11111111111111111111111111111111111111111) + return cls(0b1111111111111111111111111111111111111111111111111) @classmethod def all_channel(cls: type[P]) -> P: @@ -250,7 +250,7 @@ def voice(cls: type[P]) -> P: """A factory method that creates a :class:`Permissions` with all "Voice" permissions from the official Discord UI set to ``True``. """ - return cls(0b00000011111100000000001100000000) + return cls(0b1000000001000000000000011111100000000001100000000) @classmethod def stage(cls: type[P]) -> P: @@ -653,12 +653,33 @@ def send_voice_messages(self) -> int: """ return 1 << 46 + @flag_value + def set_voice_channel_status(self) -> int: + """:class:`bool`: Returns ``True`` if a member can set voice channel status. + + .. versionadded:: 2.5 + """ + return 1 << 48 + @flag_value def send_polls(self) -> int: - """:class:`bool`: Returns ``True`` if a user can send polls. - .. versionadded:: 2.4""" + """:class:`bool`: Returns ``True`` if a member can send polls. + + .. versionadded:: 2.6 + """ return 1 << 49 + @flag_value + def use_external_apps(self) -> int: + """:class:`bool`: Returns ``True`` if a member's user-installed apps can show public responses. + Users will still be able to use user-installed apps, but responses will be ephemeral. + + This only applies to apps that are also not installed to the guild. + + .. versionadded:: 2.6 + """ + return 1 << 50 + PO = TypeVar("PO", bound="PermissionOverwrite") @@ -783,7 +804,9 @@ class PermissionOverwrite: create_guild_expressions: bool | None create_events: bool | None use_external_sounds: bool | None + set_voice_channel_status: bool | None send_polls: bool | None + use_external_apps: bool | None def __init__(self, **kwargs: bool | None): self._values: dict[str, bool | None] = {} diff --git a/discord/player.py b/discord/player.py index 0b4dc417c3..b76fe7b366 100644 --- a/discord/player.py +++ b/discord/player.py @@ -22,6 +22,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from __future__ import annotations import asyncio diff --git a/discord/poll.py b/discord/poll.py new file mode 100644 index 0000000000..25c964cfec --- /dev/null +++ b/discord/poll.py @@ -0,0 +1,522 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +import datetime +from typing import TYPE_CHECKING, Any + +from . import utils +from .enums import PollLayoutType, try_enum +from .iterators import VoteIterator +from .partial_emoji import PartialEmoji + +__all__ = ( + "PollMedia", + "PollAnswer", + "PollAnswerCount", + "PollResults", + "Poll", +) + + +if TYPE_CHECKING: + from .abc import Snowflake + from .emoji import Emoji + from .message import Message, PartialMessage + from .types.poll import Poll as PollPayload + from .types.poll import PollAnswer as PollAnswerPayload + from .types.poll import PollAnswerCount as PollAnswerCountPayload + from .types.poll import PollMedia as PollMediaPayload + from .types.poll import PollResults as PollResultsPayload + + +class PollMedia: + """Represents a poll media object that supports both questions and answers. + + .. versionadded:: 2.6 + + Attributes + ---------- + text: :class:`str` + The question/answer text. May have up to 300 characters for questions and 55 characters for answers. + + emoji: Optional[Union[:class:`Emoji`, :class:`PartialEmoji`, :class:`str`]] + The answer's emoji. + """ + + def __init__(self, text: str, emoji: Emoji | PartialEmoji | str | None = None): + self.text: str = text + self.emoji: Emoji | PartialEmoji | str | None = emoji + + def to_dict(self) -> PollMediaPayload: + dict_ = { + "text": self.text, + } + if self.emoji: + if isinstance(self.emoji, str): + dict_["emoji"] = { + "name": self.emoji, + } + else: + if self.emoji.id: + dict_["emoji"] = { + "id": str(self.emoji.id), + } + else: + dict_["emoji"] = {"name": self.emoji.name} + + return dict_ + + @classmethod + def from_dict( + cls, data: PollMediaPayload, message: Message | PartialMessage | None = None + ) -> PollMedia: + + _emoji: dict[str, Any] = data.get("emoji") or {} + if isinstance(_emoji, dict) and _emoji.get("name"): + emoji = PartialEmoji.from_dict(_emoji) + if emoji.id and message: + emoji = message._state.get_emoji(emoji.id) or emoji + else: + emoji = _emoji or None + return cls( + data["text"], + emoji, + ) + + def __repr__(self) -> str: + return f"" + + +class PollAnswer: + """Represents a poll answer object. + + .. versionadded:: 2.6 + + Attributes + ---------- + id: :class:`int` + The answer's ID. It currently starts at ``1`` for the first answer, then goes up sequentially. + It may not be reliable to depend on this. + media: :class:`PollMedia` + The relevant media for this answer. + """ + + def __init__(self, text: str, emoji: Emoji | PartialEmoji | str | None = None): + self.media = PollMedia(text, emoji) + self.id = None + self._poll = None + + @property + def text(self) -> str: + """The answer's text. Shortcut for :attr:`PollMedia.text`.""" + return self.media.text + + @property + def emoji(self) -> Emoji | PartialEmoji | None: + """The answer's emoji. Shortcut for :attr:`PollMedia.emoji`.""" + return self.media.emoji + + @property + def count(self) -> int | None: + """This answer's vote count, if recieved from Discord.""" + if not (self._poll and self.id): + return None + if self._poll.results is None: + return None # Unknown vote count. + _count = self._poll.results and utils.get( + self._poll.results.answer_counts, id=self.id + ) + if _count: + return _count.count + return 0 # If an answer isn't in answer_counts, it has 0 votes. + + def to_dict(self) -> PollAnswerPayload: + dict_ = { + "poll_media": self.media.to_dict(), + } + if self.id is not None: + dict_["answer_id"] = (self.id,) + return dict_ + + @classmethod + def from_dict( + cls, + data: PollAnswerPayload, + poll=None, + message: Message | PartialMessage | None = None, + ) -> PollAnswer: + media = PollMedia.from_dict(data["poll_media"], message=message) + answer = cls( + media.text, + media.emoji, + ) + answer.id = data["answer_id"] + answer._poll = poll + return answer + + def __repr__(self) -> str: + return f"" + + def voters( + self, *, limit: int | None = None, after: Snowflake | None = None + ) -> VoteIterator: + """Returns an :class:`AsyncIterator` representing the users that have voted with this answer. + Only works if this poll was recieved from Discord. + + The ``after`` parameter must represent a member + and meet the :class:`abc.Snowflake` abc. + + Parameters + ---------- + limit: Optional[:class:`int`] + The maximum number of results to return. + If not provided, returns all the users who + voted with this answer. + after: Optional[:class:`abc.Snowflake`] + For pagination, answers are sorted by member. + + Yields + ------ + Union[:class:`User`, :class:`Member`] + The member (if retrievable) or the user that has voted + with this answer. The case where it can be a :class:`Member` is + in a guild message context. Sometimes it can be a :class:`User` + if the member has left the guild. + + Raises + ------ + HTTPException + Getting the voters for the answer failed. + RuntimeError + This poll wasn't recieved from a message. + + Examples + -------- + + Usage :: + + async for user in answer.users(): + print(f'{user} voted **{answer.text}**!') + + Flattening into a list: :: + + users = await answer.users().flatten() + # users is now a list of User... + winner = random.choice(users) + await channel.send(f'{winner} has won the raffle.') + """ + + if not self._poll or not self._poll._message: + raise RuntimeError( + "Users can only be fetched from an existing message poll." + ) + + if limit is None: + limit = self.count or 100 # Ambiguous + + return VoteIterator(self._poll._message, self, limit, after) + + +class PollAnswerCount: + """Represents a poll answer count object. + + .. versionadded:: 2.6 + + Attributes + ---------- + id: :class:`int` + The answer's ID. It currently starts at ``1`` for the first answer, then goes up sequentially. + It may not be reliable to depend on this. + count: :class:`int` + The number of votes for this answer. + me: :class:`bool` + If the current user voted this answer. This is always ``False`` for bots. + """ + + def __init__(self, data: PollAnswerCountPayload): + self.id = data["id"] + self.count: int = data.get("count", 0) + self.me = data.get("me_voted") + + def to_dict(self) -> PollAnswerCountPayload: + return {"id": self.id, "count": self.count, "me_voted": self.me} + + def __repr__(self) -> str: + return f"" + + +class PollResults: + """Represents a poll results object. + + .. versionadded:: 2.6 + + Attributes + ---------- + is_finalized: :class:`bool` + Whether the poll has ended and all answer counts have been precisely tallied. + + answer_counts: List[:class:`PollAnswerCount`] + A list of counts for each answer. If an answer isn't included, it has no votes. + """ + + def __init__(self, data: PollResultsPayload): + self.is_finalized = data.get("is_finalized") + self._answer_counts = { + a["id"]: PollAnswerCount(a) for a in data.get("answer_counts", []) + } + + def to_dict(self) -> PollResultsPayload: + return { + "is_finalized": self.is_finalized, + "answer_counts": [a.to_dict() for a in self.answer_counts], + } + + def __repr__(self) -> str: + return f"" + + @property + def answer_counts(self) -> list[PollAnswerCount]: + return list(self._answer_counts.values()) + + def total_votes(self) -> int: + """ + Get the total number of votes across all answers. This may not be accurate if :attr:`is_finalized` is ``False``. + + Returns + ------- + :class:`int` + The total number of votes on this poll. + """ + return sum([a.count for a in self.answer_counts]) + + +class Poll: + """Represents a Poll. Polls are sent in regular messages, and you must have :attr:`~discord.Permissions.send_polls` to send them. + + .. versionadded:: 2.6 + + Attributes + ---------- + question: Union[:class:`PollMedia`, :class:`str`] + The poll's question media, or a ``str`` representing the question text. Question text can be up to 300 characters. + answers: Optional[List[:class:`PollAnswer`]] + A list of the poll's answers. A maximum of 10 answers can be set. + duration: :class:`int` + The number of hours until this poll expires. Users must specify this when creating a poll, but existing polls return :attr:`expiry` instead. Defaults to 24. + allow_multiselect: :class:`bool` + Whether multiple answers can be selected. Defaults to ``False``. + layout_type: :class:`PollLayoutType` + The poll's layout type. Only one exists at the moment. + results: Optional[:class:`PollResults`] + The results of this poll recieved from Discord. If ``None``, this should be considered "unknown" rather than "no" results. + """ + + def __init__( + self, + question: PollMedia | str, + *, + answers: list[PollAnswer] | None = None, + duration: int | None = 24, + allow_multiselect: bool | None = False, + layout_type: PollLayoutType | None = PollLayoutType.default, + ): + self.question = ( + question if isinstance(question, PollMedia) else PollMedia(question) + ) + self.answers: list[PollAnswer] = answers or [] + self.duration: int | None = duration + self.allow_multiselect: bool = allow_multiselect + self.layout_type: PollLayoutType = layout_type + self.results = None + self._expiry = None + self._message = None + + @utils.cached_property + def expiry(self) -> datetime.datetime | None: + """An aware datetime object that specifies the date and time in UTC when the poll will end.""" + return utils.parse_time(self._expiry) + + def to_dict(self) -> PollPayload: + dict_ = { + "question": self.question.to_dict(), + "answers": [a.to_dict() for a in self.answers], + "duration": self.duration, + "allow_multiselect": self.allow_multiselect, + "layout_type": self.layout_type.value, + } + if self.results: + dict_["results"] = [r.to_dict() for r in self.results] + if self._expiry: + dict_["expiry"] = self._expiry + return dict_ + + @classmethod + def from_dict( + cls, data: PollPayload, message: Message | PartialMessage | None = None + ) -> Poll: + if not data: + return None + poll = cls( + question=PollMedia.from_dict(data["question"], message=message), + answers=[ + PollAnswer.from_dict(a, message=message) + for a in data.get("answers", []) + ], + duration=data.get("duration"), + allow_multiselect=data.get("allow_multiselect"), + layout_type=try_enum(PollLayoutType, data.get("layout_type", 1)), + ) + if (results := data.get("results")) is not None: + poll.results = PollResults(results) + elif message and message.poll: + # results is nullable, so grab old results if necessary. + poll.results = message.poll.results + poll._expiry = data.get("expiry") + poll._message = message + for a in poll.answers: + a._poll = poll + return poll + + def __repr__(self) -> str: + return f"" + + def has_ended(self) -> bool | None: + """ + Checks if this poll has completely ended. Shortcut for :attr:`PollResults.is_finalized`, if available. + + Returns + ------- + Optional[:class:`bool`] + Returns a boolean if :attr:`results` is available, otherwise ``None``. + """ + if not self.results: + return None + return self.results.is_finalized + + def total_votes(self) -> int | None: + """ + Shortcut for :meth:`PollResults.total_votes` This may not be precise if :attr:`is_finalized` is ``False``. + + Returns + ------- + Optional[:class:`int`] + The total number of votes on this poll if :attr:`results` is available, otherwise ``None``. + """ + if not self.results: + return None + return self.results.total_votes() + + def get_answer(self, id) -> PollAnswer | None: + """ + Get a poll answer by ID. + + Parameters + ---------- + id: :class:`int` + The ID to search for. + + Returns + ------- + Optional[:class:`.PollAnswer`] + The returned answer or ``None`` if not found. + """ + return utils.get(self.answers, id=id) + + def add_answer( + self, text: str, *, emoji: Emoji | PartialEmoji | str | None = None + ) -> Poll: + """Add an answer to this poll. + + This function returns the class instance to allow for fluent-style + chaining. + + Parameters + ---------- + text: :class:`str` + The answer text. Maximum 55 characters. + emoji: Optional[Union[:class:`Emoji`, :class:`PartialEmoji`, :class:`str`]] + The answer's emoji. + + Raises + ------ + ValueError + The poll already has 10 answers or ``text`` exceeds the character length. + RuntimeError + You cannot add an answer to an existing poll. + + Examples + -------- + + Regular usage :: + + poll = Poll( + question=PollMedia("What's your favorite color?"), + + answers=[PollAnswer("Red", "❤")] + duration=24, + allow_multiselect=False + ) + poll.add_answer(text="Green", emoji="💚") + poll.add_answer(text="Blue", emoji="💙") + + Chaining style :: + + poll = Poll("What's your favorite color?").add_answer("Red", emoji="❤").add_answer("Green").add_answer("Blue") + """ + if len(self.answers) >= 10: + raise ValueError("Polls may only have up to 10 answers.") + if len(text) > 55: + raise ValueError("text length must be between 1 and 55 characters.") + if self.expiry or self._message: + raise RuntimeError("You cannot add answers to an existing poll.") + + self.answers.append(PollAnswer(text, emoji)) + return self + + async def end(self) -> Message: + """ + Immediately ends this poll, if attached to a message. Only doable by the poll's owner. + Shortcut to :meth:`Message.end_poll` + + Returns + ------- + :class:`Message` + The updated message. + + Raises + ------ + Forbidden + You do not have permissions to end this poll. + HTTPException + Ending this poll failed. + RuntimeError + This poll wasn't recieved from a message. + """ + + if not self._message: + raise RuntimeError("You can only end a poll recieved from a message.") + + return await self._message.end_poll() diff --git a/discord/raw_models.py b/discord/raw_models.py index 79c8091d98..e59507dd48 100644 --- a/discord/raw_models.py +++ b/discord/raw_models.py @@ -30,7 +30,6 @@ from .automod import AutoModAction, AutoModTriggerType from .enums import AuditLogAction, ChannelType, ReactionType, try_enum -from .types.user import User if TYPE_CHECKING: from .abc import MessageableChannel @@ -47,6 +46,7 @@ IntegrationDeleteEvent, MemberRemoveEvent, MessageDeleteEvent, + MessagePollVoteEvent, MessageUpdateEvent, ReactionActionEvent, ReactionClearEmojiEvent, @@ -56,7 +56,9 @@ ThreadMembersUpdateEvent, ThreadUpdateEvent, TypingEvent, + VoiceChannelStatusUpdateEvent, ) + from .user import User __all__ = ( @@ -75,6 +77,8 @@ "AutoModActionExecutionEvent", "RawThreadMembersUpdateEvent", "RawAuditLogEntryEvent", + "RawVoiceChannelStatusUpdateEvent", + "RawMessagePollVoteEvent", ) @@ -203,8 +207,7 @@ class RawReactionActionEvent(_RawReprMixin): emoji: :class:`PartialEmoji` The custom or unicode emoji being used. member: Optional[:class:`Member`] - The member who added the reaction. Only available if `event_type` is `REACTION_ADD` - and the reaction is inside a guild. + The member who added the reaction. Only available if the reaction occurs within a guild. .. versionadded:: 1.3 @@ -441,6 +444,36 @@ def __init__(self, data: ThreadDeleteEvent) -> None: self.data: ThreadDeleteEvent = data +class RawVoiceChannelStatusUpdateEvent(_RawReprMixin): + """Represents the payload for an :func:`on_raw_voice_channel_status_update` event. + + .. versionadded:: 2.5 + + Attributes + ---------- + id: :class:`int` + The channel ID where the voice channel status update originated from. + guild_id: :class:`int` + The guild ID where the voice channel status update originated from. + status: Optional[:class:`str`] + The new new voice channel status. + data: :class:`dict` + The raw data sent by the `gateway `_. + """ + + __slots__ = ("id", "guild_id", "status", "data") + + def __init__(self, data: VoiceChannelStatusUpdateEvent) -> None: + self.id: int = int(data["id"]) + self.guild_id: int = int(data["guild_id"]) + + try: + self.status: str | None = data["status"] + except KeyError: + self.status: str | None = None + self.data: VoiceChannelStatusUpdateEvent = data + + class RawTypingEvent(_RawReprMixin): """Represents the payload for a :func:`on_raw_typing` event. @@ -564,7 +597,7 @@ class AutoModActionExecutionEvent: The member that triggered the action, if cached. channel_id: Optional[:class:`int`] The ID of the channel in which the member's content was posted. - channel: Optional[Union[:class:`TextChannel`, :class:`Thread`, :class:`VoiceChannel`]] + channel: Optional[Union[:class:`TextChannel`, :class:`Thread`, :class:`VoiceChannel`, :class:`StageChannel`]] The channel in which the member's content was posted, if cached. message_id: Optional[:class:`int`] The ID of the message that triggered the action. This is only available if the @@ -749,3 +782,42 @@ def __init__(self, data: AuditLogEntryEvent) -> None: self.extra = data.get("options") self.changes = data.get("changes") self.data: AuditLogEntryEvent = data + + +class RawMessagePollVoteEvent(_RawReprMixin): + """Represents the payload for a :func:`on_message_poll_vote` event. + + .. versionadded:: 2.6 + + Attributes + ---------- + user_id: :class:`int`: + The user that added or removed their vote + message_id: :class:`int` + The message ID of the poll that received the vote. + channel_id: :class:`int` + The channel ID where the vote was updated. + guild_id: Optional[:class:`int`] + The guild ID where the vote was updated, if applicable. + answer_id: :class:`int` + The answer ID of the vote that was updated. + added: :class:`bool` + Whether this vote was added or removed. + data: :class:`dict` + The raw data sent by the `gateway ` + """ + + __slots__ = ("user_id", "message_id", "channel_id", "guild_id", "data", "added") + + def __init__(self, data: MessagePollVoteEvent, added: bool) -> None: + self.user_id: int = int(data["user_id"]) + self.channel_id: int = int(data["channel_id"]) + self.message_id: int = int(data["message_id"]) + self.answer_id: int = int(data["answer_id"]) + self.data: MessagePollVoteEvent = data + self.added: bool = added + + try: + self.guild_id: int | None = int(data["guild_id"]) + except KeyError: + self.guild_id: int | None = None diff --git a/discord/reaction.py b/discord/reaction.py index 426b5474ef..2726e8984f 100644 --- a/discord/reaction.py +++ b/discord/reaction.py @@ -111,7 +111,7 @@ def __init__( self.me: bool = data.get("me") self.burst: bool = data.get("burst") self.me_burst: bool = data.get("me_burst") - self._burst_colours: list[Colour] = data.get("burst_colors", []) + self._burst_colours: list[str] = data.get("burst_colors", []) @property def burst_colours(self) -> list[Colour]: diff --git a/discord/role.py b/discord/role.py index 4012d29cb5..1907606e9d 100644 --- a/discord/role.py +++ b/discord/role.py @@ -30,6 +30,7 @@ from .asset import Asset from .colour import Colour from .errors import InvalidArgument +from .flags import RoleFlags from .mixins import Hashable from .permissions import Permissions from .utils import MISSING, _bytes_to_base64_data, _get_as_snowflake, snowflake_time @@ -177,6 +178,11 @@ class Role(Hashable): Only available to guilds that contain ``ROLE_ICONS`` in :attr:`Guild.features`. .. versionadded:: 2.0 + + flags: :class:`RoleFlags` + Extra attributes of the role. + + .. versionadded:: 2.6 """ __slots__ = ( @@ -193,6 +199,7 @@ class Role(Hashable): "unicode_emoji", "_icon", "_state", + "flags", ) def __init__(self, *, guild: Guild, state: ConnectionState, data: RolePayload): @@ -253,6 +260,7 @@ def _update(self, data: RolePayload): self.mentionable: bool = data.get("mentionable", False) self._icon: str | None = data.get("icon") self.unicode_emoji: str | None = data.get("unicode_emoji") + self.flags: RoleFlags = RoleFlags._from_value(data.get("flags", 0)) self.tags: RoleTags | None try: diff --git a/discord/scheduled_events.py b/discord/scheduled_events.py index f7c0f898d9..4ba16bcdec 100644 --- a/discord/scheduled_events.py +++ b/discord/scheduled_events.py @@ -270,11 +270,9 @@ async def edit( name: str = MISSING, description: str = MISSING, status: int | ScheduledEventStatus = MISSING, - location: str - | int - | VoiceChannel - | StageChannel - | ScheduledEventLocation = MISSING, + location: ( + str | int | VoiceChannel | StageChannel | ScheduledEventLocation + ) = MISSING, start_time: datetime.datetime = MISSING, end_time: datetime.datetime = MISSING, cover: bytes | None = MISSING, @@ -484,11 +482,11 @@ async def cancel(self, *, reason: str | None = None) -> None: def subscribers( self, *, - limit: int = 100, + limit: int | None = 100, as_member: bool = False, before: Snowflake | datetime.datetime | None = None, after: Snowflake | datetime.datetime | None = None, - ) -> AsyncIterator: + ) -> ScheduledEventSubscribersIterator: """Returns an :class:`AsyncIterator` representing the users or members subscribed to the event. The ``after`` and ``before`` parameters must represent member diff --git a/discord/sinks/__init__.py b/discord/sinks/__init__.py index 7af605af81..6db5209af0 100644 --- a/discord/sinks/__init__.py +++ b/discord/sinks/__init__.py @@ -7,6 +7,7 @@ :copyright: 2021-present Pycord Development :license: MIT, see LICENSE for more details. """ + from .core import * from .errors import * from .m4a import * diff --git a/discord/sinks/core.py b/discord/sinks/core.py index 05d090a07a..c8ea31a82a 100644 --- a/discord/sinks/core.py +++ b/discord/sinks/core.py @@ -22,6 +22,9 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + +from __future__ import annotations + import io import os import struct @@ -201,7 +204,7 @@ def __init__(self, *, filters=None): filters = default_filters self.filters = filters Filters.__init__(self, **self.filters) - self.vc: VoiceClient = None + self.vc: VoiceClient | None = None self.audio_data = {} def init(self, vc): # called under listen diff --git a/discord/sinks/errors.py b/discord/sinks/errors.py index da1a928566..5f036efff5 100644 --- a/discord/sinks/errors.py +++ b/discord/sinks/errors.py @@ -21,6 +21,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from discord.errors import DiscordException diff --git a/discord/sinks/m4a.py b/discord/sinks/m4a.py index 6b65e3653c..1cff9da538 100644 --- a/discord/sinks/m4a.py +++ b/discord/sinks/m4a.py @@ -21,6 +21,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + import io import os import subprocess diff --git a/discord/sinks/mka.py b/discord/sinks/mka.py index 819608d368..c2bbefb923 100644 --- a/discord/sinks/mka.py +++ b/discord/sinks/mka.py @@ -21,6 +21,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + import io import subprocess diff --git a/discord/sinks/mkv.py b/discord/sinks/mkv.py index 2071c1ae4f..93f4cc7444 100644 --- a/discord/sinks/mkv.py +++ b/discord/sinks/mkv.py @@ -21,6 +21,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + import io import subprocess diff --git a/discord/sinks/mp3.py b/discord/sinks/mp3.py index c4707eb5ed..74386a2738 100644 --- a/discord/sinks/mp3.py +++ b/discord/sinks/mp3.py @@ -21,6 +21,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + import io import subprocess diff --git a/discord/sinks/mp4.py b/discord/sinks/mp4.py index 9f540c6c29..c4d0ed2b63 100644 --- a/discord/sinks/mp4.py +++ b/discord/sinks/mp4.py @@ -21,6 +21,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + import io import os import subprocess diff --git a/discord/sinks/ogg.py b/discord/sinks/ogg.py index 09abcfc402..7b531464bd 100644 --- a/discord/sinks/ogg.py +++ b/discord/sinks/ogg.py @@ -21,6 +21,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + import io import subprocess diff --git a/discord/sinks/pcm.py b/discord/sinks/pcm.py index 5ec8d47634..c587da349a 100644 --- a/discord/sinks/pcm.py +++ b/discord/sinks/pcm.py @@ -21,6 +21,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from .core import Filters, Sink, default_filters diff --git a/discord/sinks/wave.py b/discord/sinks/wave.py index 90645bd602..37f5aac933 100644 --- a/discord/sinks/wave.py +++ b/discord/sinks/wave.py @@ -21,6 +21,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + import wave from .core import Filters, Sink, default_filters diff --git a/discord/state.py b/discord/state.py index d222ba4518..c8d8d4dced 100644 --- a/discord/state.py +++ b/discord/state.py @@ -59,8 +59,10 @@ from .member import Member from .mentions import AllowedMentions from .message import Message +from .monetization import Entitlement from .object import Object from .partial_emoji import PartialEmoji +from .poll import Poll, PollAnswerCount from .raw_models import * from .role import Role from .scheduled_events import ScheduledEvent @@ -83,9 +85,10 @@ from .types.emoji import Emoji as EmojiPayload from .types.guild import Guild as GuildPayload from .types.message import Message as MessagePayload + from .types.poll import Poll as PollPayload from .types.sticker import GuildSticker as GuildStickerPayload from .types.user import User as UserPayload - from .voice_client import VoiceProtocol + from .voice_client import VoiceClient T = TypeVar("T") CS = TypeVar("CS", bound="ConnectionState") @@ -143,9 +146,7 @@ def done(self) -> None: _log = logging.getLogger(__name__) -async def logging_coroutine( - coroutine: Coroutine[Any, Any, T], *, info: str -) -> T | None: +async def logging_coroutine(coroutine: Coroutine[Any, Any, T], *, info: str) -> None: try: await coroutine except Exception: @@ -275,10 +276,11 @@ def clear(self, *, views: bool = True) -> None: self._emojis: dict[int, Emoji] = {} self._stickers: dict[int, GuildSticker] = {} self._guilds: dict[int, Guild] = {} + self._polls: dict[int, Guild] = {} if views: self._view_store: ViewStore = ViewStore(self) self._modal_store: ModalStore = ModalStore(self) - self._voice_clients: dict[int, VoiceProtocol] = {} + self._voice_clients: dict[int, VoiceClient] = {} # LRU of max size 128 self._private_channels: OrderedDict[int, PrivateChannel] = OrderedDict() @@ -331,14 +333,14 @@ def intents(self) -> Intents: return ret @property - def voice_clients(self) -> list[VoiceProtocol]: + def voice_clients(self) -> list[VoiceClient]: return list(self._voice_clients.values()) - def _get_voice_client(self, guild_id: int | None) -> VoiceProtocol | None: + def _get_voice_client(self, guild_id: int | None) -> VoiceClient | None: # the keys of self._voice_clients are ints return self._voice_clients.get(guild_id) # type: ignore - def _add_voice_client(self, guild_id: int, voice: VoiceProtocol) -> None: + def _add_voice_client(self, guild_id: int, voice: VoiceClient) -> None: self._voice_clients[guild_id] = voice def _remove_voice_client(self, guild_id: int) -> None: @@ -434,6 +436,25 @@ def get_sticker(self, sticker_id: int | None) -> GuildSticker | None: # the keys of self._stickers are ints return self._stickers.get(sticker_id) # type: ignore + @property + def polls(self) -> list[Poll]: + return list(self._polls.values()) + + def store_raw_poll(self, poll: PollPayload, raw): + channel = self.get_channel(raw.channel_id) or PartialMessageable( + state=self, id=raw.channel_id + ) + message = channel.get_partial_message(raw.message_id) + p = Poll.from_dict(poll, message) + self._polls[message.id] = p + return p + + def store_poll(self, poll: Poll, message_id: int): + self._polls[message_id] = poll + + def get_poll(self, message_id): + return self._polls.get(message_id) + @property def private_channels(self) -> list[PrivateChannel]: return list(self._private_channels.values()) @@ -528,9 +549,9 @@ async def chunker( async def query_members( self, guild: Guild, - query: str, + query: str | None, limit: int, - user_ids: list[int], + user_ids: list[int] | None, cache: bool, presences: bool, ): @@ -665,6 +686,18 @@ def parse_auto_moderation_action_execution(self, data) -> None: event = AutoModActionExecutionEvent(self, data) self.dispatch("auto_moderation_action_execution", event) + def parse_entitlement_create(self, data) -> None: + event = Entitlement(data=data, state=self) + self.dispatch("entitlement_create", event) + + def parse_entitlement_update(self, data) -> None: + event = Entitlement(data=data, state=self) + self.dispatch("entitlement_update", event) + + def parse_entitlement_delete(self, data) -> None: + event = Entitlement(data=data, state=self) + self.dispatch("entitlement_delete", event) + def parse_message_create(self, data) -> None: channel, _ = self._get_guild_channel(data) # channel would be the correct type here @@ -719,6 +752,8 @@ def parse_message_update(self, data) -> None: older_message.author = message.author self.dispatch("message_edit", older_message, message) else: + if poll_data := data.get("poll"): + self.store_raw_poll(poll_data, raw) self.dispatch("raw_message_edit", raw) if "components" in data and self._view_store.is_message_tracked(raw.message_id): @@ -768,6 +803,17 @@ def parse_message_reaction_remove(self, data) -> None: emoji_id = utils._get_as_snowflake(emoji, "id") emoji = PartialEmoji.with_state(self, id=emoji_id, name=emoji["name"]) raw = RawReactionActionEvent(data, emoji, "REACTION_REMOVE") + + member_data = data.get("member") + if member_data: + guild = self._get_guild(raw.guild_id) + if guild is not None: + raw.member = Member(data=member_data, guild=guild, state=self) + else: + raw.member = None + else: + raw.member = None + self.dispatch("raw_reaction_remove", raw) message = self._get_message(raw.message_id) @@ -799,6 +845,56 @@ def parse_message_reaction_remove_emoji(self, data) -> None: if reaction: self.dispatch("reaction_clear_emoji", reaction) + def parse_message_poll_vote_add(self, data) -> None: + raw = RawMessagePollVoteEvent(data, True) + guild = self._get_guild(raw.guild_id) + if guild: + user = guild.get_member(raw.user_id) + else: + user = self.get_user(raw.user_id) + self.dispatch("raw_poll_vote_add", raw) + + self._get_message(raw.message_id) + poll = self.get_poll(raw.message_id) + # if message was cached, poll has already updated but votes haven't + if poll and poll.results: + answer = poll.get_answer(raw.answer_id) + counts = poll.results._answer_counts + if answer is not None: + if answer.id in counts: + counts[answer.id].count += 1 + else: + counts[answer.id] = PollAnswerCount( + {"id": answer.id, "count": 1, "me_voted": False} + ) + if poll is not None and user is not None: + answer = poll.get_answer(raw.answer_id) + if answer is not None: + self.dispatch("poll_vote_add", poll, user, answer) + + def parse_message_poll_vote_remove(self, data) -> None: + raw = RawMessagePollVoteEvent(data, False) + guild = self._get_guild(raw.guild_id) + if guild: + user = guild.get_member(raw.user_id) + else: + user = self.get_user(raw.user_id) + self.dispatch("raw_poll_vote_remove", raw) + + self._get_message(raw.message_id) + poll = self.get_poll(raw.message_id) + # if message was cached, poll has already updated but votes haven't + if poll and poll.results: + answer = poll.get_answer(raw.answer_id) + counts = poll.results._answer_counts + if answer is not None: + if answer.id in counts: + counts[answer.id].count -= 1 + if poll is not None and user is not None: + answer = poll.get_answer(raw.answer_id) + if answer is not None: + self.dispatch("poll_vote_remove", poll, user, answer) + def parse_interaction_create(self, data) -> None: interaction = Interaction(data=data, state=self) if data["type"] == 3: # interaction component @@ -963,14 +1059,27 @@ def parse_thread_create(self, data) -> None: ) return - thread = Thread(guild=guild, state=guild._state, data=data) - has_thread = guild.get_thread(thread.id) - guild._add_thread(thread) - if not has_thread: + cached_thread = guild.get_thread(int(data["id"])) + if not cached_thread: + thread = Thread(guild=guild, state=guild._state, data=data) + guild._add_thread(thread) if data.get("newly_created"): + thread._add_member( + ThreadMember( + thread, + { + "id": thread.id, + "user_id": data["owner_id"], + "join_timestamp": data["thread_metadata"][ + "create_timestamp" + ], + "flags": utils.MISSING, + }, + ) + ) self.dispatch("thread_create", thread) - else: - self.dispatch("thread_join", thread) + else: + self.dispatch("thread_join", cached_thread) def parse_thread_update(self, data) -> None: guild_id = int(data["guild_id"]) @@ -1082,6 +1191,7 @@ def parse_thread_member_update(self, data) -> None: member = ThreadMember(thread, data) thread.me = member + thread._add_member(member) def parse_thread_members_update(self, data) -> None: guild_id = int(data["guild_id"]) @@ -1110,20 +1220,21 @@ def parse_thread_members_update(self, data) -> None: removed_member_ids = [int(x) for x in data.get("removed_member_ids", [])] self_id = self.self_id for member in added_members: + thread._add_member(member) if member.id != self_id: - thread._add_member(member) self.dispatch("thread_member_join", member) else: thread.me = member self.dispatch("thread_join", thread) for member_id in removed_member_ids: + member = thread._pop_member(member_id) if member_id != self_id: - member = thread._pop_member(member_id) self.dispatch("raw_thread_member_remove", raw) if member is not None: self.dispatch("thread_member_remove", member) else: + thread.me = None self.dispatch("thread_remove", thread) def parse_guild_member_add(self, data) -> None: @@ -1755,6 +1866,30 @@ def parse_voice_server_update(self, data) -> None: ) ) + def parse_voice_channel_status_update(self, data) -> None: + raw = RawVoiceChannelStatusUpdateEvent(data) + self.dispatch("raw_voice_channel_status_update", raw) + guild = self._get_guild(int(data["guild_id"])) + channel_id = int(data["id"]) + if guild is not None: + channel = guild.get_channel(channel_id) + if channel is not None: + old_status = channel.status + channel.status = data.get("status", None) + self.dispatch( + "voice_channel_status_update", channel, old_status, channel.status + ) + else: + _log.debug( + "VOICE_CHANNEL_STATUS_UPDATE referencing an unknown channel ID: %s. Discarding.", + channel_id, + ) + else: + _log.debug( + "VOICE_CHANNEL_STATUS_UPDATE referencing unknown guild ID: %s. Discarding.", + data["guild_id"], + ) + def parse_typing_start(self, data) -> None: raw = RawTypingEvent(data) diff --git a/discord/threads.py b/discord/threads.py index 9a65997e52..28eafc9522 100644 --- a/discord/threads.py +++ b/discord/threads.py @@ -288,7 +288,7 @@ def jump_url(self) -> str: @property def members(self) -> list[ThreadMember]: - """A list of thread members in this thread. + """A list of thread members in this thread, including the bot if it is a member of this thread. This requires :attr:`Intents.members` to be properly filled. Most of the time however, this data is not provided by the gateway and a call to :meth:`fetch_members` is diff --git a/discord/types/audit_log.py b/discord/types/audit_log.py index c0b1303947..d543f9e3b7 100644 --- a/discord/types/audit_log.py +++ b/discord/types/audit_log.py @@ -112,6 +112,7 @@ class _AuditLogChange_Str(TypedDict): "deny", "permissions", "tags", + "status", ] new_value: str old_value: str @@ -270,6 +271,8 @@ class AuditEntryInfo(TypedDict): application_id: Snowflake auto_moderation_rule_name: str auto_moderation_rule_trigger_type: str + # TODO: add integration_type + status: str class AuditLogEntry(TypedDict): diff --git a/discord/types/channel.py b/discord/types/channel.py index bc825a3f38..1b7fb1fe5e 100644 --- a/discord/types/channel.py +++ b/discord/types/channel.py @@ -22,6 +22,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from __future__ import annotations from typing import Literal, Union @@ -31,7 +32,7 @@ from ..flags import ChannelFlags from .snowflake import Snowflake from .threads import ThreadArchiveDuration, ThreadMember, ThreadMetadata -from .user import PartialUser +from .user import User OverwriteType = Literal[0, 1] @@ -107,6 +108,7 @@ class NewsChannel(_BaseGuildChannel, _TextChannelOptional): class VoiceChannel(_BaseGuildChannel): rtc_region: NotRequired[str | None] video_quality_mode: NotRequired[VideoQualityMode] + status: NotRequired[str | None] type: Literal[2] bitrate: int user_limit: int @@ -157,7 +159,7 @@ class DMChannel(TypedDict): id: Snowflake type: Literal[1] last_message_id: Snowflake | None - recipients: list[PartialUser] + recipients: list[User] class GroupDMChannel(_BaseChannel): diff --git a/discord/types/components.py b/discord/types/components.py index fa82d47b6f..4c0ce30ad4 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -30,9 +30,10 @@ from .._typed_dict import NotRequired, TypedDict from .channel import ChannelType from .emoji import PartialEmoji +from .snowflake import Snowflake ComponentType = Literal[1, 2, 3, 4] -ButtonStyle = Literal[1, 2, 3, 4, 5] +ButtonStyle = Literal[1, 2, 3, 4, 5, 6] InputTextStyle = Literal[1, 2] @@ -49,6 +50,7 @@ class ButtonComponent(TypedDict): label: NotRequired[str] type: Literal[2] style: ButtonStyle + sku_id: Snowflake class InputText(TypedDict): diff --git a/discord/types/embed.py b/discord/types/embed.py index 0d73afe40c..dbfcb14c87 100644 --- a/discord/types/embed.py +++ b/discord/types/embed.py @@ -22,6 +22,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from __future__ import annotations from typing import Literal diff --git a/discord/types/emoji.py b/discord/types/emoji.py index 68040ebce2..f4d7f64923 100644 --- a/discord/types/emoji.py +++ b/discord/types/emoji.py @@ -22,6 +22,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from __future__ import annotations from typing import TypedDict diff --git a/discord/types/guild.py b/discord/types/guild.py index 567f36d5cc..cac645b272 100644 --- a/discord/types/guild.py +++ b/discord/types/guild.py @@ -22,6 +22,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from __future__ import annotations from typing import Literal @@ -184,3 +185,8 @@ class RolePositionUpdate(TypedDict, total=False): class GuildMFAModify(TypedDict): level: Literal[0, 1] + + +class GuildBulkBan(TypedDict): + banned_users: list[Snowflake] + failed_users: list[Snowflake] diff --git a/discord/types/interactions.py b/discord/types/interactions.py index db66489067..2c7fd520ab 100644 --- a/discord/types/interactions.py +++ b/discord/types/interactions.py @@ -25,7 +25,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Literal, Union +from typing import TYPE_CHECKING, Dict, Literal, Union from ..permissions import Permissions from .channel import ChannelType @@ -33,6 +33,7 @@ from .embed import Embed from .member import Member from .message import Attachment +from .monetization import Entitlement from .role import Role from .snowflake import Snowflake from .user import User @@ -111,7 +112,7 @@ class GuildApplicationCommandPermissions(PartialGuildApplicationCommandPermissio guild_id: Snowflake -InteractionType = Literal[1, 2, 3] +InteractionType = Literal[1, 2, 3, 4, 5] class _ApplicationCommandInteractionDataOption(TypedDict): @@ -219,6 +220,19 @@ class Interaction(TypedDict): type: InteractionType token: str version: int + entitlements: list[Entitlement] + authorizing_integration_owners: AuthorizingIntegrationOwners + context: InteractionContextType + + +class InteractionMetadata(TypedDict): + id: Snowflake + type: InteractionType + user_id: Snowflake + authorizing_integration_owners: AuthorizingIntegrationOwners + original_response_message_id: NotRequired[Snowflake] + interacted_message_id: NotRequired[Snowflake] + triggering_interaction_metadata: NotRequired[InteractionMetadata] class InteractionApplicationCommandCallbackData(TypedDict, total=False): @@ -230,7 +244,7 @@ class InteractionApplicationCommandCallbackData(TypedDict, total=False): components: list[Component] -InteractionResponseType = Literal[1, 4, 5, 6, 7] +InteractionResponseType = Literal[1, 4, 5, 6, 7, 8, 9, 10] class InteractionResponse(TypedDict): @@ -251,3 +265,10 @@ class EditApplicationCommand(TypedDict): type: NotRequired[ApplicationCommandType] name: str default_permission: bool + + +InteractionContextType = Literal[0, 1, 2] +ApplicationIntegrationType = Literal[0, 1] +_StringApplicationIntegrationType = Literal["0", "1"] + +AuthorizingIntegrationOwners = Dict[_StringApplicationIntegrationType, Snowflake] diff --git a/discord/types/member.py b/discord/types/member.py index c6fdf6183a..0bc1071fb1 100644 --- a/discord/types/member.py +++ b/discord/types/member.py @@ -48,6 +48,7 @@ class Member(PartialMember, total=False): pending: bool permissions: str communication_disabled_until: str + flags: int class _OptionalMemberWithUser(PartialMember, total=False): diff --git a/discord/types/message.py b/discord/types/message.py index 93e99ba7ab..f138609d1b 100644 --- a/discord/types/message.py +++ b/discord/types/message.py @@ -32,13 +32,14 @@ from .embed import Embed from .emoji import PartialEmoji from .member import Member, UserWithMember +from .poll import Poll from .snowflake import Snowflake, SnowflakeList from .sticker import StickerItem from .threads import Thread from .user import User if TYPE_CHECKING: - from .interactions import MessageInteraction + from .interactions import InteractionMetadata, MessageInteraction from .._typed_dict import NotRequired, TypedDict @@ -55,6 +56,9 @@ class Reaction(TypedDict): me: bool emoji: PartialEmoji burst: bool + me_burst: bool + burst_colors: list[str] + count_details: ReactionCountDetails class ReactionCountDetails(TypedDict): @@ -66,6 +70,7 @@ class Attachment(TypedDict): height: NotRequired[int | None] width: NotRequired[int | None] content_type: NotRequired[str] + description: NotRequired[str] spoiler: NotRequired[bool] id: Snowflake filename: str @@ -74,6 +79,8 @@ class Attachment(TypedDict): proxy_url: str duration_secs: NotRequired[float] waveform: NotRequired[str] + flags: NotRequired[int] + title: NotRequired[str] MessageActivityType = Literal[1, 2, 3, 5] @@ -119,6 +126,7 @@ class Message(TypedDict): sticker_items: NotRequired[list[StickerItem]] referenced_message: NotRequired[Message | None] interaction: NotRequired[MessageInteraction] + interaction_metadata: NotRequired[InteractionMetadata] components: NotRequired[list[Component]] thread: NotRequired[Thread | None] id: Snowflake @@ -135,6 +143,7 @@ class Message(TypedDict): embeds: list[Embed] pinned: bool type: MessageType + poll: Poll AllowedMentionType = Literal["roles", "users", "everyone"] @@ -145,3 +154,8 @@ class AllowedMentions(TypedDict): roles: SnowflakeList users: SnowflakeList replied_user: bool + + +class MessageCall(TypedDict): + participants: SnowflakeList + ended_timestamp: NotRequired[str] diff --git a/discord/types/monetization.py b/discord/types/monetization.py new file mode 100644 index 0000000000..8b186c83e0 --- /dev/null +++ b/discord/types/monetization.py @@ -0,0 +1,62 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-2021 Rapptz +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from typing import Literal + +from .._typed_dict import NotRequired, TypedDict +from .snowflake import Snowflake + +SKUType = Literal[5, 6] +EntitlementType = Literal[8] +OwnerType = Literal[1, 2] + + +class SKU(TypedDict): + id: Snowflake + type: SKUType + application_id: Snowflake + name: str + slug: str + flags: int + + +class Entitlement(TypedDict): + id: Snowflake + sku_id: Snowflake + application_id: Snowflake + user_id: NotRequired[Snowflake] + type: EntitlementType + deleted: bool + starts_at: NotRequired[str] + ends_at: NotRequired[str] + guild_id: NotRequired[Snowflake] + + +class CreateTestEntitlementPayload(TypedDict): + sku_id: Snowflake + owner_id: Snowflake + owner_type: OwnerType diff --git a/discord/types/onboarding.py b/discord/types/onboarding.py new file mode 100644 index 0000000000..7bfa44dc13 --- /dev/null +++ b/discord/types/onboarding.py @@ -0,0 +1,64 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from typing import Literal, TypedDict + +from .._typed_dict import NotRequired +from .emoji import Emoji +from .snowflake import Snowflake, SnowflakeList + +PromptType = Literal[0, 1] +OnboardingMode = Literal[0, 1] + + +class Onboarding(TypedDict): + guild_id: Snowflake + prompts: list[OnboardingPrompt] + default_channel_ids: SnowflakeList + enabled: bool + mode: OnboardingMode + + +class OnboardingPrompt(TypedDict): + id: Snowflake + type: PromptType + options: list[PromptOption] + title: str + single_select: bool + required: bool + in_onboarding: bool + + +class PromptOption(TypedDict): + id: Snowflake + channel_ids: SnowflakeList + role_ids: SnowflakeList + emoji: NotRequired[Emoji] + emoji_id: NotRequired[Snowflake] + emoji_name: NotRequired[str] + emoji_animated: NotRequired[bool] + title: str + description: str | None diff --git a/discord/types/poll.py b/discord/types/poll.py new file mode 100644 index 0000000000..21e54431e6 --- /dev/null +++ b/discord/types/poll.py @@ -0,0 +1,63 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from typing import Literal, TypedDict + +from .._typed_dict import NotRequired +from .emoji import Emoji + +PollLayoutType = Literal[1] + + +class PollMedia(TypedDict): + text: str + emoji: NotRequired[Emoji] + + +class PollAnswer(TypedDict): + answer_id: int + poll_media: PollMedia + + +class PollResults(TypedDict): + is_finalized: bool + answer_counts: list[PollAnswerCount] + + +class PollAnswerCount(TypedDict): + id: int + count: int + me_voted: bool + + +class Poll(TypedDict): + question: PollMedia + answers: list[PollAnswer] + duration: NotRequired[int] + expiry: NotRequired[str] + allow_multiselect: bool + layout_type: NotRequired[PollLayoutType] + results: NotRequired[PollResults] diff --git a/discord/types/raw_models.py b/discord/types/raw_models.py index 44ac45363d..2d0698eca3 100644 --- a/discord/types/raw_models.py +++ b/discord/types/raw_models.py @@ -22,6 +22,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from __future__ import annotations from .._typed_dict import NotRequired, TypedDict @@ -130,6 +131,12 @@ class MemberRemoveEvent(TypedDict): user: User +class VoiceChannelStatusUpdateEvent(TypedDict): + id: Snowflake + guild_id: Snowflake + status: NotRequired[str] + + class ThreadMembersUpdateEvent(TypedDict): id: Snowflake guild_id: Snowflake @@ -147,3 +154,11 @@ class AuditLogEntryEvent(TypedDict): changes: NotRequired[list[dict]] reason: NotRequired[str] options: NotRequired[dict] + + +class MessagePollVoteEvent(TypedDict): + user_id: Snowflake + guild_id: NotRequired[Snowflake] + channel_id: Snowflake + message_id: Snowflake + answer_id: int diff --git a/discord/types/role.py b/discord/types/role.py index 8996e7e21d..d387d38b77 100644 --- a/discord/types/role.py +++ b/discord/types/role.py @@ -39,6 +39,7 @@ class Role(TypedDict): permissions: str managed: bool mentionable: bool + flags: int class RoleTags(TypedDict, total=False): diff --git a/discord/types/scheduled_events.py b/discord/types/scheduled_events.py index 85d77d7313..9bb4ad0328 100644 --- a/discord/types/scheduled_events.py +++ b/discord/types/scheduled_events.py @@ -60,5 +60,6 @@ class ScheduledEventEntityMetadata(TypedDict): class ScheduledEventSubscriber(TypedDict): guild_scheduled_event_id: Snowflake + user_id: Snowflake user: User member: Member | None diff --git a/discord/types/user.py b/discord/types/user.py index 6398fe1fc8..a0383e4f04 100644 --- a/discord/types/user.py +++ b/discord/types/user.py @@ -22,6 +22,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from __future__ import annotations from typing import Literal, TypedDict diff --git a/discord/types/voice.py b/discord/types/voice.py index 353bf1023d..4dc485cbf7 100644 --- a/discord/types/voice.py +++ b/discord/types/voice.py @@ -22,6 +22,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from __future__ import annotations from typing import Literal diff --git a/discord/types/webhook.py b/discord/types/webhook.py index b5db096e11..20fa0e900e 100644 --- a/discord/types/webhook.py +++ b/discord/types/webhook.py @@ -42,13 +42,6 @@ class SourceGuild(TypedDict): WebhookType = Literal[1, 2, 3] -class FollowerWebhook(TypedDict): - source_channel: NotRequired[PartialChannel] - source_guild: NotRequired[SourceGuild] - channel_id: Snowflake - webhook_id: Snowflake - - class PartialWebhook(TypedDict): guild_id: NotRequired[Snowflake] user: NotRequired[User] @@ -57,6 +50,13 @@ class PartialWebhook(TypedDict): type: WebhookType +class FollowerWebhook(PartialWebhook): + source_channel: NotRequired[PartialChannel] + source_guild: NotRequired[SourceGuild] + channel_id: Snowflake + webhook_id: Snowflake + + class Webhook(PartialWebhook): name: NotRequired[str | None] avatar: NotRequired[str | None] diff --git a/discord/types/widget.py b/discord/types/widget.py index daeef2db95..1ebeb959a3 100644 --- a/discord/types/widget.py +++ b/discord/types/widget.py @@ -22,6 +22,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from __future__ import annotations from .._typed_dict import NotRequired, TypedDict diff --git a/discord/ui/button.py b/discord/ui/button.py index c607de459c..5487fd6b73 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -67,6 +67,8 @@ class Button(Item[V]): The label of the button, if any. Maximum of 80 chars. emoji: Optional[Union[:class:`.PartialEmoji`, :class:`.Emoji`, :class:`str`]] The emoji of the button, if available. + sku_id: Optional[Union[:class:`int`]] + The ID of the SKU this button refers to. row: Optional[:class:`int`] The relative row this button belongs to. A Discord component can only have 5 rows. By default, items are arranged automatically into those 5 rows. If you'd @@ -81,6 +83,7 @@ class Button(Item[V]): "disabled", "label", "emoji", + "sku_id", "row", ) @@ -93,6 +96,7 @@ def __init__( custom_id: str | None = None, url: str | None = None, emoji: str | Emoji | PartialEmoji | None = None, + sku_id: int | None = None, row: int | None = None, ): super().__init__() @@ -102,6 +106,10 @@ def __init__( raise ValueError("custom_id must be 100 characters or fewer") if custom_id is not None and url is not None: raise TypeError("cannot mix both url and custom_id with Button") + if sku_id is not None and url is not None: + raise TypeError("cannot mix both url and sku_id with Button") + if custom_id is not None and sku_id is not None: + raise TypeError("cannot mix both sku_id and custom_id with Button") if not isinstance(custom_id, str) and custom_id is not None: raise TypeError( @@ -109,11 +117,13 @@ def __init__( ) self._provided_custom_id = custom_id is not None - if url is None and custom_id is None: + if url is None and custom_id is None and sku_id is None: custom_id = os.urandom(16).hex() if url is not None: style = ButtonStyle.link + if sku_id is not None: + style = ButtonStyle.premium if emoji is not None: if isinstance(emoji, str): @@ -134,6 +144,7 @@ def __init__( label=label, style=style, emoji=emoji, + sku_id=sku_id, ) self.row = row @@ -212,6 +223,20 @@ def emoji(self, value: str | Emoji | PartialEmoji | None): # type: ignore f" {value.__class__} instead" ) + @property + def sku_id(self) -> int | None: + """The ID of the SKU this button refers to.""" + return self._underlying.sku_id + + @sku_id.setter + def sku_id(self, value: int | None): # type: ignore + if value is None: + self._underlying.sku_id = None + elif isinstance(value, int): + self._underlying.sku_id = value + else: + raise TypeError(f"expected int or None, received {value.__class__} instead") + @classmethod def from_component(cls: type[B], button: ButtonComponent) -> B: return cls( @@ -221,6 +246,7 @@ def from_component(cls: type[B], button: ButtonComponent) -> B: custom_id=button.custom_id, url=button.url, emoji=button.emoji, + sku_id=button.sku_id, row=None, ) @@ -260,11 +286,10 @@ def button( .. note:: - Buttons with a URL cannot be created with this function. - Consider creating a :class:`Button` manually instead. - This is because buttons with a URL do not have a callback - associated with them since Discord does not do any processing - with it. + Premium and link buttons cannot be created with this decorator. Consider + creating a :class:`Button` object manually instead. These types of + buttons do not have a callback associated since Discord doesn't handle + them when clicked. Parameters ---------- diff --git a/discord/user.py b/discord/user.py index c3dee8eeb7..d986a018f5 100644 --- a/discord/user.py +++ b/discord/user.py @@ -32,16 +32,20 @@ from .asset import Asset from .colour import Colour from .flags import PublicUserFlags +from .iterators import EntitlementIterator +from .monetization import Entitlement from .utils import MISSING, _bytes_to_base64_data, snowflake_time if TYPE_CHECKING: from datetime import datetime + from .abc import Snowflake, SnowflakeTime from .channel import DMChannel from .guild import Guild from .message import Message from .state import ConnectionState from .types.channel import DMChannel as DMChannelPayload + from .types.user import PartialUser as PartialUserPayload from .types.user import User as UserPayload @@ -70,6 +74,7 @@ class BaseUser(_UserTag): "bot", "system", "_public_flags", + "_avatar_decoration", "_state", ) @@ -84,9 +89,12 @@ class BaseUser(_UserTag): _avatar: str | None _banner: str | None _accent_colour: int | None + _avatar_decoration: dict | None _public_flags: int - def __init__(self, *, state: ConnectionState, data: UserPayload) -> None: + def __init__( + self, *, state: ConnectionState, data: UserPayload | PartialUserPayload + ) -> None: self._state = state self._update(data) @@ -134,6 +142,7 @@ def _update(self, data: UserPayload) -> None: self._avatar = data["avatar"] self._banner = data.get("banner", None) self._accent_colour = data.get("accent_color", None) + self._avatar_decoration = data.get("avatar_decoration_data", None) self._public_flags = data.get("public_flags", 0) self.bot = data.get("bot", False) self.system = data.get("system", False) @@ -149,6 +158,7 @@ def _copy(cls: type[BU], user: BU) -> BU: self._avatar = user._avatar self._banner = user._banner self._accent_colour = user._accent_colour + self._avatar_decoration = user._avatar_decoration self.bot = user.bot self._state = user._state self._public_flags = user._public_flags @@ -221,6 +231,18 @@ def banner(self) -> Asset | None: return None return Asset._from_user_banner(self._state, self.id, self._banner) + @property + def avatar_decoration(self) -> Asset | None: + """Returns the user's avatar decoration, if available. + + .. versionadded:: 2.5 + """ + if self._avatar_decoration is None: + return None + return Asset._from_avatar_decoration( + self._state, self.id, self._avatar_decoration.get("asset") + ) + @property def accent_colour(self) -> Colour | None: """Returns the user's accent colour, if applicable. @@ -405,7 +427,11 @@ def _update(self, data: UserPayload) -> None: # TODO: Username might not be able to edit anymore. async def edit( - self, *, username: str = MISSING, avatar: bytes = MISSING + self, + *, + username: str = MISSING, + avatar: bytes = MISSING, + banner: bytes = MISSING, ) -> ClientUser: """|coro| @@ -413,16 +439,19 @@ async def edit( .. note:: - To upload an avatar, a :term:`py:bytes-like object` must be passed in that + To upload an avatar or banner, a :term:`py:bytes-like object` must be passed in that represents the image being uploaded. If this is done through a file then the file must be opened via ``open('some_filename', 'rb')`` and the :term:`py:bytes-like object` is given through the use of ``fp.read()``. - The only image formats supported for uploading is JPEG and PNG. + The only image formats supported for uploading are JPEG, PNG, and GIF. .. versionchanged:: 2.0 The edit is no longer in-place, instead the newly edited client user is returned. + .. versionchanged:: 2.6 + The ``banner`` keyword-only parameter was added. + Parameters ---------- username: :class:`str` @@ -430,6 +459,9 @@ async def edit( avatar: :class:`bytes` A :term:`py:bytes-like object` representing the image to upload. Could be ``None`` to denote no avatar. + banner: :class:`bytes` + A :term:`py:bytes-like object` representing the image to upload. + Could be ``None`` to denote no banner. Returns ------- @@ -441,7 +473,7 @@ async def edit( HTTPException Editing your profile failed. InvalidArgument - Wrong image format passed for ``avatar``. + Wrong image format passed for ``avatar`` or ``banner``. """ payload: dict[str, Any] = {} if username is not MISSING: @@ -452,6 +484,11 @@ async def edit( elif avatar is not MISSING: payload["avatar"] = _bytes_to_base64_data(avatar) + if banner is None: + payload["banner"] = None + elif banner is not MISSING: + payload["banner"] = _bytes_to_base64_data(banner) + data: UserPayload = await self._state.http.edit_profile(payload) return ClientUser(state=self._state, data=data) @@ -578,3 +615,80 @@ async def create_dm(self) -> DMChannel: state = self._state data: DMChannelPayload = await state.http.start_private_message(self.id) return state.add_dm_channel(data) + + async def create_test_entitlement(self, sku: discord.abc.Snowflake) -> Entitlement: + """|coro| + + Creates a test entitlement for the user. + + Parameters + ---------- + sku: :class:`Snowflake` + The SKU to create a test entitlement for. + + Returns + ------- + :class:`Entitlement` + The created entitlement. + """ + payload = { + "sku_id": sku.id, + "owner_id": self.id, + "owner_type": 2, + } + data = await self._state.http.create_test_entitlement(self.id, payload) + return Entitlement(data=data, state=self._state) + + def entitlements( + self, + skus: list[Snowflake] | None = None, + before: SnowflakeTime | None = None, + after: SnowflakeTime | None = None, + limit: int | None = 100, + exclude_ended: bool = False, + ) -> EntitlementIterator: + """Returns an :class:`.AsyncIterator` that enables fetching the user's entitlements. + + This is identical to :meth:`Client.entitlements` with the ``user`` parameter. + + .. versionadded:: 2.6 + + Parameters + ---------- + skus: list[:class:`.abc.Snowflake`] | None + Limit the fetched entitlements to entitlements that are for these SKUs. + before: :class:`.abc.Snowflake` | :class:`datetime.datetime` | None + Retrieves guilds before this date or object. + If a datetime is provided, it is recommended to use a UTC-aware datetime. + If the datetime is naive, it is assumed to be local time. + after: :class:`.abc.Snowflake` | :class:`datetime.datetime` | None + Retrieve guilds after this date or object. + If a datetime is provided, it is recommended to use a UTC-aware datetime. + If the datetime is naive, it is assumed to be local time. + limit: Optional[:class:`int`] + The number of entitlements to retrieve. + If ``None``, retrieves every entitlement, which may be slow. + Defaults to ``100``. + exclude_ended: :class:`bool` + Whether to limit the fetched entitlements to those that have not ended. + Defaults to ``False``. + + Yields + ------ + :class:`.Entitlement` + The application's entitlements. + + Raises + ------ + :exc:`HTTPException` + Retrieving the entitlements failed. + """ + return EntitlementIterator( + self._state, + sku_ids=[sku.id for sku in skus] if skus else None, + before=before, + after=after, + limit=limit, + user_id=self.id, + exclude_ended=exclude_ended, + ) diff --git a/discord/utils.py b/discord/utils.py index 7e41b6d04f..b95678a1c4 100644 --- a/discord/utils.py +++ b/discord/utils.py @@ -22,6 +22,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from __future__ import annotations import array @@ -172,12 +173,12 @@ def __init__(self, name: str, function: Callable[[T], T_co]) -> None: self.__doc__ = getattr(function, "__doc__") @overload - def __get__(self, instance: None, owner: type[T]) -> CachedSlotProperty[T, T_co]: - ... + def __get__( + self, instance: None, owner: type[T] + ) -> CachedSlotProperty[T, T_co]: ... @overload - def __get__(self, instance: T, owner: type[T]) -> T_co: - ... + def __get__(self, instance: T, owner: type[T]) -> T_co: ... def __get__(self, instance: T | None, owner: type[T]) -> Any: if instance is None: @@ -251,18 +252,15 @@ async def inner_call(): @overload -def parse_time(timestamp: None) -> None: - ... +def parse_time(timestamp: None) -> None: ... @overload -def parse_time(timestamp: str) -> datetime.datetime: - ... +def parse_time(timestamp: str) -> datetime.datetime: ... @overload -def parse_time(timestamp: str | None) -> datetime.datetime | None: - ... +def parse_time(timestamp: str | None) -> datetime.datetime | None: ... def parse_time(timestamp: str | None) -> datetime.datetime | None: @@ -298,6 +296,7 @@ def warn_deprecated( since: str | None = None, removed: str | None = None, reference: str | None = None, + stacklevel: int = 3, ) -> None: """Warn about a deprecated function, with the ability to specify details about the deprecation. Emits a DeprecationWarning. @@ -317,6 +316,8 @@ def warn_deprecated( reference: Optional[:class:`str`] A reference that explains the deprecation, typically a URL to a page such as a changelog entry or a GitHub issue/PR. + stacklevel: :class:`int` + The stacklevel kwarg passed to :func:`warnings.warn`. Defaults to 3. """ warnings.simplefilter("always", DeprecationWarning) # turn off filter message = f"{name} is deprecated" @@ -330,7 +331,7 @@ def warn_deprecated( if reference: message += f" See {reference} for more information." - warnings.warn(message, stacklevel=3, category=DeprecationWarning) + warnings.warn(message, stacklevel=stacklevel, category=DeprecationWarning) warnings.simplefilter("default", DeprecationWarning) # reset filter @@ -339,6 +340,7 @@ def deprecated( since: str | None = None, removed: str | None = None, reference: str | None = None, + stacklevel: int = 3, *, use_qualname: bool = True, ) -> Callable[[Callable[[P], T]], Callable[[P], T]]: @@ -358,6 +360,8 @@ def deprecated( reference: Optional[:class:`str`] A reference that explains the deprecation, typically a URL to a page such as a changelog entry or a GitHub issue/PR. + stacklevel: :class:`int` + The stacklevel kwarg passed to :func:`warnings.warn`. Defaults to 3. use_qualname: :class:`bool` Whether to use the qualified name of the function in the deprecation warning. If ``False``, the short name of the function will be used instead. For example, __qualname__ will display as ``Client.login`` while __name__ @@ -791,8 +795,7 @@ class SnowflakeList(array.array): if TYPE_CHECKING: - def __init__(self, data: Iterable[int], *, is_sorted: bool = False): - ... + def __init__(self, data: Iterable[int], *, is_sorted: bool = False): ... def __new__(cls, data: Iterable[int], *, is_sorted: bool = False): return array.array.__new__(cls, "Q", data if is_sorted else sorted(data)) # type: ignore @@ -1093,13 +1096,11 @@ async def _achunk(iterator: AsyncIterator[T], max_size: int) -> AsyncIterator[li @overload -def as_chunks(iterator: Iterator[T], max_size: int) -> Iterator[list[T]]: - ... +def as_chunks(iterator: Iterator[T], max_size: int) -> Iterator[list[T]]: ... @overload -def as_chunks(iterator: AsyncIterator[T], max_size: int) -> AsyncIterator[list[T]]: - ... +def as_chunks(iterator: AsyncIterator[T], max_size: int) -> AsyncIterator[list[T]]: ... def as_chunks(iterator: _Iter[T], max_size: int) -> _Iter[list[T]]: diff --git a/discord/voice_client.py b/discord/voice_client.py index 61bc0ad9d2..b9d4766883 100644 --- a/discord/voice_client.py +++ b/discord/voice_client.py @@ -532,14 +532,14 @@ async def disconnect(self, *, force: bool = False) -> None: if self.socket: self.socket.close() - async def move_to(self, channel: abc.Snowflake) -> None: + async def move_to(self, channel: abc.Connectable) -> None: """|coro| Moves you to a different voice channel. Parameters ---------- - channel: :class:`abc.Snowflake` + channel: :class:`abc.Connectable` The channel to move to. Must be a voice channel. """ await self.channel.guild.change_voice_state(channel=channel) @@ -630,8 +630,7 @@ def play( *, after: Callable[[Exception | None], Any] | None = None, wait_finish: Literal[False] = False, - ) -> None: - ... + ) -> None: ... @overload def play( @@ -640,8 +639,7 @@ def play( *, after: Callable[[Exception | None], Any] | None = None, wait_finish: Literal[True], - ) -> asyncio.Future: - ... + ) -> asyncio.Future: ... def play( self, diff --git a/discord/webhook/async_.py b/discord/webhook/async_.py index 9c44c64d94..6d8f7b35c5 100644 --- a/discord/webhook/async_.py +++ b/discord/webhook/async_.py @@ -74,8 +74,10 @@ from ..guild import Guild from ..http import Response from ..mentions import AllowedMentions + from ..poll import Poll from ..state import ConnectionState from ..types.message import Message as MessagePayload + from ..types.webhook import FollowerWebhook as FollowerWebhookPayload from ..types.webhook import Webhook as WebhookPayload from ..ui.view import View @@ -401,7 +403,7 @@ def edit_webhook_message( payload: dict[str, Any] | None = None, multipart: list[dict[str, Any]] | None = None, files: list[File] | None = None, - ) -> Response[Message]: + ) -> Response[WebhookMessage]: params = {} if thread_id: @@ -460,7 +462,7 @@ def fetch_webhook( session: aiohttp.ClientSession, proxy: str | None = None, proxy_auth: aiohttp.BasicAuth | None = None, - ) -> Response[WebhookPayload]: + ) -> Response[WebhookPayload | FollowerWebhookPayload]: route = Route("GET", "/webhooks/{webhook_id}", webhook_id=webhook_id) return self.request( route, session=session, proxy=proxy, proxy_auth=proxy_auth, auth_token=token @@ -474,7 +476,7 @@ def fetch_webhook_with_token( session: aiohttp.ClientSession, proxy: str | None = None, proxy_auth: aiohttp.BasicAuth | None = None, - ) -> Response[WebhookPayload]: + ) -> Response[WebhookPayload | FollowerWebhookPayload]: route = Route( "GET", "/webhooks/{webhook_id}/{webhook_token}", @@ -621,6 +623,7 @@ def handle_message_parameters( embed: Embed | None = MISSING, embeds: list[Embed] = MISSING, view: View | None = MISSING, + poll: Poll | None = MISSING, applied_tags: list[Snowflake] = MISSING, allowed_mentions: AllowedMentions | None = MISSING, previous_allowed_mentions: AllowedMentions | None = None, @@ -646,6 +649,8 @@ def handle_message_parameters( if view is not MISSING: payload["components"] = view.to_components() if view is not None else [] + if poll is not MISSING: + payload["poll"] = poll.to_dict() payload["tts"] = tts if avatar_url: payload["avatar_url"] = str(avatar_url) @@ -985,7 +990,7 @@ class BaseWebhook(Hashable): def __init__( self, - data: WebhookPayload, + data: WebhookPayload | FollowerWebhookPayload, token: str | None = None, state: ConnectionState | None = None, ): @@ -995,7 +1000,7 @@ def __init__( ) self._update(data) - def _update(self, data: WebhookPayload): + def _update(self, data: WebhookPayload | FollowerWebhookPayload): self.id = int(data["id"]) self.type = try_enum(WebhookType, int(data["type"])) self.channel_id = utils._get_as_snowflake(data, "channel_id") @@ -1154,7 +1159,7 @@ async def foo(): def __init__( self, - data: WebhookPayload, + data: WebhookPayload | FollowerWebhookPayload, session: aiohttp.ClientSession, proxy: str | None = None, proxy_auth: aiohttp.BasicAuth | None = None, @@ -1568,13 +1573,13 @@ async def send( embeds: list[Embed] = MISSING, allowed_mentions: AllowedMentions = MISSING, view: View = MISSING, + poll: Poll = MISSING, thread: Snowflake = MISSING, thread_name: str | None = None, applied_tags: list[Snowflake] = MISSING, wait: Literal[True], delete_after: float = None, - ) -> WebhookMessage: - ... + ) -> WebhookMessage: ... @overload async def send( @@ -1591,13 +1596,13 @@ async def send( embeds: list[Embed] = MISSING, allowed_mentions: AllowedMentions = MISSING, view: View = MISSING, + poll: Poll = MISSING, thread: Snowflake = MISSING, thread_name: str | None = None, applied_tags: list[Snowflake] = MISSING, wait: Literal[False] = ..., delete_after: float = None, - ) -> None: - ... + ) -> None: ... async def send( self, @@ -1613,6 +1618,7 @@ async def send( embeds: list[Embed] = MISSING, allowed_mentions: AllowedMentions = MISSING, view: View = MISSING, + poll: Poll = MISSING, thread: Snowflake = MISSING, thread_name: str | None = None, applied_tags: list[Snowflake] = MISSING, @@ -1694,6 +1700,10 @@ async def send( delete_after: :class:`float` If provided, the number of seconds to wait in the background before deleting the message we just sent. + poll: :class:`Poll` + The poll to send. + + .. versionadded:: 2.6 Returns ------- @@ -1753,6 +1763,9 @@ async def send( if ephemeral is True and view.timeout is None: view.timeout = 15 * 60.0 + if poll is None: + poll = MISSING + params = handle_message_parameters( content=content, username=username, @@ -1764,6 +1777,7 @@ async def send( embeds=embeds, ephemeral=ephemeral, view=view, + poll=poll, applied_tags=applied_tags, allowed_mentions=allowed_mentions, previous_allowed_mentions=previous_mentions, @@ -1855,7 +1869,7 @@ async def fetch_message( thread_id=thread_id, ) msg = self._create_message(data) - if isinstance(msg.channel, PartialMessageable): + if thread_id and isinstance(msg.channel, PartialMessageable): msg._thread_id = thread_id return msg diff --git a/discord/webhook/sync.py b/discord/webhook/sync.py index 9e812b5709..d2d3213d71 100644 --- a/discord/webhook/sync.py +++ b/discord/webhook/sync.py @@ -940,8 +940,7 @@ def send( thread: Snowflake = MISSING, thread_name: str | None = None, wait: Literal[True], - ) -> SyncWebhookMessage: - ... + ) -> SyncWebhookMessage: ... @overload def send( @@ -960,8 +959,7 @@ def send( thread_name: str | None = None, wait: Literal[False] = ..., suppress: bool = MISSING, - ) -> None: - ... + ) -> None: ... def send( self, @@ -1147,7 +1145,7 @@ def fetch_message( thread_id=thread_id, ) msg = self._create_message(data) - if isinstance(msg.channel, PartialMessageable): + if thread_id and isinstance(msg.channel, PartialMessageable): msg._thread_id = thread_id return msg diff --git a/discord/welcome_screen.py b/discord/welcome_screen.py index ae0a98da08..7e709ab7a6 100644 --- a/discord/welcome_screen.py +++ b/discord/welcome_screen.py @@ -157,12 +157,10 @@ async def edit( welcome_channels: list[WelcomeScreenChannel] | None = ..., enabled: bool | None = ..., reason: str | None = ..., - ) -> None: - ... + ) -> None: ... @overload - async def edit(self) -> None: - ... + async def edit(self) -> None: ... async def edit(self, **options): """|coro| diff --git a/docs/api/audit_logs.rst b/docs/api/audit_logs.rst index f974e9602b..08c27ee640 100644 --- a/docs/api/audit_logs.rst +++ b/docs/api/audit_logs.rst @@ -516,5 +516,13 @@ this goal, it must make use of a couple of data classes that aid in this goal. :type: :class:`int` + .. attribute:: status + + The voice channel status of a :class:`VoiceChannel`. + + See also :attr:`VoiceChannel.status`. + + :type: :class:`str` + .. this is currently missing the following keys: reason and application_id I'm not sure how to about porting these diff --git a/docs/api/data_classes.rst b/docs/api/data_classes.rst index ab9e8d678d..1d891b90cd 100644 --- a/docs/api/data_classes.rst +++ b/docs/api/data_classes.rst @@ -49,6 +49,11 @@ Message .. autoclass:: MessageReference :members: +.. attributetable:: MessageCall + +.. autoclass:: MessageCall + :members: + .. attributetable:: PartialMessage .. autoclass:: PartialMessage @@ -93,6 +98,34 @@ Embed .. autoclass:: EmbedProvider :members: +Poll +~~~~~ + +.. attributetable:: Poll + +.. autoclass:: Poll + :members: + +.. attributetable:: PollMedia + +.. autoclass:: PollMedia + :members: + +.. attributetable:: PollAnswer + +.. autoclass:: PollAnswer + :members: + +.. attributetable:: PollAnswerCount + +.. autoclass:: PollAnswerCount + :members: + +.. attributetable:: PollResults + +.. autoclass:: PollResults + :members: + Flags @@ -118,6 +151,11 @@ Flags .. autoclass:: MessageFlags() :members: +.. attributetable:: AttachmentFlags + +.. autoclass:: AttachmentFlags() + :members: + .. attributetable:: PublicUserFlags .. autoclass:: PublicUserFlags() @@ -128,6 +166,21 @@ Flags .. autoclass:: ChannelFlags() :members: +.. attributetable:: SKUFlags + +.. autoclass:: SKUFlags() + :members: + +.. attributetable:: MemberFlags + +.. autoclass:: MemberFlags() + :members: + +.. attributetable:: RoleFlags + +.. autoclass:: RoleFlags() + :members: + Colour ------ diff --git a/docs/api/enums.rst b/docs/api/enums.rst index 45317a37ce..3f8e8f6e5f 100644 --- a/docs/api/enums.rst +++ b/docs/api/enums.rst @@ -29,7 +29,12 @@ of :class:`enum.Enum`. A string. .. attribute:: integer - An integer. + An integer between -2⁵³ and 2⁵³. + + .. note:: + + IDs, such as 881224361015672863, are often too big for this input type. + .. attribute:: boolean A boolean. @@ -47,7 +52,11 @@ of :class:`enum.Enum`. A mentionable (user or role). .. attribute:: number - A floating number. + A floating-point number between -2⁵³ and 2⁵³. + + .. note:: + + IDs, such as 881224361015672863, are often too big for this input type. .. attribute:: attachment @@ -518,6 +527,9 @@ of :class:`enum.Enum`. .. attribute:: link Represents a link button. + .. attribute:: premium + + Represents a premium button. .. attribute:: blurple @@ -1592,6 +1604,34 @@ of :class:`enum.Enum`. .. versionadded:: 2.5 + .. attribute:: voice_channel_status_update + + A voice channel status was updated. + + When this is the action, the type of :attr:`~AuditLogEntry.target` is + the :class:`VoiceChannel` or :class:`Object` with the ID of the voice + channel which was updated. + + Possible attributes for :class:`AuditLogDiff`: + + - :attr:`~AuditLogDiff.status` + + .. versionadded:: 2.5 + + .. attribute:: voice_channel_status_delete + + A voice channel status was deleted. + + When this is the action, the type of :attr:`~AuditLogEntry.target` is + the :class:`VoiceChannel` or :class:`Object` with the ID of the voice + channel which was updated. + + Possible attributes for :class:`AuditLogDiff`: + + - :attr:`~AuditLogDiff.status` + + .. versionadded:: 2.5 + .. class:: AuditLogActionCategory @@ -2293,6 +2333,34 @@ of :class:`enum.Enum`. Represents the slurs keyword preset rule. +.. class:: PromptType + + Represents how each prompt's options are displayed. + + .. versionadded:: 2.5 + + .. attribute:: multiple_choice + + The options will appear in a grid form, showing the name and description. + + .. attribute:: dropdown + + The options will appear in a dropdown (similar to select menus), but without the description displayed. This is **enforced** if there are more than 12 options in the prompt. + +.. class:: OnboardingMode + + Represents the current mode of the guild's onboarding flow. + + .. versionadded:: 2.5 + + .. attribute:: default + + Only default channels are counted towards the Onboarding requirements. + + .. attribute:: advanced + + Both default channels and questions (``OnboardingPrompt``s) will count towards the Onboarding requirements. + .. class:: ReactionType Represents a Reaction's type. @@ -2306,3 +2374,126 @@ of :class:`enum.Enum`. .. attribute:: burst Represents a super reaction. + +.. class:: SKUType + + Represents an SKU's type. + + .. versionadded:: 2.5 + + .. attribute:: durable + + A one-time purchase that is permanent and is not subject to either renewal + or consumption, such as lifetime access to an app's premium features. + + .. attribute:: consumable + + A one-time, non-renewable purchase that provides access, such as a temporary + power-up or boost in a game. + + .. attribute:: subscription + + Represents a recurring subscription. + + .. attribute:: subscription_group + + A system-generated group for each subscription SKU created. These types of SKUs are currently unused. + + +.. class:: EntitlementType + + Represents an entitlement's type. + + .. versionadded:: 2.5 + + .. attribute:: purchase + + Entitlement was purchased by the user. + + .. attribute:: premium_subscription + + Entitlement is for a Discord Nitro subscription. + + .. attribute:: developer_gift + + Entitlement was gifted by the developer. + + .. attribute:: test_mode_purchase + + Entitlement was purchased by a developer in the application's test mode. + + .. attribute:: free_purchase + + Entitlement was granted when the SKU was free. + + .. attribute:: user_gift + + Entitlement was gifted by another user. + + .. attribute:: premium_purchase + + Entitlement was claimed by a user for free as a Nitro subscriber. + + .. attribute:: application_subscription + + Entitlement was purchased as an app subscription. + + +.. class:: EntitlementOwnerType + + Represents an entitlement's ownership type. + + .. versionadded:: 2.5 + + .. attribute:: guild + + Entitlement is owned by a guild. + + .. attribute:: user + + Entitlement is owned by a user. + + +.. class:: PollLayoutType + + Represents a poll's layout type. + + .. versionadded:: 2.6 + + .. attribute:: default + + Represents the default layout. + + +.. class:: IntegrationType + + The integration type for an application. + + .. versionadded:: 2.6 + + .. attribute:: guild_install + + The integration is added to a guild. + + .. attribute:: user_install + + The integration is added to a user account. + + +.. class:: InteractionContextType + + The context where an interaction occurs. + + .. versionadded:: 2.6 + + .. attribute:: guild + + The interaction is in a guild. + + .. attribute:: bot_dm + + The interaction is in the bot's own DM channel with the user. + + .. attribute:: private_channel + + The interaction is in a private DM or group DM channel. diff --git a/docs/api/events.rst b/docs/api/events.rst index 2eb92c19b5..1b011fa1f4 100644 --- a/docs/api/events.rst +++ b/docs/api/events.rst @@ -410,6 +410,42 @@ Connection WebSocket library. It can be :class:`bytes` to denote a binary message or :class:`str` to denote a regular text message. +Entitlements +------------ +.. function:: on_entitlement_create(entitlement) + + Called when a user subscribes to an SKU. + + .. versionadded:: 2.5 + + :param entitlement: The entitlement that was created as a result of the subscription. + :type entitlement: :class:`Entitlement` + +.. function:: on_entitlement_update(entitlement) + + Called when a user's subscription to an Entitlement is renewed for the next billing period. + + .. versionadded:: 2.5 + + :param entitlement: The entitlement that was updated. + :type entitlement: :class:`Entitlement` + +.. function:: on_entitlement_delete(entitlement) + + Called when a user's entitlement is deleted. + + Entitlements are usually only deleted when Discord issues a refund for a subscription, + or manually removes an entitlement from a user. + + .. note:: + + This is not called when a user's subscription is cancelled. + + .. versionadded:: 2.5 + + :param entitlement: The entitlement that was deleted. + :type entitlement: :class:`Entitlement` + Guilds ------ .. function:: on_guild_join(guild) @@ -841,6 +877,7 @@ Messages - The message's embeds were suppressed or unsuppressed. - A call message has received an update to its participants or ending time. + - A poll has ended and the results have been finalized. This requires :attr:`Intents.messages` to be enabled. @@ -873,6 +910,58 @@ Messages :param payload: The raw event payload data. :type payload: :class:`RawMessageUpdateEvent` +Polls +~~~~~~~~~ +.. function:: on_poll_vote_add(poll, user, answer) + + Called when a vote is cast on a poll. If multiple answers were selected, this fires multiple times. + if the poll was not found in the internal poll cache, then this + event will not be called. Consider using :func:`on_raw_poll_vote_add` instead. + + This requires :attr:`Intents.polls` to be enabled. + + :param poll: The current state of the poll. + :type poll: :class:`Poll` + :param user: The user who added the vote. + :type user: Union[:class:`Member`, :class:`User`] + :param answer: The answer that was voted. + :type answer: :class:`PollAnswer` + +.. function:: on_raw_poll_vote_add(payload) + + Called when a vote is cast on a poll. Unlike :func:`on_poll_vote_add`, this is + called regardless of the state of the internal poll cache. + + This requires :attr:`Intents.polls` to be enabled. + + :param payload: The raw event payload data. + :type payload: :class:`RawMessagePollVoteEvent` + +.. function:: on_poll_vote_remove(message, user, answer) + + Called when a vote is removed from a poll. If multiple answers were removed, this fires multiple times. + if the poll is not found in the internal poll cache, then this + event will not be called. Consider using :func:`on_raw_poll_vote_remove` instead. + + This requires :attr:`Intents.polls` to be enabled. + + :param poll: The current state of the poll. + :type poll: :class:`Poll` + :param user: The user who removed the vote. + :type user: Union[:class:`Member`, :class:`User`] + :param answer: The answer that was voted. + :type answer: :class:`PollAnswer` + +.. function:: on_raw_poll_vote_remove(payload) + + Called when a vote is removed from a poll. Unlike :func:`on_poll_vote_remove`, this is + called regardless of the state of the internal message cache. + + This requires :attr:`Intents.polls` to be enabled. + + :param payload: The raw event payload data. + :type payload: :class:`RawMessagePollVoteEvent` + Reactions ~~~~~~~~~ .. function:: on_reaction_add(reaction, user) @@ -1263,3 +1352,28 @@ Typing :param payload: The raw typing payload. :type payload: :class:`RawTypingEvent` + + +Voice Channel Status Update +--------------------------- +.. function:: on_voice_channel_status_update(channel, before, after) + + Called when someone updates a voice channel status. + + .. versionadded:: 2.5 + + :param channel: The channel where the voice channel status update originated from. + :type channel: :class:`abc.GuildChannel` + :param before: The old voice channel status. + :type before: Optional[:class:`str`] + :param after: The new voice channel status. + :type after: Optional[:class:`str`] + +.. function:: on_raw_voice_channel_status_update(payload) + + Called when someone updates a voice channels status. + + .. versionadded:: 2.5 + + :param payload: The raw voice channel status update payload. + :type payload: :class:`RawVoiceChannelStatusUpdateEvent` diff --git a/docs/api/models.rst b/docs/api/models.rst index 4d11fed63e..bea6b3c0de 100644 --- a/docs/api/models.rst +++ b/docs/api/models.rst @@ -101,6 +101,19 @@ Messages .. autoclass:: ReactionCountDetails() :members: +Monetization +------------ + +.. attributetable:: SKU + +.. autoclass:: SKU() + :members: + +.. attributetable:: Entitlement + +.. autoclass:: Entitlement() + :members: + Guild ----- @@ -227,6 +240,24 @@ Welcome Screen .. autoclass:: WelcomeScreenChannel() :members: +Onboarding +~~~~~~~~~~~~~~ + +.. attributetable:: Onboarding + +.. autoclass:: Onboarding() + :members: + +.. attributetable:: OnboardingPrompt + +.. autoclass:: OnboardingPrompt() + :members: + +.. attributetable:: PromptOption + +.. autoclass:: PromptOption() + :members: + Integration ~~~~~~~~~~~ @@ -322,6 +353,16 @@ Interactions .. autoclass:: MessageInteraction() :members: +.. attributetable:: InteractionMetadata + +.. autoclass:: InteractionMetadata() + :members: + +.. attributetable:: AuthorizingIntegrationOwners + +.. autoclass:: AuthorizingIntegrationOwners() + :members: + .. attributetable:: Component .. autoclass:: Component() @@ -525,6 +566,11 @@ Events .. autoclass:: RawAuditLogEntryEvent() :members: +.. attributetable:: RawVoiceChannelStatusUpdateEvent + +.. autoclass:: RawVoiceChannelStatusUpdateEvent() + :members: + Webhooks diff --git a/docs/ext/bridge/api.rst b/docs/ext/bridge/api.rst index d8f4d78821..75dfdb51de 100644 --- a/docs/ext/bridge/api.rst +++ b/docs/ext/bridge/api.rst @@ -157,3 +157,14 @@ BridgeContext Subclasses .. data:: discord.ext.bridge.Context Alias of :data:`typing.Union` [ :class:`.BridgeExtContext`, :class:`.BridgeApplicationContext` ] for typing convenience. + +Option +------ + +BridgeOption +~~~~~~~~~~~~ + +.. attributetable:: discord.ext.bridge.BridgeOption + +.. autoclass:: discord.ext.bridge.BridgeOption + :members: diff --git a/docs/ext/commands/commands.rst b/docs/ext/commands/commands.rst index 793c3a64e4..c907e2a1b4 100644 --- a/docs/ext/commands/commands.rst +++ b/docs/ext/commands/commands.rst @@ -573,11 +573,10 @@ When mixed with the :data:`typing.Optional` converter you can provide simple and @bot.command() async def ban(ctx, members: commands.Greedy[discord.Member], - delete_days: typing.Optional[int] = 0, *, + delete_seconds: typing.Optional[int] = 0, *, reason: str): - """Mass bans members with an optional delete_days parameter""" - for member in members: - await member.ban(delete_message_days=delete_days, reason=reason) + """Bulk bans members with an optional delete_seconds parameter""" + await ctx.guild.bulk_ban(*members, delete_message_seconds=delete_seconds, reason=reason) This command can be invoked any of the following ways: @@ -707,7 +706,7 @@ For example, augmenting the example above: @commands.command() async def ban(ctx, *, flags: BanFlags): for member in flags.members: - await member.ban(reason=flags.reason, delete_message_days=flags.days) + await member.ban(reason=flags.reason, delete_message_seconds=flags.days * 60 * 24) members = ', '.join(str(member) for member in flags.members) plural = f'{flags.days} days' if flags.days != 1 else f'{flags.days} day' diff --git a/examples/app_commands/slash_users.py b/examples/app_commands/slash_users.py new file mode 100644 index 0000000000..73c32cfbad --- /dev/null +++ b/examples/app_commands/slash_users.py @@ -0,0 +1,33 @@ +import discord + +# debug_guilds must not be set if we want to set contexts and integration_types on commands +bot = discord.Bot() + + +@bot.slash_command( + # Can only be used in private messages + contexts={discord.InteractionContextType.private_channel}, + # Can only be used if the bot is installed to your user account, + # if left blank it can only be used when added to guilds + integration_types={discord.IntegrationType.user_install}, +) +async def greet(ctx: discord.ApplicationContext, user: discord.User): + await ctx.respond(f"Hello, {user}!") + + +@bot.slash_command( + # This command can be used by guild members, but also by users anywhere if they install it + integration_types={ + discord.IntegrationType.guild_install, + discord.IntegrationType.user_install, + }, +) +async def say_hello(ctx: discord.ApplicationContext): + await ctx.respond("Hello!") + + +# If a bot is not installed to a guild and the channel has the `USE_EXTERNAL_APPS` +# permission disabled, the response will always be ephemeral. + + +bot.run("TOKEN") diff --git a/requirements/dev.txt b/requirements/dev.txt index d763866426..9fc5d19119 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,11 +1,11 @@ -r _.txt -pylint~=3.0.3 -pytest~=7.4.4 +pylint~=3.2.5 +pytest~=8.2.2 pytest-asyncio~=0.23.3 # pytest-order~=1.0.1 -mypy~=1.8.0 -coverage~=7.4 +mypy~=1.10.1 +coverage~=7.5 pre-commit==3.5.0 -codespell==2.2.6 -bandit==1.7.7 -flake8==7.0.0 +codespell==2.3.0 +bandit==1.7.9 +flake8==7.1.0 diff --git a/requirements/docs.txt b/requirements/docs.txt index 605916439c..d309bbdf31 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -4,5 +4,5 @@ sphinxcontrib-websupport==1.2.4 myst-parser==1.0.0 sphinxext-opengraph==0.9.1 sphinx-copybutton==0.5.2 -furo@ git+https://github.com/pradyunsg/furo@193643f +furo==2023.3.23 sphinx-autodoc-typehints==1.23.0 diff --git a/tests/helpers.py b/tests/helpers.py index 843ddf078e..8b62271a5a 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -22,6 +22,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from typing import TypeVar V = TypeVar("V") diff --git a/tests/test_utils.py b/tests/test_utils.py index bf8dc63b99..d0f94acb93 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -22,6 +22,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + # mypy: implicit-reexport=True from typing import TypeVar