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 6f4ba8a5b6..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.10-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 9719862ee2..ab0e6556f8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,7 +19,7 @@ repos: # - --remove-duplicate-keys # - --remove-unused-variables - repo: https://github.com/asottile/pyupgrade - rev: v3.15.2 + 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: 24.4.0 + rev: 24.4.2 hooks: - id: black args: [--safe, --quiet] diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c6e30c8a5..32b1ae5f9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,8 +20,16 @@ These changes are available on the `master` branch, but have not yet been releas ([#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) ### Fixed @@ -42,6 +50,16 @@ These changes are available on the `master` branch, but have not yet been releas ([#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 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)) ### Changed @@ -55,6 +73,17 @@ These changes are available on the `master` branch, but have not yet been releas ([#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) + +### 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)) ## [2.5.0] - 2024-03-02 diff --git a/discord/__init__.py b/discord/__init__.py index 5564eebe77..d6031ce3ac 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -58,6 +58,7 @@ 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/abc.py b/discord/abc.py index dc2237151d..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, @@ -1345,12 +1346,13 @@ 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: ... @@ -1365,12 +1367,13 @@ 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: ... @@ -1385,12 +1388,13 @@ 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: ... @@ -1405,12 +1409,13 @@ 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: ... @@ -1432,6 +1437,7 @@ async def send( reference=None, mention_author=None, view=None, + poll=None, suppress=None, silent=None, ): @@ -1465,7 +1471,7 @@ 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`] @@ -1515,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 ------- @@ -1594,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()") @@ -1616,6 +1629,7 @@ async def send( stickers=stickers, components=components, flags=flags, + poll=poll, ) finally: file.close() @@ -1643,6 +1657,7 @@ async def send( stickers=stickers, components=components, flags=flags, + poll=poll, ) finally: for f in files: @@ -1661,6 +1676,7 @@ async def send( stickers=stickers, components=components, flags=flags, + poll=poll, ) ret = state.create_message(channel=channel, data=data) diff --git a/discord/audit_logs.py b/discord/audit_logs.py index 297635038b..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: @@ -309,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 [ @@ -318,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/bot.py b/discord/bot.py index 7d561b52cc..19a1ca9bdc 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 @@ -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: @@ -271,7 +278,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 +293,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: @@ -1157,6 +1165,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.") @@ -1167,6 +1190,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 = [] @@ -1447,6 +1484,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 2586e3eed7..77c733194f 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -1218,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` diff --git a/discord/client.py b/discord/client.py index 5d120f38d5..d46964042f 100644 --- a/discord/client.py +++ b/discord/client.py @@ -70,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",) @@ -338,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. @@ -1010,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'. diff --git a/discord/commands/context.py b/discord/commands/context.py index dda6efa539..27c3b0acba 100644 --- a/discord/commands/context.py +++ b/discord/commands/context.py @@ -37,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 @@ -211,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 6f34c0c9d9..d89ea0a5b2 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) @@ -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,6 +716,12 @@ 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 @@ -881,9 +949,6 @@ 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 @@ -892,6 +957,10 @@ def to_dict(self) -> dict: 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 async def _invoke(self, ctx: ApplicationContext) -> None: @@ -1100,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. @@ -1118,6 +1190,12 @@ 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] @@ -1177,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 ) @@ -1218,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, @@ -1232,9 +1348,6 @@ 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 @@ -1243,9 +1356,13 @@ def to_dict(self) -> dict: 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 @@ -1476,6 +1593,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. @@ -1496,6 +1616,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: @@ -1575,8 +1699,9 @@ 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 @@ -1729,20 +1854,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 38cac6b539..2ca8c5a39a 100644 --- a/discord/commands/options.py +++ b/discord/commands/options.py @@ -330,6 +330,9 @@ def __init__( "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, 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/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 98e46e646d..1446e1af7d 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -74,6 +74,8 @@ "SKUType", "EntitlementType", "EntitlementOwnerType", + "IntegrationType", + "InteractionContextType", ) @@ -1020,6 +1022,27 @@ class EntitlementOwnerType(Enum): 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/core.py b/discord/ext/bridge/core.py index c14d9db95a..384e9ab37a 100644 --- a/discord/ext/bridge/core.py +++ b/discord/ext/bridge/core.py @@ -96,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) @@ -653,13 +675,3 @@ def decorator(func): return func return decorator - - -discord.commands.options.Option = BridgeOption -discord.Option = BridgeOption -warn_deprecated( - "Option", - "BridgeOption", - "2.5", - reference="https://github.com/Pycord-Development/pycord/pull/2417", -) 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/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 e3eca0cf4d..d3f2d32800 100644 --- a/discord/ext/pages/pagination.py +++ b/discord/ext/pages/pagination.py @@ -27,8 +27,10 @@ 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 @@ -103,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: @@ -656,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: @@ -680,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) diff --git a/discord/flags.py b/discord/flags.py index 3201e5be89..37fd8baa24 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -885,6 +885,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: @@ -917,6 +919,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: @@ -931,6 +935,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 `. """ @@ -955,6 +960,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: @@ -1079,6 +1086,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. @@ -1137,6 +1145,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): diff --git a/discord/gateway.py b/discord/gateway.py index 1219c48831..7a765405af 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -603,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 a2ece66b68..5c9892337b 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -32,7 +32,6 @@ Any, ClassVar, List, - Literal, NamedTuple, Optional, Sequence, @@ -108,7 +107,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] @@ -648,8 +647,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 @@ -3076,7 +3075,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| @@ -3096,9 +3094,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. @@ -3109,11 +3104,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 ): @@ -3121,9 +3172,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| diff --git a/discord/http.py b/discord/http.py index e79d6120b7..85ea7c002c 100644 --- a/discord/http.py +++ b/discord/http.py @@ -71,6 +71,7 @@ message, monetization, onboarding, + poll, role, scheduled_events, sticker, @@ -464,13 +465,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 = {} @@ -508,6 +510,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]: @@ -524,13 +529,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 = [] @@ -555,6 +561,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"}) @@ -587,13 +595,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( @@ -610,6 +619,7 @@ def send_files( stickers=stickers, components=components, flags=flags, + poll=poll, ) def edit_multipart_helper( @@ -907,7 +917,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( @@ -920,17 +929,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]: @@ -1192,7 +1213,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, @@ -2140,8 +2161,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, @@ -2992,6 +3013,43 @@ def edit_onboarding( 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 1362acfac4..5725796d06 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -30,7 +30,12 @@ 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 @@ -53,6 +58,8 @@ "InteractionMessage", "InteractionResponse", "MessageInteraction", + "InteractionMetadata", + "AuthorizingIntegrationOwners", ) if TYPE_CHECKING: @@ -71,10 +78,12 @@ from .commands import OptionChoice from .embeds import Embed 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 @@ -98,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 @@ -131,6 +140,14 @@ class Interaction: The guilds preferred locale, if invoked in a guild. custom_id: Optional[:class:`str`] The custom ID for the interaction. + 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, ...] = ( @@ -149,6 +166,8 @@ class Interaction: "version", "custom_id", "entitlements", + "context", + "authorizing_integration_owners", "_channel_data", "_message_data", "_guild_data", @@ -188,6 +207,18 @@ def _from_data(self, data: InteractionPayload): 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 @@ -803,6 +834,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| @@ -837,6 +869,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 ------- @@ -881,6 +917,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: @@ -1221,7 +1260,7 @@ async def premium_required(self) -> Interaction: self._responded = True return self._parent - async def _locked_response(self, coro: Coroutine[Any]): + async def _locked_response(self, coro: Coroutine[Any, Any, Any]) -> None: """|coro| Wraps a response and makes sure that it's locked while executing. @@ -1393,6 +1432,10 @@ class MessageInteraction: .. versionadded:: 2.0 + .. deprecated:: 2.6 + + See :class:`InteractionMetadata`. + .. note:: Responses to message components do not include this property. @@ -1419,3 +1462,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 1537dc8605..438c41d2ef 100644 --- a/discord/iterators.py +++ b/discord/iterators.py @@ -237,6 +237,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. diff --git a/discord/member.py b/discord/member.py index fecb9b9dfa..57c04b2936 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 @@ -62,6 +62,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 +126,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) @@ -684,7 +692,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 +702,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: diff --git a/discord/message.py b/discord/message.py index 5f4d3bc994..109bef02c8 100644 --- a/discord/message.py +++ b/discord/message.py @@ -53,6 +53,7 @@ from .member import Member from .mixins import Hashable from .partial_emoji import PartialEmoji +from .poll import Poll from .reaction import Reaction from .sticker import StickerItem from .threads import Thread @@ -67,6 +68,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 @@ -80,6 +82,7 @@ from .types.message import MessageApplication as MessageApplicationPayload from .types.message import MessageReference as MessageReferencePayload from .types.message import Reaction as ReactionPayload + from .types.poll import Poll as PollPayload from .types.threads import ThreadArchiveDuration from .types.user import User as UserPayload from .ui.view import View @@ -240,14 +243,14 @@ def __init__(self, *, data: AttachmentPayload, state: ConnectionState): setattr(self, attr, value) @property - def expires_at(self) -> datetime.datetime: + 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: + def issued_at(self) -> datetime.datetime | None: """The attachment URL's issue time in UTC.""" if not self._is: return None @@ -722,10 +725,22 @@ 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 """ __slots__ = ( @@ -759,8 +774,10 @@ class Message(Hashable): "stickers", "components", "guild", - "interaction", + "_interaction", + "interaction_metadata", "thread", + "_poll", ) if TYPE_CHECKING: @@ -840,13 +857,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 + try: + 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_metadata = None - self.interaction: MessageInteraction | None + self._poll: Poll | None try: - self.interaction = MessageInteraction(data=data["interaction"], state=state) + self._poll = Poll.from_dict(data["poll"], self) + self._state.store_poll(self._poll, self.id) except KeyError: - self.interaction = None + self._poll = None self.thread: Thread | None try: @@ -983,6 +1015,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): @@ -1039,6 +1075,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 @@ -1138,6 +1194,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. @@ -1816,6 +1876,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. @@ -1942,6 +2030,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.""" @@ -2039,7 +2131,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] @@ -2087,3 +2179,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 index 910ad90916..487c6659e1 100644 --- a/discord/monetization.py +++ b/discord/monetization.py @@ -173,4 +173,4 @@ async def delete(self) -> None: HTTPException Deleting the entitlement failed. """ - await self._state.http.delete_test_entitlement(self.id) + await self._state.http.delete_test_entitlement(self.application_id, self.id) 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 75b71d57cd..497ef597e1 100644 --- a/discord/permissions.py +++ b/discord/permissions.py @@ -626,6 +626,25 @@ def set_voice_channel_status(self) -> int: """ return 1 << 48 + @flag_value + def send_polls(self) -> int: + """: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") @@ -745,6 +764,8 @@ class PermissionOverwrite: moderate_members: bool | None send_voice_messages: 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/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 e3d38e558d..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, @@ -58,6 +58,7 @@ TypingEvent, VoiceChannelStatusUpdateEvent, ) + from .user import User __all__ = ( @@ -77,6 +78,7 @@ "RawThreadMembersUpdateEvent", "RawAuditLogEntryEvent", "RawVoiceChannelStatusUpdateEvent", + "RawMessagePollVoteEvent", ) @@ -780,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/sinks/core.py b/discord/sinks/core.py index 20d6d3a9ac..c8ea31a82a 100644 --- a/discord/sinks/core.py +++ b/discord/sinks/core.py @@ -23,6 +23,8 @@ DEALINGS IN THE SOFTWARE. """ +from __future__ import annotations + import io import os import struct @@ -202,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/state.py b/discord/state.py index 0f9a6dc438..c8d8d4dced 100644 --- a/discord/state.py +++ b/discord/state.py @@ -43,8 +43,6 @@ Union, ) -import discord - from . import utils from .activity import BaseActivity from .audit_logs import AuditLogEntry @@ -64,6 +62,7 @@ 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 @@ -86,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") @@ -146,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: @@ -278,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() @@ -334,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: @@ -437,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()) @@ -531,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, ): @@ -734,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): @@ -825,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 diff --git a/discord/types/channel.py b/discord/types/channel.py index 0f4c044aed..1b7fb1fe5e 100644 --- a/discord/types/channel.py +++ b/discord/types/channel.py @@ -32,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] @@ -159,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/guild.py b/discord/types/guild.py index be417a726d..cac645b272 100644 --- a/discord/types/guild.py +++ b/discord/types/guild.py @@ -185,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 8ad0295b40..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 @@ -221,6 +221,18 @@ class Interaction(TypedDict): 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): @@ -253,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/message.py b/discord/types/message.py index 10d819ebd4..8988891efa 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 @@ -120,6 +125,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 @@ -136,6 +142,7 @@ class Message(TypedDict): embeds: list[Embed] pinned: bool type: MessageType + poll: Poll AllowedMentionType = Literal["roles", "users", "everyone"] diff --git a/discord/types/onboarding.py b/discord/types/onboarding.py index 4fd4027bcc..7bfa44dc13 100644 --- a/discord/types/onboarding.py +++ b/discord/types/onboarding.py @@ -61,4 +61,4 @@ class PromptOption(TypedDict): emoji_name: NotRequired[str] emoji_animated: NotRequired[bool] title: str - description: NotRequired[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 de2b3fbf2b..2d0698eca3 100644 --- a/discord/types/raw_models.py +++ b/discord/types/raw_models.py @@ -154,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/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/user.py b/discord/user.py index 5cd0352df3..57580575a0 100644 --- a/discord/user.py +++ b/discord/user.py @@ -43,6 +43,7 @@ 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 @@ -89,7 +90,9 @@ class BaseUser(_UserTag): _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) diff --git a/discord/utils.py b/discord/utils.py index 9d982a3aef..b95678a1c4 100644 --- a/discord/utils.py +++ b/discord/utils.py @@ -296,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. @@ -315,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" @@ -328,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 @@ -337,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]]: @@ -356,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__ diff --git a/discord/voice_client.py b/discord/voice_client.py index 46fc293e12..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) diff --git a/discord/webhook/async_.py b/discord/webhook/async_.py index d14f9e6c5d..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,6 +1573,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, @@ -1590,6 +1596,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, @@ -1611,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, @@ -1692,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 ------- @@ -1751,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, @@ -1762,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, diff --git a/docs/api/data_classes.rst b/docs/api/data_classes.rst index 5df7a15fd4..f0508ac09b 100644 --- a/docs/api/data_classes.rst +++ b/docs/api/data_classes.rst @@ -93,6 +93,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 diff --git a/docs/api/enums.rst b/docs/api/enums.rst index 0500e89318..b54f962e47 100644 --- a/docs/api/enums.rst +++ b/docs/api/enums.rst @@ -2402,3 +2402,48 @@ of :class:`enum.Enum`. .. 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 25edc139b0..1b011fa1f4 100644 --- a/docs/api/events.rst +++ b/docs/api/events.rst @@ -877,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. @@ -909,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) diff --git a/docs/api/models.rst b/docs/api/models.rst index 5fec2f0dd0..bea6b3c0de 100644 --- a/docs/api/models.rst +++ b/docs/api/models.rst @@ -353,6 +353,16 @@ Interactions .. autoclass:: MessageInteraction() :members: +.. attributetable:: InteractionMetadata + +.. autoclass:: InteractionMetadata() + :members: + +.. attributetable:: AuthorizingIntegrationOwners + +.. autoclass:: AuthorizingIntegrationOwners() + :members: + .. attributetable:: Component .. autoclass:: Component() 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 2919fd2767..43a30c6eb5 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,11 +1,11 @@ -r _.txt -pylint~=3.1.0 -pytest~=8.1.1 +pylint~=3.2.3 +pytest~=8.2.2 pytest-asyncio~=0.23.3 # pytest-order~=1.0.1 -mypy~=1.9.0 -coverage~=7.4 +mypy~=1.10.1 +coverage~=7.5 pre-commit==3.5.0 -codespell==2.2.6 -bandit==1.7.8 -flake8==7.0.0 +codespell==2.3.0 +bandit==1.7.9 +flake8==7.1.0