Skip to content
This repository has been archived by the owner on Oct 2, 2023. It is now read-only.

Migrated Polls to Discord Interactions #162

Closed
wants to merge 16 commits into from
217 changes: 121 additions & 96 deletions general/polls/cog.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import re
import string
from typing import Optional, Tuple
from typing import Optional, Tuple, List

from discord import Embed, Message, PartialEmoji, Member, Forbidden, Guild
from discord import Embed, Message, PartialEmoji, Member, Forbidden, Guild, Interaction, SelectOption
from discord.ext import commands
from discord.ext.commands import Context, guild_only, CommandError
from discord.ui import View, Select
from discord.utils import utcnow

from PyDrocsid.cog import Cog
Expand All @@ -13,6 +14,7 @@
from PyDrocsid.events import StopEventHandling
from PyDrocsid.settings import RoleSettings
from PyDrocsid.translations import t
from PyDrocsid.redis import redis
from PyDrocsid.util import is_teamler, check_wastebasket
from .colors import Colors
from .permissions import PollsPermission
Expand All @@ -21,7 +23,7 @@
tg = t.g
t = t.polls

MAX_OPTIONS = 20 # Discord reactions limit
MAX_OPTIONS = 25 # Discord Select Item Limit

default_emojis = [name_to_emoji[f"regional_indicator_{x}"] for x in string.ascii_lowercase]

Expand All @@ -34,59 +36,121 @@ async def get_teampoll_embed(message: Message) -> Tuple[Optional[Embed], Optiona
return None, None


async def send_poll(
ctx: Context,
title: str,
args: str,
field: Optional[Tuple[str, str]] = None,
allow_delete: bool = True,
):
question, *options = [line.replace("\x00", "\n") for line in args.replace("\\\n", "\x00").split("\n") if line]

if not options:
raise CommandError(t.missing_options)
if len(options) > MAX_OPTIONS - allow_delete:
raise CommandError(t.too_many_options(MAX_OPTIONS - allow_delete))
class PollsCog(Cog, name="Polls"):
CONTRIBUTORS = [
Contributor.MaxiHuHe04,
Contributor.Defelo,
Contributor.TNT2k,
Contributor.wolflu,
Contributor.Tert0,
]

options = [PollOption(ctx, line, i) for i, line in enumerate(options)]
def __init__(self, team_roles: list[str]):
self.team_roles: list[str] = team_roles

if any(len(str(option)) > EmbedLimits.FIELD_VALUE for option in options):
raise CommandError(t.option_too_long(EmbedLimits.FIELD_VALUE))
async def send_poll(
self,
ctx: Context,
title: str,
args: str,
field: Optional[Tuple[str, str]] = None,
allow_delete: bool = True,
team_poll: bool = False,
):
question, *options = [line.replace("\x00", "\n") for line in args.replace("\\\n", "\x00").split("\n") if line]

embed = Embed(title=title, description=question, color=Colors.Polls, timestamp=utcnow())
embed.set_author(name=str(ctx.author), icon_url=ctx.author.display_avatar.url)
if allow_delete:
embed.set_footer(text=t.created_by(ctx.author, ctx.author.id), icon_url=ctx.author.display_avatar.url)
if len(options) != len(set(options)):
raise CommandError(t.option_name_duplicated)

if len(set(map(lambda x: x.emoji, options))) < len(options):
raise CommandError(t.option_duplicated)
if not options:
raise CommandError(t.missing_options)
if len(options) > MAX_OPTIONS - allow_delete:
raise CommandError(t.too_many_options(MAX_OPTIONS - allow_delete))

for option in options:
embed.add_field(name="** **", value=str(option), inline=False)
options = [PollOption(ctx, line, i) for i, line in enumerate(options)]

if field:
embed.add_field(name=field[0], value=field[1], inline=False)
if any(len(str(option)) > 100 for option in options): # Max Char Length of Select Option = 100
raise CommandError(t.option_too_long(EmbedLimits.FIELD_VALUE))

poll: Message = await ctx.send(embed=embed)
embed = Embed(title=title, description=question, color=Colors.Polls, timestamp=utcnow())
embed.set_author(name=str(ctx.author), icon_url=ctx.author.display_avatar.url)

try:
for option in options:
await poll.add_reaction(option.emoji)
if allow_delete:
await poll.add_reaction(name_to_emoji["wastebasket"])
except Forbidden:
raise CommandError(t.could_not_add_reactions(ctx.channel.mention))
embed.set_footer(text=t.created_by(ctx.author, ctx.author.id), icon_url=ctx.author.display_avatar.url)

if field:
embed.add_field(name=field[0], value=field[1], inline=False)

class PollsCog(Cog, name="Polls"):
CONTRIBUTORS = [Contributor.MaxiHuHe04, Contributor.Defelo, Contributor.TNT2k, Contributor.wolflu]
view = View(timeout=None)

def __init__(self, team_roles: list[str]):
self.team_roles: list[str] = team_roles
vote_select = Select(placeholder=t.poll_select_placeholder, max_values=len(options))

for _, option in enumerate(options):
vote_select.add_option(label=option.option, description=t.votes(cnt=0), emoji=option.emoji)

async def poll_vote(interaction: Interaction):
def get_option_by_label(label: str, select: Select) -> SelectOption:
possible_options: List[SelectOption] = list(filter(lambda option: option.label == label, select.options))
Tert0 marked this conversation as resolved.
Show resolved Hide resolved
if len(possible_options) == 1:
return possible_options[0]

def get_redis_key(message: Message, member: Member, option: SelectOption) -> str:
return f"poll_vote:{message.id}:{member.id}:{vote_select.options.index(option)}"

def get_team_poll_redis_key(message: Message, member: Member) -> str:
return f"team_poll_vote:{message.id}:{member.id}"

if interaction.data["component_type"] != 3: # Component Type 3 is the Select
return
if interaction.data.get("values") is None:
return
values = interaction.data["values"]
for value in values:
selected_option = get_option_by_label(value, vote_select)

redis_key: str = get_redis_key(interaction.message, interaction.user, selected_option)
team_poll_redis_key: str = get_team_poll_redis_key(interaction.message, interaction.user)
vote_value = 1
if await redis.exists(redis_key):
vote_value = -1
await redis.delete(redis_key)

vote_count: int = int(re.match(r"[\-]?[0-9]+", selected_option.description).group(0))
selected_option.description = t.votes(cnt=vote_count + vote_value)
if vote_value == 1:
await redis.set(redis_key, 1)
if team_poll:
await redis.sadd(team_poll_redis_key, vote_select.options.index(selected_option))
elif vote_value == -1:
if team_poll:
await redis.srem(team_poll_redis_key, vote_select.options.index(selected_option))

if team_poll:
_, index = await get_teampoll_embed(interaction.message)
value = await self.get_reacted_teamlers(interaction.message)
embed.set_field_at(index, name=tg.status, value=value, inline=False)

await interaction.message.edit(content="** **", embed=embed, view=view)

vote_select.callback = poll_vote
view.add_item(vote_select)

poll: Message = await ctx.send(content="** **", embed=embed, view=view)
# TODO Remove Content from Message
# Content will get sent as WorkARound for https://github.com/Pycord-Development/pycord/issues/192

try:
if allow_delete:
await poll.add_reaction(name_to_emoji["wastebasket"])
except Forbidden:
raise CommandError(t.could_not_add_reactions(ctx.channel.mention))

async def get_reacted_teamlers(self, message: Optional[Message] = None) -> str:
guild: Guild = self.bot.guilds[0]

def get_team_poll_redis_key(member: Member) -> str:
return f"team_poll_vote:{message.id}:{member.id}"

teamlers: set[Member] = set()
for role_name in self.team_roles:
if not (team_role := guild.get_role(await RoleSettings.get(role_name))):
Expand All @@ -95,11 +159,12 @@ async def get_reacted_teamlers(self, message: Optional[Message] = None) -> str:
teamlers.update(member for member in team_role.members if not member.bot)

if message:
for reaction in message.reactions:
if reaction.me:
teamlers.difference_update(await reaction.users().flatten())
teamlers = {
teamler for teamler in teamlers if len(await redis.smembers(get_team_poll_redis_key(teamler))) < 1
}

teamlers: list[Member] = list(teamlers)

if not teamlers:
return t.teampoll_all_voted

Expand All @@ -117,54 +182,14 @@ async def on_raw_reaction_add(self, message: Message, emoji: PartialEmoji, membe
await message.delete()
raise StopEventHandling

embed, index = await get_teampoll_embed(message)
if embed is None:
return

if not await is_teamler(member):
try:
await message.remove_reaction(emoji, member)
except Forbidden:
pass
raise StopEventHandling

for reaction in message.reactions:
if reaction.emoji == emoji.name:
break
else:
return

if not reaction.me:
return

value = await self.get_reacted_teamlers(message)
embed.set_field_at(index, name=tg.status, value=value, inline=False)
await message.edit(embed=embed)

async def on_raw_reaction_remove(self, message: Message, _, member: Member):
if member.bot or message.guild is None:
return
embed, index = await get_teampoll_embed(message)
if embed is not None:
user_reacted = False
for reaction in message.reactions:
if reaction.me and member in await reaction.users().flatten():
user_reacted = True
break
if not user_reacted and await is_teamler(member):
value = await self.get_reacted_teamlers(message)
embed.set_field_at(index, name=tg.status, value=value, inline=False)
await message.edit(embed=embed)
return

@commands.command(usage=t.poll_usage, aliases=["vote"])
@guild_only()
async def poll(self, ctx: Context, *, args: str):
"""
Starts a poll. Multiline options can be specified using a `\\` at the end of a line
"""

await send_poll(ctx, t.poll, args)
await self.send_poll(ctx, t.poll, args)

@commands.command(usage=t.poll_usage, aliases=["teamvote", "tp"])
@PollsPermission.team_poll.check
Expand All @@ -175,12 +200,13 @@ async def teampoll(self, ctx: Context, *, args: str):
Multiline options can be specified using a `\\` at the end of a line
"""

await send_poll(
await self.send_poll(
ctx,
t.team_poll,
args,
field=(tg.status, await self.get_reacted_teamlers()),
allow_delete=False,
team_poll=True,
)

@commands.command(aliases=["yn"])
Expand Down Expand Up @@ -216,17 +242,16 @@ async def team_yesno(self, ctx: Context, *, text: str):
Starts a yes/no poll and shows, which teamler has not voted yet.
"""

embed = Embed(title=t.team_poll, description=text, color=Colors.Polls, timestamp=utcnow())
embed.set_author(name=str(ctx.author), icon_url=ctx.author.display_avatar.url)

embed.add_field(name=tg.status, value=await self.get_reacted_teamlers(), inline=False)

message: Message = await ctx.send(embed=embed)
try:
await message.add_reaction(name_to_emoji["+1"])
await message.add_reaction(name_to_emoji["-1"])
except Forbidden:
raise CommandError(t.could_not_add_reactions(message.channel.mention))
await self.send_poll(
ctx,
t.team_poll,
f"""{text}
{name_to_emoji["+1"]} {t.yes}
{name_to_emoji["-1"]} {t.no}""",
field=(tg.status, await self.get_reacted_teamlers()),
allow_delete=False,
team_poll=True,
)


class PollOption:
Expand Down
10 changes: 9 additions & 1 deletion general/polls/translations/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ permissions:
delete: delete polls

poll: Poll
anonymous_poll: Anonymous Poll
team_poll: Team Poll
vote_explanation: Vote using the reactions below!
too_many_options: You specified too many options. The maximum amount is {}.
option_too_long: Options are limited to {} characters.
missing_options: Missing options
option_duplicated: You may not use the same emoji twice!
empty_option: Empty option
poll_usage: |
<question>
Expand All @@ -24,3 +24,11 @@ created_by: Created by @{} ({})
can_not_use_wastebucket_as_option: "You can not use :wastebasket: as option"
foreign_message: "You are not allowed to add yes/no reactions to foreign messages!"
could_not_add_reactions: Could not add reactions because I don't have `add_reactions` permission in {}.
poll_select_placeholder: Select an option to vote!
votes:
zero: "{cnt} votes"
Tert0 marked this conversation as resolved.
Show resolved Hide resolved
one: "{cnt} vote"
many: "{cnt} votes"
yes: Yes
no: No
option_name_duplicated: "You can't have a duplicated Option!"
Tert0 marked this conversation as resolved.
Show resolved Hide resolved