diff --git a/changelog/1175.feature.rst b/changelog/1175.feature.rst new file mode 100644 index 0000000000..78dd79b311 --- /dev/null +++ b/changelog/1175.feature.rst @@ -0,0 +1,6 @@ +Add the new poll discord API feature. This includes the following new classes and events: + +- New types: :class:`Poll`, :class:`PollAnswer`, :class:`PollMedia`, :class:`RawMessagePollVoteActionEvent` and :class:`PollLayoutType`. +- Edited :meth:`abc.Messageable.send`, :meth:`Webhook.send`, :meth:`ext.commands.Context.send` and :meth:`disnake.InteractionResponse.send_message` to be able to send polls. +- Edited :class:`Message` to store a new :attr:`Message.poll` attribute for polls. +- Edited :class:`Event` to contain the new :func:`on_message_poll_vote_add`, :func:`on_message_poll_vote_remove`, :func:`on_raw_message_poll_vote_add` and :func:`on_raw_message_poll_vote_remove`. diff --git a/disnake/__init__.py b/disnake/__init__.py index 73b0c56349..e0af7f3354 100644 --- a/disnake/__init__.py +++ b/disnake/__init__.py @@ -57,6 +57,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/disnake/abc.py b/disnake/abc.py index a3c78f8644..b5b0f5509e 100644 --- a/disnake/abc.py +++ b/disnake/abc.py @@ -74,6 +74,7 @@ from .iterators import HistoryIterator from .member import Member from .message import Message, MessageReference, PartialMessage + from .poll import Poll from .state import ConnectionState from .threads import AnyThreadArchiveDuration, ForumTag from .types.channel import ( @@ -640,6 +641,7 @@ def _apply_implict_permissions(self, base: Permissions) -> None: if not base.send_messages: base.send_tts_messages = False base.send_voice_messages = False + base.send_polls = False base.mention_everyone = False base.embed_links = False base.attach_files = False @@ -887,6 +889,7 @@ async def set_permissions( request_to_speak: Optional[bool] = ..., send_messages: Optional[bool] = ..., send_messages_in_threads: Optional[bool] = ..., + send_polls: Optional[bool] = ..., send_tts_messages: Optional[bool] = ..., send_voice_messages: Optional[bool] = ..., speak: Optional[bool] = ..., @@ -1435,6 +1438,7 @@ async def send( mention_author: bool = ..., view: View = ..., components: Components[MessageUIComponent] = ..., + poll: Poll = ..., ) -> Message: ... @@ -1456,6 +1460,7 @@ async def send( mention_author: bool = ..., view: View = ..., components: Components[MessageUIComponent] = ..., + poll: Poll = ..., ) -> Message: ... @@ -1477,6 +1482,7 @@ async def send( mention_author: bool = ..., view: View = ..., components: Components[MessageUIComponent] = ..., + poll: Poll = ..., ) -> Message: ... @@ -1498,6 +1504,7 @@ async def send( mention_author: bool = ..., view: View = ..., components: Components[MessageUIComponent] = ..., + poll: Poll = ..., ) -> Message: ... @@ -1520,6 +1527,7 @@ async def send( mention_author: Optional[bool] = None, view: Optional[View] = None, components: Optional[Components[MessageUIComponent]] = None, + poll: Optional[Poll] = None, ): """|coro| @@ -1528,7 +1536,7 @@ async def send( The content must be a type that can convert to a string through ``str(content)``. At least one of ``content``, ``embed``/``embeds``, ``file``/``files``, - ``stickers``, ``components``, or ``view`` must be provided. + ``stickers``, ``components``, ``poll`` or ``view`` must be provided. To upload a single file, the ``file`` parameter should be used with a single :class:`.File` object. To upload multiple files, the ``files`` @@ -1624,6 +1632,11 @@ async def send( .. versionadded:: 2.9 + poll: :class:`.Poll` + The poll to send with the message. + + .. versionadded:: 2.10 + Raises ------ HTTPException @@ -1676,6 +1689,10 @@ async def send( if stickers is not None: stickers_payload = [sticker.id for sticker in stickers] + poll_payload = None + if poll: + poll_payload = poll._to_dict() + allowed_mentions_payload = None if allowed_mentions is None: allowed_mentions_payload = state.allowed_mentions and state.allowed_mentions.to_dict() @@ -1737,6 +1754,7 @@ async def send( message_reference=reference_payload, stickers=stickers_payload, components=components_payload, + poll=poll_payload, flags=flags_payload, ) finally: @@ -1753,6 +1771,7 @@ async def send( message_reference=reference_payload, stickers=stickers_payload, components=components_payload, + poll=poll_payload, flags=flags_payload, ) diff --git a/disnake/enums.py b/disnake/enums.py index 56b06ca5a2..6f81211156 100644 --- a/disnake/enums.py +++ b/disnake/enums.py @@ -71,6 +71,7 @@ "OnboardingPromptType", "SKUType", "EntitlementType", + "PollLayoutType", ) @@ -1215,6 +1216,14 @@ class Event(Enum): """Called when messages are bulk deleted. Represents the :func:`on_bulk_message_delete` event. """ + poll_vote_add = "poll_vote_add" + """Called when a vote is added on a `Poll`. + Represents the :func:`on_poll_vote_add` event. + """ + poll_vote_remove = "poll_vote_remove" + """Called when a vote is removed from a `Poll`. + Represents the :func:`on_poll_vote_remove` event. + """ raw_message_edit = "raw_message_edit" """Called when a message is edited regardless of the state of the internal message cache. Represents the :func:`on_raw_message_edit` event. @@ -1227,6 +1236,14 @@ class Event(Enum): """Called when a bulk delete is triggered regardless of the messages being in the internal message cache or not. Represents the :func:`on_raw_bulk_message_delete` event. """ + raw_poll_vote_add = "raw_poll_vote_add" + """Called when a vote is added on a `Poll` regardless of the internal message cache. + Represents the :func:`on_raw_poll_vote_add` event. + """ + raw_poll_vote_remove = "raw_poll_vote_remove" + """Called when a vote is removed from a `Poll` regardless of the internal message cache. + Represents the :func:`on_raw_poll_vote_remove` event. + """ reaction_add = "reaction_add" """Called when a message has a reaction added to it. Represents the :func:`on_reaction_add` event. @@ -1364,6 +1381,10 @@ class EntitlementType(Enum): application_subscription = 8 +class PollLayoutType(Enum): + default = 1 + + T = TypeVar("T") diff --git a/disnake/ext/commands/base_core.py b/disnake/ext/commands/base_core.py index 8f99381110..804d3717f6 100644 --- a/disnake/ext/commands/base_core.py +++ b/disnake/ext/commands/base_core.py @@ -671,6 +671,7 @@ def default_member_permissions( request_to_speak: bool = ..., send_messages: bool = ..., send_messages_in_threads: bool = ..., + send_polls: bool = ..., send_tts_messages: bool = ..., send_voice_messages: bool = ..., speak: bool = ..., diff --git a/disnake/ext/commands/core.py b/disnake/ext/commands/core.py index ffa74aaede..eb7d190b0e 100644 --- a/disnake/ext/commands/core.py +++ b/disnake/ext/commands/core.py @@ -2032,6 +2032,7 @@ def has_permissions( request_to_speak: bool = ..., send_messages: bool = ..., send_messages_in_threads: bool = ..., + send_polls: bool = ..., send_tts_messages: bool = ..., send_voice_messages: bool = ..., speak: bool = ..., @@ -2157,6 +2158,7 @@ def bot_has_permissions( request_to_speak: bool = ..., send_messages: bool = ..., send_messages_in_threads: bool = ..., + send_polls: bool = ..., send_tts_messages: bool = ..., send_voice_messages: bool = ..., speak: bool = ..., @@ -2260,6 +2262,7 @@ def has_guild_permissions( request_to_speak: bool = ..., send_messages: bool = ..., send_messages_in_threads: bool = ..., + send_polls: bool = ..., send_tts_messages: bool = ..., send_voice_messages: bool = ..., speak: bool = ..., @@ -2360,6 +2363,7 @@ def bot_has_guild_permissions( request_to_speak: bool = ..., send_messages: bool = ..., send_messages_in_threads: bool = ..., + send_polls: bool = ..., send_tts_messages: bool = ..., send_voice_messages: bool = ..., speak: bool = ..., diff --git a/disnake/flags.py b/disnake/flags.py index 1b26493892..406095a6d2 100644 --- a/disnake/flags.py +++ b/disnake/flags.py @@ -1028,11 +1028,13 @@ def __init__( automod_execution: bool = ..., bans: bool = ..., dm_messages: bool = ..., + dm_polls: bool = ..., dm_reactions: bool = ..., dm_typing: bool = ..., emojis: bool = ..., emojis_and_stickers: bool = ..., guild_messages: bool = ..., + guild_polls: bool = ..., guild_reactions: bool = ..., guild_scheduled_events: bool = ..., guild_typing: bool = ..., @@ -1043,6 +1045,7 @@ def __init__( message_content: bool = ..., messages: bool = ..., moderation: bool = ..., + polls: bool = ..., presences: bool = ..., reactions: bool = ..., typing: bool = ..., @@ -1598,6 +1601,61 @@ def automod(self): """ return (1 << 20) | (1 << 21) + @alias_flag_value + def polls(self): + """:class:`bool`: Whether guild and direct message polls related events 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) + """ + return (1 << 24) | (1 << 25) + + @flag_value + def guild_polls(self): + """:class:`bool`: Whether guild polls related events are enabled. + + .. versionadded:: 2.10 + + 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:`Message.poll` (only for guild messages) + - :class:`Poll` and all its attributes. + """ + return 1 << 24 + + @flag_value + def dm_polls(self): + """:class:`bool`: Whether direct message polls related events are enabled. + + .. versionadded:: 2.10 + + 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:`Message.poll` (only for DM messages) + - :class:`Poll` and all its attributes. + """ + return 1 << 25 + class MemberCacheFlags(BaseFlags): """Controls the library's cache policy when it comes to members. diff --git a/disnake/http.py b/disnake/http.py index b8e9786f87..f0d157d671 100644 --- a/disnake/http.py +++ b/disnake/http.py @@ -70,6 +70,7 @@ member, message, onboarding, + poll, role, sku, sticker, @@ -528,6 +529,7 @@ def send_message( message_reference: Optional[message.MessageReference] = None, stickers: Optional[Sequence[Snowflake]] = None, components: Optional[Sequence[components.Component]] = None, + poll: Optional[poll.PollCreatePayload] = None, flags: Optional[int] = None, ) -> Response[message.Message]: r = Route("POST", "/channels/{channel_id}/messages", channel_id=channel_id) @@ -563,8 +565,50 @@ def send_message( if flags is not None: payload["flags"] = flags + if poll is not None: + payload["poll"] = poll + return self.request(r, json=payload) + def get_poll_answer_voters( + self, + channel_id: Snowflake, + message_id: Snowflake, + answer_id: int, + *, + after: Optional[Snowflake] = None, + limit: Optional[int] = None, + ) -> Response[poll.PollVoters]: + params: Dict[str, Any] = {} + + if after is not None: + params["after"] = after + if limit is not None: + params["limit"] = limit + + return self.request( + Route( + "GET", + "/channels/{channel_id}/polls/{message_id}/answers/{answer_id}", + channel_id=channel_id, + message_id=message_id, + answer_id=answer_id, + ), + params=params, + ) + + 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 send_typing(self, channel_id: Snowflake) -> Response[None]: return self.request(Route("POST", "/channels/{channel_id}/typing", channel_id=channel_id)) @@ -582,6 +626,7 @@ def send_multipart_helper( message_reference: Optional[message.MessageReference] = None, stickers: Optional[Sequence[Snowflake]] = None, components: Optional[Sequence[components.Component]] = None, + poll: Optional[poll.PollCreatePayload] = None, flags: Optional[int] = None, ) -> Response[message.Message]: payload: Dict[str, Any] = {"tts": tts} @@ -603,6 +648,8 @@ def send_multipart_helper( payload["sticker_ids"] = stickers if flags is not None: payload["flags"] = flags + if poll: + payload["poll"] = poll multipart = to_multipart_with_attachments(payload, files) @@ -622,6 +669,7 @@ def send_files( message_reference: Optional[message.MessageReference] = None, stickers: Optional[Sequence[Snowflake]] = None, components: Optional[Sequence[components.Component]] = None, + poll: Optional[poll.PollCreatePayload] = None, flags: Optional[int] = None, ) -> Response[message.Message]: r = Route("POST", "/channels/{channel_id}/messages", channel_id=channel_id) @@ -637,6 +685,7 @@ def send_files( message_reference=message_reference, stickers=stickers, components=components, + poll=poll, flags=flags, ) diff --git a/disnake/interactions/base.py b/disnake/interactions/base.py index ce3b5cc89b..9543cabc66 100644 --- a/disnake/interactions/base.py +++ b/disnake/interactions/base.py @@ -74,6 +74,7 @@ from ..file import File from ..guild import GuildChannel, GuildMessageable from ..mentions import AllowedMentions + from ..poll import Poll from ..state import ConnectionState from ..threads import Thread from ..types.components import Modal as ModalPayload @@ -386,6 +387,7 @@ async def edit_original_response( attachments: Optional[List[Attachment]] = MISSING, view: Optional[View] = MISSING, components: Optional[Components[MessageUIComponent]] = MISSING, + poll: Poll = MISSING, suppress_embeds: bool = MISSING, flags: MessageFlags = MISSING, allowed_mentions: Optional[AllowedMentions] = None, @@ -450,6 +452,12 @@ async def edit_original_response( .. versionadded:: 2.4 + poll: :class:`Poll` + A poll. This can only be sent after a defer. If not used after a defer the + discord API ignore the field. + + .. versionadded:: 2.10 + allowed_mentions: :class:`AllowedMentions` Controls the mentions being processed in this message. See :meth:`.abc.Messageable.send` for more information. @@ -512,6 +520,7 @@ async def edit_original_response( embeds=embeds, view=view, components=components, + poll=poll, suppress_embeds=suppress_embeds, flags=flags, allowed_mentions=allowed_mentions, @@ -625,6 +634,7 @@ async def send( suppress_embeds: bool = MISSING, flags: MessageFlags = MISSING, delete_after: float = MISSING, + poll: Poll = MISSING, ) -> None: """|coro| @@ -701,6 +711,11 @@ async def send( .. versionchanged:: 2.7 Added support for ephemeral responses. + poll: :class:`Poll` + The poll to send with the message. + + .. versionadded:: 2.10 + Raises ------ HTTPException @@ -728,6 +743,7 @@ async def send( suppress_embeds=suppress_embeds, flags=flags, delete_after=delete_after, + poll=poll, ) @@ -902,6 +918,7 @@ async def send_message( suppress_embeds: bool = MISSING, flags: MessageFlags = MISSING, delete_after: float = MISSING, + poll: Poll = MISSING, ) -> None: """|coro| @@ -964,6 +981,12 @@ async def send_message( .. versionadded:: 2.9 + poll: :class:`Poll` + The poll to send with the message. + + .. versionadded:: 2.10 + + Raises ------ HTTPException @@ -1037,6 +1060,8 @@ async def send_message( if components is not MISSING: payload["components"] = components_to_dict(components) + if poll is not MISSING: + payload["poll"] = poll._to_dict() parent = self._parent adapter = async_context.get() @@ -1550,6 +1575,10 @@ class InteractionMessage(Message): A list of components in the message. guild: Optional[:class:`Guild`] The guild that the message belongs to, if applicable. + poll: Optional[:class:`Poll`] + The poll contained in this message. + + .. versionadded:: 2.10 """ __slots__ = () diff --git a/disnake/iterators.py b/disnake/iterators.py index 9e15f379b4..6d629066af 100644 --- a/disnake/iterators.py +++ b/disnake/iterators.py @@ -39,6 +39,7 @@ "MemberIterator", "GuildScheduledEventUserIterator", "EntitlementIterator", + "PollAnswerIterator", ) if TYPE_CHECKING: @@ -1140,3 +1141,65 @@ async def _after_strategy(self, retrieve: int) -> List[EntitlementPayload]: # endpoint returns items in ascending order when `after` is used self.after = Object(id=int(data[-1]["id"])) return data + + +class PollAnswerIterator(_AsyncIterator[Union["User", "Member"]]): + def __init__( + self, + message: Message, + answer_id: int, + *, + limit: Optional[int], + after: Optional[Snowflake] = None, + ) -> None: + self.channel_id: int = message.channel.id + self.message_id: int = message.id + self.answer_id: int = answer_id + self.guild: Optional[Guild] = message.guild + self.state: ConnectionState = message._state + + self.limit: Optional[int] = limit + self.after: Optional[Snowflake] = after + + self.getter = message._state.http.get_poll_answer_voters + self.users = asyncio.Queue() + + async def next(self) -> Union[User, Member]: + if self.users.empty(): + await self.fill_users() + + try: + return self.users.get_nowait() + except asyncio.QueueEmpty: + raise NoMoreItems from None + + def _get_retrieve(self) -> bool: + self.retrieve = min(self.limit, 100) if self.limit is not None else 100 + return self.retrieve > 0 + + async def fill_users(self) -> None: + if self._get_retrieve(): + after = self.after.id if self.after else None + data = ( + await self.getter( + channel_id=self.channel_id, + message_id=self.message_id, + answer_id=self.answer_id, + after=after, + limit=self.retrieve, + ) + )["users"] + + if len(data): + if self.limit is not None: + self.limit -= self.retrieve + self.after = Object(id=int(data[-1]["id"])) + + if len(data) < 100: + self.limit = 0 # terminate loop + + for element in data: + member = None + if not (self.guild is None or isinstance(self.guild, Object)): + member = self.guild.get_member(int(element["id"])) + await self.users.put(member or self.state.create_user(data=element)) diff --git a/disnake/message.py b/disnake/message.py index 6e4e1aaa6c..15fe00c5a9 100644 --- a/disnake/message.py +++ b/disnake/message.py @@ -34,6 +34,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 @@ -906,6 +907,11 @@ class Message(Hashable): guild: Optional[:class:`Guild`] The guild that the message belongs to, if applicable. + + poll: Optional[:class:`Poll`] + The poll contained in this message. + + .. versionadded:: 2.10 """ __slots__ = ( @@ -941,6 +947,7 @@ class Message(Hashable): "stickers", "components", "guild", + "poll", "_edited_timestamp", "_role_subscription_data", ) @@ -1002,6 +1009,10 @@ def __init__( ) self.interaction: Optional[InteractionReference] = inter + self.poll: Optional[Poll] = None + if poll_data := data.get("poll"): + self.poll = Poll.from_dict(message=self, data=poll_data) + try: # if the channel doesn't have a guild attribute, we handle that self.guild = channel.guild # type: ignore diff --git a/disnake/permissions.py b/disnake/permissions.py index cf19761ae4..95a6792fe8 100644 --- a/disnake/permissions.py +++ b/disnake/permissions.py @@ -197,6 +197,7 @@ def __init__( request_to_speak: bool = ..., send_messages: bool = ..., send_messages_in_threads: bool = ..., + send_polls: bool = ..., send_tts_messages: bool = ..., send_voice_messages: bool = ..., speak: bool = ..., @@ -428,6 +429,7 @@ def text(cls) -> Self: read_message_history=True, send_tts_messages=True, send_voice_messages=True, + send_polls=True, ) @classmethod @@ -599,6 +601,7 @@ def update( request_to_speak: bool = ..., send_messages: bool = ..., send_messages_in_threads: bool = ..., + send_polls: bool = ..., send_tts_messages: bool = ..., send_voice_messages: bool = ..., speak: bool = ..., @@ -1058,6 +1061,14 @@ def send_voice_messages(self) -> int: """ return 1 << 46 + @flag_value + def send_polls(self) -> int: + """:class:`bool`: Returns ``True`` if a user can send polls. + + .. versionadded:: 2.10 + """ + return 1 << 49 + @flag_value def use_external_apps(self) -> int: """:class:`bool`: Returns ``True`` if a user's apps can send public responses. @@ -1175,6 +1186,7 @@ class PermissionOverwrite: request_to_speak: Optional[bool] send_messages: Optional[bool] send_messages_in_threads: Optional[bool] + send_polls: Optional[bool] send_tts_messages: Optional[bool] send_voice_messages: Optional[bool] speak: Optional[bool] @@ -1242,6 +1254,7 @@ def __init__( request_to_speak: Optional[bool] = ..., send_messages: Optional[bool] = ..., send_messages_in_threads: Optional[bool] = ..., + send_polls: Optional[bool] = ..., send_tts_messages: Optional[bool] = ..., send_voice_messages: Optional[bool] = ..., speak: Optional[bool] = ..., @@ -1376,6 +1389,7 @@ def update( request_to_speak: Optional[bool] = ..., send_messages: Optional[bool] = ..., send_messages_in_threads: Optional[bool] = ..., + send_polls: Optional[bool] = ..., send_tts_messages: Optional[bool] = ..., send_voice_messages: Optional[bool] = ..., speak: Optional[bool] = ..., diff --git a/disnake/poll.py b/disnake/poll.py new file mode 100644 index 0000000000..39f4140945 --- /dev/null +++ b/disnake/poll.py @@ -0,0 +1,423 @@ +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +from datetime import timedelta +from typing import TYPE_CHECKING, Dict, List, Optional, Union + +from . import utils +from .abc import Snowflake +from .emoji import Emoji, _EmojiTag +from .enums import PollLayoutType, try_enum +from .iterators import PollAnswerIterator +from .partial_emoji import PartialEmoji + +if TYPE_CHECKING: + from datetime import datetime + + from .message import Message + from .state import ConnectionState + from .types.poll import ( + Poll as PollPayload, + PollAnswer as PollAnswerPayload, + PollCreateAnswerPayload, + PollCreateMediaPayload, + PollCreatePayload, + PollMedia as PollMediaPayload, + ) + +__all__ = ( + "PollMedia", + "PollAnswer", + "Poll", +) + + +class PollMedia: + """Represents data of a poll's question/answers. + + .. versionadded:: 2.10 + + Parameters + ---------- + text: :class:`str` + The text of this media. + emoji: Optional[Union[:class:`Emoji`, :class:`PartialEmoji`, :class:`str`]] + The emoji of this media. + + Attributes + ---------- + text: Optional[:class:`str`] + The text of this media. + emoji: Optional[:class:`PartialEmoji`] + The emoji of this media. + """ + + __slots__ = ("text", "emoji") + + def __init__( + self, text: Optional[str], *, emoji: Optional[Union[Emoji, PartialEmoji, str]] = None + ) -> None: + if text is None and emoji is None: + raise ValueError("At least one of `text` or `emoji` must be not None") + + self.text = text + self.emoji: Optional[Union[Emoji, PartialEmoji]] = None + if isinstance(emoji, str): + self.emoji = PartialEmoji.from_str(emoji) + elif isinstance(emoji, _EmojiTag): + self.emoji = emoji + else: + if emoji is not None: + raise TypeError("Emoji must be None, a str, PartialEmoji, or Emoji instance.") + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} text={self.text!r} emoji={self.emoji!r}>" + + @classmethod + def from_dict(cls, state: ConnectionState, data: PollMediaPayload) -> PollMedia: + text = data.get("text") + + emoji = None + if emoji_data := data.get("emoji"): + emoji = state._get_emoji_from_data(emoji_data) + + return cls(text=text, emoji=emoji) + + def _to_dict(self) -> PollCreateMediaPayload: + payload: PollCreateMediaPayload = {} + if self.text: + payload["text"] = self.text + if self.emoji: + if self.emoji.id: + payload["emoji"] = {"id": self.emoji.id} + else: + payload["emoji"] = {"name": self.emoji.name} + return payload + + +class PollAnswer: + """Represents a poll answer from discord. + + .. versionadded:: 2.10 + + Parameters + ---------- + media: :class:`PollMedia` + The media object to set the text and/or emoji for this answer. + + Attributes + ---------- + id: Optional[:class:`int`] + The ID of this answer. This will be ``None`` only if this object was created manually + and did not originate from the API. + media: :class:`PollMedia` + The media fields of this answer. + poll: Optional[:class:`Poll`] + The poll associated with this answer. This will be ``None`` only if this object was created manually + and did not originate from the API. + vote_count: :class:`int` + The number of votes for this answer. + self_voted: :class:`bool` + Whether the current user voted for this answer. + """ + + __slots__ = ("id", "media", "poll", "vote_count", "self_voted") + + def __init__(self, media: PollMedia) -> None: + self.id: Optional[int] = None + self.poll: Optional[Poll] = None + self.media = media + self.vote_count: int = 0 + self.self_voted: bool = False + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} media={self.media!r}>" + + @classmethod + def from_dict(cls, state: ConnectionState, poll: Poll, data: PollAnswerPayload) -> PollAnswer: + answer = cls(PollMedia.from_dict(state, data["poll_media"])) + answer.id = int(data["answer_id"]) + answer.poll = poll + + return answer + + def _to_dict(self) -> PollCreateAnswerPayload: + return {"poll_media": self.media._to_dict()} + + def voters( + self, *, limit: Optional[int] = 100, after: Optional[Snowflake] = None + ) -> PollAnswerIterator: + """Returns an :class:`AsyncIterator` representing the users that have voted for this answer. + + The ``after`` parameter must represent a member and meet the :class:`abc.Snowflake` abc. + + .. note:: + + This method works only on PollAnswer(s) objects that originate from the API and not on the ones built manually. + + Parameters + ---------- + limit: Optional[:class:`int`] + The maximum number of results to return. + If ``None``, retrieves every user who voted for this answer. + Note, however, that this would make it a slow operation. + Defaults to ``100``. + after: Optional[:class:`abc.Snowflake`] + For pagination, votes are sorted by member. + + Raises + ------ + HTTPException + Getting the voters for this answer failed. + Forbidden + Tried to get the voters for this answer without the required permissions. + ValueError + You tried to invoke this method on an object that didn't originate from the API. + + Yields + ------ + Union[:class:`User`, :class:`Member`] + The member (if retrievable) or the user that has voted + for 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. + """ + if not (self.id is not None and self.poll and self.poll.message): + raise ValueError( + "This object was manually built. To use this method, you need to use a poll object retrieved from the Discord API." + ) + + return PollAnswerIterator(self.poll.message, self.id, limit=limit, after=after) + + +class Poll: + """Represents a poll from Discord. + + .. versionadded:: 2.10 + + Parameters + ---------- + question: Union[:class:`str`, :class:`PollMedia`] + The question of the poll. Currently, emojis are not supported in poll questions. + answers: List[Union[:class:`str`, :class:`PollAnswer`]] + The answers for this poll, up to 10. + duration: :class:`datetime.timedelta` + The total duration of the poll, up to 32 days. Defaults to 1 day. + Note that this gets rounded down to the closest hour. + allow_multiselect: :class:`bool` + Whether users will be able to pick more than one answer. Defaults to ``False``. + layout_type: :class:`PollLayoutType` + The layout type of the poll. Defaults to :attr:`PollLayoutType.default`. + + Attributes + ---------- + message: Optional[:class:`Message`] + The message which contains this poll. This will be ``None`` only if this object was created manually + and did not originate from the API. + question: :class:`PollMedia` + The question of the poll. + duration: Optional[:class:`datetime.timedelta`] + The original duration for this poll. ``None`` if the poll is a non-expiring poll. + allow_multiselect: :class:`bool` + Whether users are able to pick more than one answer. + layout_type: :class:`PollLayoutType` + The type of the layout of the poll. + is_finalized: :class:`bool` + Whether the votes have been precisely counted. + """ + + __slots__ = ( + "message", + "question", + "_answers", + "duration", + "allow_multiselect", + "layout_type", + "is_finalized", + ) + + def __init__( + self, + question: Union[str, PollMedia], + *, + answers: List[Union[str, PollAnswer]], + duration: timedelta = timedelta(hours=24), + allow_multiselect: bool = False, + layout_type: PollLayoutType = PollLayoutType.default, + ) -> None: + self.message: Optional[Message] = None + + if isinstance(question, str): + self.question = PollMedia(question) + elif isinstance(question, PollMedia): + self.question: PollMedia = question + else: + raise TypeError( + f"Expected 'str' or 'PollMedia' for 'question', got {question.__class__.__name__!r}." + ) + + self._answers: Dict[int, PollAnswer] = {} + for i, answer in enumerate(answers, 1): + if isinstance(answer, PollAnswer): + self._answers[i] = answer + elif isinstance(answer, str): + self._answers[i] = PollAnswer(PollMedia(answer)) + else: + raise TypeError( + f"Expected 'List[str]' or 'List[PollAnswer]' for 'answers', got List[{answer.__class__.__name__!r}]." + ) + + self.duration: Optional[timedelta] = duration + self.allow_multiselect: bool = allow_multiselect + self.layout_type: PollLayoutType = layout_type + self.is_finalized: bool = False + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} question={self.question!r} answers={self.answers!r}>" + + @property + def answers(self) -> List[PollAnswer]: + """List[:class:`PollAnswer`]: The list of answers for this poll. + + See also :meth:`get_answer` to get specific answers by ID. + """ + return list(self._answers.values()) + + @property + def created_at(self) -> Optional[datetime]: + """Optional[:class:`datetime.datetime`]: When this poll was created. + + ``None`` if this poll does not originate from the discord API. + """ + if not self.message: + return + return utils.snowflake_time(self.message.id) + + @property + def expires_at(self) -> Optional[datetime]: + """Optional[:class:`datetime.datetime`]: The date when this poll will expire. + + ``None`` if this poll does not originate from the discord API or if this + poll is non-expiring. + """ + # non-expiring poll + if not self.duration: + return + + created_at = self.created_at + # manually built object + if not created_at: + return + return created_at + self.duration + + @property + def remaining_duration(self) -> Optional[timedelta]: + """Optional[:class:`datetime.timedelta`]: The remaining duration for this poll. + If this poll is finalized this property will arbitrarily return a + zero valued timedelta. + + ``None`` if this poll does not originate from the discord API. + """ + if self.is_finalized: + return timedelta(hours=0) + if not self.expires_at or not self.message: + return + + return self.expires_at - utils.utcnow() + + def get_answer(self, answer_id: int, /) -> Optional[PollAnswer]: + """Return the requested poll answer. + + Parameters + ---------- + answer_id: :class:`int` + The answer id. + + Returns + ------- + Optional[:class:`PollAnswer`] + The requested answer. + """ + return self._answers.get(answer_id) + + @classmethod + def from_dict( + cls, + message: Message, + data: PollPayload, + ) -> Poll: + state = message._state + poll = cls( + question=PollMedia.from_dict(state, data["question"]), + answers=[], + allow_multiselect=data["allow_multiselect"], + layout_type=try_enum(PollLayoutType, data["layout_type"]), + ) + for answer in data["answers"]: + answer_obj = PollAnswer.from_dict(state, poll, answer) + poll._answers[int(answer["answer_id"])] = answer_obj + + poll.message = message + if expiry := data["expiry"]: + poll.duration = utils.parse_time(expiry) - utils.snowflake_time(poll.message.id) + else: + # future support for non-expiring polls + # read the foot note https://discord.com/developers/docs/resources/poll#poll-object-poll-object-structure + poll.duration = None + + if results := data.get("results"): + poll.is_finalized = results["is_finalized"] + + for answer_count in results["answer_counts"]: + try: + answer = poll._answers[int(answer_count["id"])] + except KeyError: + # this should never happen + continue + answer.vote_count = answer_count["count"] + answer.self_voted = answer_count["me_voted"] + + return poll + + def _to_dict(self) -> PollCreatePayload: + payload: PollCreatePayload = { + "question": self.question._to_dict(), + "duration": (int(self.duration.total_seconds()) // 3600), # type: ignore + "allow_multiselect": self.allow_multiselect, + "layout_type": self.layout_type.value, + "answers": [answer._to_dict() for answer in self._answers.values()], + } + return payload + + async def expire(self) -> Message: + """|coro| + + Immediately ends a poll. + + .. note:: + + This method works only on Poll(s) objects that originate + from the API and not on the ones built manually. + + Raises + ------ + HTTPException + Expiring the poll failed. + Forbidden + Tried to expire a poll without the required permissions. + ValueError + You tried to invoke this method on an object that didn't originate from the API.``` + + Returns + ------- + :class:`Message` + The message which contains the expired `Poll`. + """ + if not self.message: + raise ValueError( + "This object was manually built. To use this method, you need to use a poll object retrieved from the Discord API." + ) + + data = await self.message._state.http.expire_poll(self.message.channel.id, self.message.id) + return self.message._state.create_message(channel=self.message.channel, data=data) diff --git a/disnake/raw_models.py b/disnake/raw_models.py index 8b7e25f43a..48b8dab56d 100644 --- a/disnake/raw_models.py +++ b/disnake/raw_models.py @@ -24,6 +24,8 @@ MessageReactionRemoveEmojiEvent, MessageReactionRemoveEvent, MessageUpdateEvent, + PollVoteAddEvent, + PollVoteRemoveEvent, PresenceUpdateEvent, ThreadDeleteEvent, TypingStartEvent, @@ -45,6 +47,7 @@ "RawTypingEvent", "RawGuildMemberRemoveEvent", "RawPresenceUpdateEvent", + "RawPollVoteActionEvent", ) @@ -147,6 +150,62 @@ def __init__(self, data: MessageUpdateEvent) -> None: self.guild_id: Optional[int] = None +PollEventType = Literal["POLL_VOTE_ADD", "POLL_VOTE_REMOVE"] + + +class RawPollVoteActionEvent(_RawReprMixin): + """Represents the event payload for :func:`on_raw_poll_vote_add` and + :func:`on_raw_poll_vote_remove` events. + + .. versionadded:: 2.10 + + Attributes + ---------- + message_id: :class:`int` + The message ID that got or lost a vote. + user_id: :class:`int` + The user ID who added the vote or whose vote was removed. + cached_member: Optional[:class:`Member`] + The member who added the vote. Available only when the guilds and members are cached. + channel_id: :class:`int` + The channel ID where the vote addition or removal took place. + guild_id: Optional[:class:`int`] + The guild ID where the vote addition or removal took place, if applicable. + answer_id: :class:`int` + The ID of the answer that was voted or unvoted. + event_type: :class:`str` + The event type that triggered this action. Can be + ``POLL_VOTE_ADD`` for vote addition or + ``POLL_VOTE_REMOVE`` for vote removal. + """ + + __slots__ = ( + "message_id", + "user_id", + "cached_member", + "channel_id", + "guild_id", + "event_type", + "answer_id", + ) + + def __init__( + self, + data: Union[PollVoteAddEvent, PollVoteRemoveEvent], + event_type: PollEventType, + ) -> None: + self.message_id: int = int(data["message_id"]) + self.user_id: int = int(data["user_id"]) + self.cached_member: Optional[Member] = None + self.channel_id: int = int(data["channel_id"]) + self.event_type = event_type + self.answer_id: int = int(data["answer_id"]) + try: + self.guild_id: Optional[int] = int(data["guild_id"]) + except KeyError: + self.guild_id: Optional[int] = None + + ReactionEventType = Literal["REACTION_ADD", "REACTION_REMOVE"] diff --git a/disnake/state.py b/disnake/state.py index f4885513d7..ab4e5a8d78 100644 --- a/disnake/state.py +++ b/disnake/state.py @@ -70,6 +70,7 @@ RawIntegrationDeleteEvent, RawMessageDeleteEvent, RawMessageUpdateEvent, + RawPollVoteActionEvent, RawPresenceUpdateEvent, RawReactionActionEvent, RawReactionClearEmojiEvent, @@ -930,6 +931,39 @@ def parse_message_reaction_remove_emoji( if reaction: self.dispatch("reaction_clear_emoji", reaction) + def _handle_poll_event( + self, raw: RawPollVoteActionEvent, event_type: Literal["add", "remove"] + ) -> None: + guild = self._get_guild(raw.guild_id) + answer = None + if guild is not None: + member = guild.get_member(raw.user_id) + message = self._get_message(raw.message_id) + if message is not None and message.poll is not None: + answer = message.poll.get_answer(raw.answer_id) + + if member is not None: + raw.cached_member = member + + if answer is not None: + if event_type == "add": + answer.vote_count += 1 + else: + answer.vote_count -= 1 + + self.dispatch(f"raw_poll_vote_{event_type}", raw) + + if raw.cached_member is not None and answer is not None: + self.dispatch(f"poll_vote_{event_type}", raw.cached_member, answer) + + def parse_message_poll_vote_add(self, data: gateway.PollVoteAddEvent) -> None: + raw = RawPollVoteActionEvent(data, "POLL_VOTE_ADD") + self._handle_poll_event(raw, "add") + + def parse_message_poll_vote_remove(self, data: gateway.PollVoteRemoveEvent) -> None: + raw = RawPollVoteActionEvent(data, "POLL_VOTE_REMOVE") + self._handle_poll_event(raw, "remove") + def parse_interaction_create(self, data: gateway.InteractionCreateEvent) -> None: # note: this does not use an intermediate variable for `data["type"]` since # it wouldn't allow automatically narrowing the `data` union type based diff --git a/disnake/threads.py b/disnake/threads.py index 8095bbc9a3..b7a11f25c9 100644 --- a/disnake/threads.py +++ b/disnake/threads.py @@ -474,6 +474,7 @@ def permissions_for( if not base.send_messages_in_threads: base.send_tts_messages = False base.send_voice_messages = False + base.send_polls = False base.mention_everyone = False base.embed_links = False base.attach_files = False diff --git a/disnake/types/gateway.py b/disnake/types/gateway.py index 9e81523d29..2926786e5d 100644 --- a/disnake/types/gateway.py +++ b/disnake/types/gateway.py @@ -323,6 +323,24 @@ class MessageReactionRemoveEmojiEvent(TypedDict): emoji: PartialEmoji +# https://discord.com/developers/docs/topics/gateway-events#message-poll-vote-add +class PollVoteAddEvent(TypedDict): + channel_id: Snowflake + guild_id: NotRequired[Snowflake] + message_id: Snowflake + user_id: Snowflake + answer_id: int + + +# https://discord.com/developers/docs/topics/gateway-events#message-poll-vote-remove +class PollVoteRemoveEvent(TypedDict): + channel_id: Snowflake + guild_id: NotRequired[Snowflake] + message_id: Snowflake + user_id: Snowflake + answer_id: int + + # https://discord.com/developers/docs/topics/gateway-events#interaction-create InteractionCreateEvent = BaseInteraction diff --git a/disnake/types/message.py b/disnake/types/message.py index 0b9bbed3c3..424b7ffd66 100644 --- a/disnake/types/message.py +++ b/disnake/types/message.py @@ -12,6 +12,7 @@ from .emoji import PartialEmoji from .interactions import InteractionMessageReference from .member import Member, UserWithMember +from .poll import Poll from .snowflake import Snowflake, SnowflakeList from .sticker import StickerItem from .threads import Thread @@ -115,6 +116,7 @@ class Message(TypedDict): sticker_items: NotRequired[List[StickerItem]] position: NotRequired[int] role_subscription_data: NotRequired[RoleSubscriptionData] + poll: NotRequired[Poll] # specific to MESSAGE_CREATE/MESSAGE_UPDATE events guild_id: NotRequired[Snowflake] diff --git a/disnake/types/poll.py b/disnake/types/poll.py new file mode 100644 index 0000000000..37d33e2206 --- /dev/null +++ b/disnake/types/poll.py @@ -0,0 +1,72 @@ +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +from typing import List, Literal, Optional, TypedDict + +from typing_extensions import NotRequired + +from .emoji import PartialEmoji +from .snowflake import Snowflake +from .user import User + + +class PollMedia(TypedDict): + text: NotRequired[str] + emoji: NotRequired[PartialEmoji] + + +class PollAnswer(TypedDict): + # sent only as part of responses from Discord's API/Gateway + answer_id: Snowflake + poll_media: PollMedia + + +PollLayoutType = Literal[1] + + +class PollAnswerCount(TypedDict): + id: Snowflake + count: int + me_voted: bool + + +class PollResult(TypedDict): + is_finalized: bool + answer_counts: List[PollAnswerCount] + + +class PollVoters(TypedDict): + users: List[User] + + +class Poll(TypedDict): + question: PollMedia + answers: List[PollAnswer] + expiry: Optional[str] + allow_multiselect: bool + layout_type: PollLayoutType + # sent only as part of responses from Discord's API/Gateway + results: NotRequired[PollResult] + + +class EmojiPayload(TypedDict): + id: NotRequired[int] + name: NotRequired[str] + + +class PollCreateMediaPayload(TypedDict): + text: NotRequired[str] + emoji: NotRequired[EmojiPayload] + + +class PollCreateAnswerPayload(TypedDict): + poll_media: PollCreateMediaPayload + + +class PollCreatePayload(TypedDict): + question: PollCreateMediaPayload + answers: List[PollCreateAnswerPayload] + duration: int + allow_multiselect: bool + layout_type: NotRequired[int] diff --git a/disnake/webhook/async_.py b/disnake/webhook/async_.py index 7e1228a529..98650f4bf1 100644 --- a/disnake/webhook/async_.py +++ b/disnake/webhook/async_.py @@ -63,6 +63,7 @@ from ..http import Response from ..mentions import AllowedMentions from ..message import Attachment + from ..poll import Poll from ..state import ConnectionState from ..sticker import GuildSticker, StandardSticker, StickerItem from ..types.message import Message as MessagePayload @@ -511,6 +512,7 @@ def handle_message_parameters_dict( allowed_mentions: Optional[AllowedMentions] = MISSING, previous_allowed_mentions: Optional[AllowedMentions] = None, stickers: Sequence[Union[GuildSticker, StandardSticker, StickerItem]] = MISSING, + poll: Poll = MISSING, # these parameters are exclusive to webhooks in forum/media channels thread_name: str = MISSING, applied_tags: Sequence[Snowflake] = MISSING, @@ -579,6 +581,8 @@ def handle_message_parameters_dict( payload["thread_name"] = thread_name if applied_tags: payload["applied_tags"] = [t.id for t in applied_tags] + if poll is not MISSING: + payload["poll"] = poll._to_dict() return DictPayloadParameters(payload=payload, files=files) @@ -602,6 +606,7 @@ def handle_message_parameters( allowed_mentions: Optional[AllowedMentions] = MISSING, previous_allowed_mentions: Optional[AllowedMentions] = None, stickers: Sequence[Union[GuildSticker, StandardSticker, StickerItem]] = MISSING, + poll: Poll = MISSING, # these parameters are exclusive to webhooks in forum/media channels thread_name: str = MISSING, applied_tags: Sequence[Snowflake] = MISSING, @@ -626,6 +631,7 @@ def handle_message_parameters( stickers=stickers, thread_name=thread_name, applied_tags=applied_tags, + poll=poll, ) if params.files: @@ -1495,6 +1501,7 @@ async def send( allowed_mentions: AllowedMentions = ..., view: View = ..., components: Components[MessageUIComponent] = ..., + poll: Poll = ..., thread: Snowflake = ..., thread_name: str = ..., applied_tags: Sequence[Snowflake] = ..., @@ -1521,6 +1528,7 @@ async def send( allowed_mentions: AllowedMentions = ..., view: View = ..., components: Components[MessageUIComponent] = ..., + poll: Poll = ..., thread: Snowflake = ..., thread_name: str = ..., applied_tags: Sequence[Snowflake] = ..., @@ -1551,6 +1559,7 @@ async def send( applied_tags: Sequence[Snowflake] = MISSING, wait: bool = False, delete_after: float = MISSING, + poll: Poll = MISSING, ) -> Optional[WebhookMessage]: """|coro| @@ -1677,6 +1686,11 @@ async def send( .. versionadded:: 2.9 + poll: :class:`Poll` + The poll to send with the message. + + .. versionadded:: 2.10 + Raises ------ HTTPException @@ -1749,6 +1763,7 @@ async def send( applied_tags=applied_tags, allowed_mentions=allowed_mentions, previous_allowed_mentions=previous_mentions, + poll=poll, ) adapter = async_context.get() diff --git a/docs/api/events.rst b/docs/api/events.rst index aeb441f9a3..fb0059f1fa 100644 --- a/docs/api/events.rst +++ b/docs/api/events.rst @@ -1243,6 +1243,38 @@ This section documents events related to Discord chat messages. :param messages: The messages that have been deleted. :type messages: List[:class:`Message`] +.. function:: on_poll_vote_add(member, answer) + + Called when a vote is added on a poll. If the member or message is not found in the internal cache, then this event will not be called. + + This requires :attr:`Intents.guild_polls` or :attr:`Intents.dm_polls` to be enabled to receive events about polls sent in guilds or DMs. + + .. note:: + + You can use :attr:`Intents.polls` to enable both :attr:`Intents.guild_polls` and :attr:`Intents.dm_polls` in one go. + + + :param member: The member who voted. + :type member: :class:`Member` + :param answer: The :class:`PollAnswer` object for which the vote was added. + :type answer: :class:`PollAnswer` + +.. function:: on_poll_vote_remove(member, answer) + + Called when a vote is removed on a poll. If the member or message is not found in the internal cache, then this event will not be called. + + This requires :attr:`Intents.guild_polls` or :attr:`Intents.dm_polls` to be enabled to receive events about polls sent in guilds or DMs. + + .. note:: + + You can use :attr:`Intents.polls` to enable both :attr:`Intents.guild_polls` and :attr:`Intents.dm_polls` in one go. + + + :param member: The member who removed the vote. + :type member: :class:`Member` + :param answer: The :class:`PollAnswer` object for which the vote was removed. + :type answer: :class:`PollAnswer` + .. function:: on_raw_message_edit(payload) Called when a message is edited. Unlike :func:`on_message_edit`, this is called @@ -1293,6 +1325,34 @@ This section documents events related to Discord chat messages. :param payload: The raw event payload data. :type payload: :class:`RawBulkMessageDeleteEvent` +.. function:: on_raw_poll_vote_add(payload) + + Called when a vote is added on a poll. Unlike :func:`on_poll_vote_add`, this is + called regardless of the guilds being in the internal guild cache or not. + + This requires :attr:`Intents.guild_polls` or :attr:`Intents.dm_polls` to be enabled to receive events about polls sent in guilds or DMs. + + .. note:: + + You can use :attr:`Intents.polls` to enable both :attr:`Intents.guild_polls` and :attr:`Intents.dm_polls` in one go. + + :param payload: The raw event payload data. + :type payload: :class:`RawPollVoteActionEvent` + +.. function:: on_raw_poll_vote_remove(payload) + + Called when a vote is removed on a poll. Unlike :func:`on_poll_vote_remove`, this is + called regardless of the guilds being in the internal guild cache or not. + + This requires :attr:`Intents.guild_polls` or :attr:`Intents.dm_polls` to be enabled to receive events about polls sent in guilds or DMs. + + .. note:: + + You can use :attr:`Intents.polls` to enable both :attr:`Intents.guild_polls` and :attr:`Intents.dm_polls` in one go. + + :param payload: The raw event payload data. + :type payload: :class:`RawPollVoteActionEvent` + .. function:: on_reaction_add(reaction, user) Called when a message has a reaction added to it. Similar to :func:`on_message_edit`, diff --git a/docs/api/messages.rst b/docs/api/messages.rst index e0a7c8a513..6c02bc0208 100644 --- a/docs/api/messages.rst +++ b/docs/api/messages.rst @@ -94,6 +94,14 @@ RawMessageUpdateEvent .. autoclass:: RawMessageUpdateEvent() :members: +RawPollVoteActionEvent +~~~~~~~~~~~~~~~~~~~~~~ + +.. attributetable:: RawPollVoteActionEvent + +.. autoclass:: RawPollVoteActionEvent() + :members: + RawReactionActionEvent ~~~~~~~~~~~~~~~~~~~~~~ @@ -177,6 +185,30 @@ PartialMessage .. autoclass:: PartialMessage :members: +Poll +~~~~ + +.. attributetable:: Poll + +.. autoclass:: Poll + :members: + +PollAnswer +~~~~~~~~~~ + +.. attributetable:: PollAnswer + +.. autoclass:: PollAnswer + :members: + +PollMedia +~~~~~~~~~ + +.. attributetable:: PollMedia + +.. autoclass:: PollMedia + :members: + Enumerations ------------ @@ -369,6 +401,19 @@ MessageType .. versionadded:: 2.10 +PollLayoutType +~~~~~~~~~~~~~~ + +.. class:: PollLayoutType + + Specifies the layout of a :class:`Poll`. + + .. versionadded:: 2.10 + + .. attribute:: default + + The default poll layout type. + Events ------ @@ -376,10 +421,14 @@ Events - :func:`on_message_edit(before, after) ` - :func:`on_message_delete(message) ` - :func:`on_bulk_message_delete(messages) ` +- :func:`on_poll_vote_add(member, answer) ` +- :func:`on_poll_vote_removed(member, answer) ` - :func:`on_raw_message_edit(payload) ` - :func:`on_raw_message_delete(payload) ` - :func:`on_raw_bulk_message_delete(payload) ` +- :func:`on_raw_poll_vote_add(payload) ` +- :func:`on_raw_poll_vote_remove(payload) ` - :func:`on_reaction_add(reaction, user) ` - :func:`on_reaction_remove(reaction, user) ` diff --git a/docs/intents.rst b/docs/intents.rst index 8c4a41c3ae..79c3872aaa 100644 --- a/docs/intents.rst +++ b/docs/intents.rst @@ -98,7 +98,7 @@ Message Content Intent ++++++++++++++++++++++ - Whether you want a prefix that isn't the bot mention. -- Whether you want to access the contents of messages. This includes content (text), embeds, attachments, and components. +- Whether you want to access the contents of messages. This includes content (text), embeds, attachments, components and polls. .. _need_presence_intent: @@ -191,12 +191,13 @@ As of August 31st, 2022, Discord has blocked message content from being sent to If you are on version 2.4 or before, your bot will be able to access message content without the intent enabled in the code. However, as of version 2.5, it is required to enable :attr:`Intents.message_content` to receive message content over the gateway. -Message content refers to four attributes on the :class:`.Message` object: +Message content refers to five attributes on the :class:`.Message` object: - :attr:`~.Message.content` - :attr:`~.Message.embeds` - :attr:`~.Message.attachments` - :attr:`~.Message.components` +- :attr:`~.Message.poll` You will always receive message content in the following cases even without the message content intent: