Skip to content

Commit 59471c4

Browse files
feat: add polls (#1691)
* feat: initial work on polls * refactor: use constants instead of hardcoded values * feat: expose poll objects * fix: make converter optional * feat: add polls to send functions * feat: allow chaining of add_answer * feat: a couple of create changes * docs: add poll docs * feat: add http methods for polls * fix: oops * feat: add methods to interact with http methods * ci: correct from checks. * feat: add poll events * feat: add send polls permission * ci: correct from checks. * docs: clarify weirdness of text property --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 2eef35d commit 59471c4

File tree

18 files changed

+489
-1
lines changed

18 files changed

+489
-1
lines changed

docs/src/API Reference/API Reference/models/Discord/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ search:
2222
- [Invite](invite)
2323
- [Message](message)
2424
- [Modals](modals)
25+
- [Poll](poll)
2526
- [Reaction](reaction)
2627
- [Role](role)
2728
- [Scheduled event](scheduled_event)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
::: interactions.models.discord.poll

interactions/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
MentionPrefix,
2626
Missing,
2727
MISSING,
28+
POLL_MAX_ANSWERS,
29+
POLL_MAX_DURATION_HOURS,
2830
PREMIUM_GUILD_LIMITS,
2931
SELECT_MAX_NAME_LENGTH,
3032
SELECTS_MAX_OPTIONS,
@@ -244,6 +246,12 @@
244246
PartialEmojiConverter,
245247
PermissionOverwrite,
246248
Permissions,
249+
Poll,
250+
PollAnswer,
251+
PollAnswerCount,
252+
PollLayoutType,
253+
PollMedia,
254+
PollResults,
247255
PremiumTier,
248256
PremiumType,
249257
process_allowed_mentions,
@@ -596,6 +604,14 @@
596604
"PartialEmojiConverter",
597605
"PermissionOverwrite",
598606
"Permissions",
607+
"Poll",
608+
"PollAnswer",
609+
"PollAnswerCount",
610+
"PollLayoutType",
611+
"POLL_MAX_ANSWERS",
612+
"POLL_MAX_DURATION_HOURS",
613+
"PollMedia",
614+
"PollResults",
599615
"PREMIUM_GUILD_LIMITS",
600616
"PremiumTier",
601617
"PremiumType",

interactions/api/events/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@
4141
MessageCreate,
4242
MessageDelete,
4343
MessageDeleteBulk,
44+
MessagePollVoteAdd,
45+
MessagePollVoteRemove,
4446
MessageReactionAdd,
4547
MessageReactionRemove,
4648
MessageReactionRemoveAll,
@@ -159,6 +161,8 @@
159161
"MessageCreate",
160162
"MessageDelete",
161163
"MessageDeleteBulk",
164+
"MessagePollVoteAdd",
165+
"MessagePollVoteRemove",
162166
"MessageReactionAdd",
163167
"MessageReactionRemove",
164168
"MessageReactionRemoveAll",

interactions/api/events/discord.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ async def an_event_handler(event: ChannelCreate):
7272
"MessageCreate",
7373
"MessageDelete",
7474
"MessageDeleteBulk",
75+
"MessagePollVoteAdd",
76+
"MessagePollVoteRemove",
7577
"MessageReactionAdd",
7678
"MessageReactionRemove",
7779
"MessageReactionRemoveAll",
@@ -115,6 +117,7 @@ async def an_event_handler(event: ChannelCreate):
115117
from interactions.models.discord.entitlement import Entitlement
116118
from interactions.models.discord.guild import Guild, GuildIntegration
117119
from interactions.models.discord.message import Message
120+
from interactions.models.discord.poll import Poll
118121
from interactions.models.discord.reaction import Reaction
119122
from interactions.models.discord.role import Role
120123
from interactions.models.discord.scheduled_event import ScheduledEvent
@@ -588,6 +591,72 @@ class MessageReactionRemoveEmoji(MessageReactionRemoveAll):
588591
"""The emoji that was removed"""
589592

590593

594+
@attrs.define(eq=False, order=False, hash=False, kw_only=False)
595+
class BaseMessagePollEvent(BaseEvent):
596+
user_id: "Snowflake_Type" = attrs.field(repr=False)
597+
"""The ID of the user that voted"""
598+
channel_id: "Snowflake_Type" = attrs.field(repr=False)
599+
"""The ID of the channel the poll is in"""
600+
message_id: "Snowflake_Type" = attrs.field(repr=False)
601+
"""The ID of the message the poll is in"""
602+
answer_id: int = attrs.field(repr=False)
603+
"""The ID of the answer the user voted for"""
604+
guild_id: "Optional[Snowflake_Type]" = attrs.field(repr=False, default=None)
605+
"""The ID of the guild the poll is in"""
606+
607+
def get_message(self) -> "Optional[Message]":
608+
"""Get the message object if it is cached"""
609+
return self.client.cache.get_message(self.channel_id, self.message_id)
610+
611+
def get_user(self) -> "Optional[User]":
612+
"""Get the user object if it is cached"""
613+
return self.client.get_user(self.user_id)
614+
615+
def get_channel(self) -> "Optional[TYPE_ALL_CHANNEL]":
616+
"""Get the channel object if it is cached"""
617+
return self.client.get_channel(self.channel_id)
618+
619+
def get_guild(self) -> "Optional[Guild]":
620+
"""Get the guild object if it is cached"""
621+
return self.client.get_guild(self.guild_id) if self.guild_id is not None else None
622+
623+
def get_poll(self) -> "Optional[Poll]":
624+
"""Get the poll object if it is cached"""
625+
message = self.get_message()
626+
return message.poll if message is not None else None
627+
628+
async def fetch_message(self) -> "Message":
629+
"""Fetch the message the poll is in"""
630+
return await self.client.cache.fetch_message(self.channel_id, self.message_id)
631+
632+
async def fetch_user(self) -> "User":
633+
"""Fetch the user that voted"""
634+
return await self.client.fetch_user(self.user_id)
635+
636+
async def fetch_channel(self) -> "TYPE_ALL_CHANNEL":
637+
"""Fetch the channel the poll is in"""
638+
return await self.client.fetch_channel(self.channel_id)
639+
640+
async def fetch_guild(self) -> "Optional[Guild]":
641+
"""Fetch the guild the poll is in"""
642+
return await self.client.fetch_guild(self.guild_id) if self.guild_id is not None else None
643+
644+
async def fetch_poll(self) -> "Poll":
645+
"""Fetch the poll object"""
646+
message = await self.fetch_message()
647+
return message.poll
648+
649+
650+
@attrs.define(eq=False, order=False, hash=False, kw_only=False)
651+
class MessagePollVoteAdd(BaseMessagePollEvent):
652+
"""Dispatched when a user votes in a poll"""
653+
654+
655+
@attrs.define(eq=False, order=False, hash=False, kw_only=False)
656+
class MessagePollVoteRemove(BaseMessagePollEvent):
657+
"""Dispatched when a user remotes a votes in a poll"""
658+
659+
591660
@attrs.define(eq=False, order=False, hash=False, kw_only=False)
592661
class PresenceUpdate(BaseEvent):
593662
"""A user's presence has changed."""

interactions/api/events/processors/message_events.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,41 @@ async def _on_raw_message_delete_bulk(self, event: "RawGatewayEvent") -> None:
8383
event.data.get("ids"),
8484
)
8585
)
86+
87+
@Processor.define()
88+
async def _on_raw_message_poll_vote_add(self, event: "RawGatewayEvent") -> None:
89+
"""
90+
Process raw message poll vote add event and dispatch a processed poll vote add event.
91+
92+
Args:
93+
event: raw poll vote add event
94+
95+
"""
96+
self.dispatch(
97+
events.MessagePollVoteAdd(
98+
event.data.get("guild_id", None),
99+
event.data["channel_id"],
100+
event.data["message_id"],
101+
event.data["user_id"],
102+
event.data["option"],
103+
)
104+
)
105+
106+
@Processor.define()
107+
async def _on_raw_message_poll_vote_remove(self, event: "RawGatewayEvent") -> None:
108+
"""
109+
Process raw message poll vote remove event and dispatch a processed poll vote remove event.
110+
111+
Args:
112+
event: raw poll vote remove event
113+
114+
"""
115+
self.dispatch(
116+
events.MessagePollVoteRemove(
117+
event.data.get("guild_id", None),
118+
event.data["channel_id"],
119+
event.data["message_id"],
120+
event.data["user_id"],
121+
event.data["option"],
122+
)
123+
)

interactions/api/http/http_requests/messages.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
from typing import TYPE_CHECKING, cast
1+
from typing import TYPE_CHECKING, cast, TypedDict
22

33
import discord_typings
44

55
from interactions.models.internal.protocols import CanRequest
6+
from interactions.client.utils.serializer import dict_filter_none
67
from ..route import Route
78

89
__all__ = ("MessageRequests",)
@@ -13,6 +14,10 @@
1314
from interactions import UPLOADABLE_TYPE
1415

1516

17+
class GetAnswerVotersData(TypedDict):
18+
users: list[discord_typings.UserData]
19+
20+
1621
class MessageRequests(CanRequest):
1722
async def create_message(
1823
self,
@@ -175,3 +180,59 @@ async def crosspost_message(
175180
)
176181
)
177182
return cast(discord_typings.MessageData, result)
183+
184+
async def get_answer_voters(
185+
self,
186+
channel_id: "Snowflake_Type",
187+
message_id: "Snowflake_Type",
188+
answer_id: int,
189+
after: "Snowflake_Type | None" = None,
190+
limit: int = 25,
191+
) -> GetAnswerVotersData:
192+
"""
193+
Get a list of users that voted for this specific answer.
194+
195+
Args:
196+
channel_id: Channel the message is in
197+
message_id: The message with the poll
198+
answer_id: The answer to get voters for
199+
after: Get messages after this user ID
200+
limit: The max number of users to return (default 25, max 100)
201+
202+
Returns:
203+
GetAnswerVotersData: A response that has a list of users that voted for the answer
204+
205+
"""
206+
result = await self.request(
207+
Route(
208+
"GET",
209+
"/channels/{channel_id}/polls/{message_id}/answers/{answer_id}",
210+
channel_id=channel_id,
211+
message_id=message_id,
212+
answer_id=answer_id,
213+
),
214+
params=dict_filter_none({"after": after, "limit": limit}),
215+
)
216+
return cast(GetAnswerVotersData, result)
217+
218+
async def end_poll(self, channel_id: "Snowflake_Type", message_id: "Snowflake_Type") -> discord_typings.MessageData:
219+
"""
220+
Ends a poll. Only can end polls from the current bot.
221+
222+
Args:
223+
channel_id: Channel the message is in
224+
message_id: The message with the poll
225+
226+
Returns:
227+
message object
228+
229+
"""
230+
result = await self.request(
231+
Route(
232+
"POST",
233+
"/channels/{channel_id}/polls/{message_id}/expire",
234+
channel_id=channel_id,
235+
message_id=message_id,
236+
)
237+
)
238+
return cast(discord_typings.MessageData, result)

interactions/client/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
MISSING,
3030
MENTION_PREFIX,
3131
PREMIUM_GUILD_LIMITS,
32+
POLL_MAX_ANSWERS,
33+
POLL_MAX_DURATION_HOURS,
3234
Absent,
3335
T,
3436
T_co,
@@ -61,6 +63,8 @@
6163
"EMBED_MAX_FIELDS",
6264
"EMBED_TOTAL_MAX",
6365
"EMBED_FIELD_VALUE_LENGTH",
66+
"POLL_MAX_ANSWERS",
67+
"POLL_MAX_DURATION_HOURS",
6468
"Singleton",
6569
"Sentinel",
6670
"GlobalScope",

interactions/client/const.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@
8484
"NON_RESUMABLE_WEBSOCKET_CLOSE_CODES",
8585
"CLIENT_FEATURE_FLAGS",
8686
"has_client_feature",
87+
"POLL_MAX_ANSWERS",
88+
"POLL_MAX_DURATION_HOURS",
8789
)
8890

8991
_ver_info = sys.version_info
@@ -130,6 +132,9 @@ def get_logger() -> logging.Logger:
130132
EMBED_TOTAL_MAX = 6000
131133
EMBED_FIELD_VALUE_LENGTH = 1024
132134

135+
POLL_MAX_ANSWERS = 10
136+
POLL_MAX_DURATION_HOURS = 168
137+
133138

134139
class Singleton(type):
135140
_instances: ClassVar[dict] = {}

interactions/client/mixins/send.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from interactions.models.discord.components import BaseComponent
1111
from interactions.models.discord.embed import Embed
1212
from interactions.models.discord.message import AllowedMentions, Message, MessageReference
13+
from interactions.models.discord.poll import Poll
1314
from interactions.models.discord.sticker import Sticker
1415
from interactions.models.discord.snowflake import Snowflake_Type
1516

@@ -49,6 +50,7 @@ async def send(
4950
delete_after: Optional[float] = None,
5051
nonce: Optional[str | int] = None,
5152
enforce_nonce: bool = False,
53+
poll: "Optional[Poll | dict]" = None,
5254
**kwargs: Any,
5355
) -> "Message":
5456
"""
@@ -73,6 +75,7 @@ async def send(
7375
enforce_nonce: If enabled and nonce is present, it will be checked for uniqueness in the past few minutes. \
7476
If another message was created by the same author with the same nonce, that message will be returned \
7577
and no new message will be created.
78+
poll: A poll.
7679
7780
Returns:
7881
New message object that was sent.
@@ -115,6 +118,7 @@ async def send(
115118
flags=flags,
116119
nonce=nonce,
117120
enforce_nonce=enforce_nonce,
121+
poll=poll,
118122
**kwargs,
119123
)
120124

interactions/ext/hybrid_commands/context.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
Attachment,
2424
process_message_payload,
2525
TYPE_MESSAGEABLE_CHANNEL,
26+
Poll,
2627
)
2728
from interactions.models.discord.enums import ContextType
2829
from interactions.client.mixins.send import SendMixin
@@ -309,6 +310,7 @@ async def send(
309310
suppress_embeds: bool = False,
310311
silent: bool = False,
311312
flags: Optional[Union[int, "MessageFlags"]] = None,
313+
poll: "Optional[Poll | dict]" = None,
312314
delete_after: Optional[float] = None,
313315
ephemeral: bool = False,
314316
**kwargs: Any,
@@ -330,6 +332,7 @@ async def send(
330332
suppress_embeds: Should embeds be suppressed on this send
331333
silent: Should this message be sent without triggering a notification.
332334
flags: Message flags to apply.
335+
poll: A poll.
333336
delete_after: Delete message after this many seconds.
334337
ephemeral: Should this message be sent as ephemeral (hidden) - only works with interactions
335338
@@ -358,6 +361,7 @@ async def send(
358361
file=file,
359362
tts=tts,
360363
flags=flags,
364+
poll=poll,
361365
delete_after=delete_after,
362366
pass_self_into_delete=bool(self._slash_ctx),
363367
**kwargs,

0 commit comments

Comments
 (0)