diff --git a/general/polls/cog.py b/general/polls/cog.py index ee9efefc5..da55abf69 100644 --- a/general/polls/cog.py +++ b/general/polls/cog.py @@ -1,18 +1,19 @@ 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 -from PyDrocsid.embeds import EmbedLimits from PyDrocsid.emojis import name_to_emoji, emoji_to_name 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 @@ -21,7 +22,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] @@ -34,59 +35,123 @@ 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(100)) - 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), + ) + 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) + + if await redis.exists(redis_key): + vote_value = -1 + await redis.delete(redis_key) + if team_poll: + await redis.srem(team_poll_redis_key, vote_select.options.index(selected_option)) + else: + 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)) + + 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 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))): @@ -95,11 +160,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 @@ -117,46 +183,6 @@ 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): @@ -164,7 +190,7 @@ 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 @@ -175,12 +201,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"]) @@ -216,17 +243,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: diff --git a/general/polls/translations/en.yml b/general/polls/translations/en.yml index e779d9255..c77aa0f3f 100644 --- a/general/polls/translations/en.yml +++ b/general/polls/translations/en.yml @@ -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: | @@ -24,3 +24,10 @@ 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: + one: "{cnt} vote" + many: "{cnt} votes" +yes: Yes +no: No +option_name_duplicated: "You can't have a duplicated Option!"