diff --git a/techsupport_bot/commands/voting.py b/techsupport_bot/commands/voting.py index ce64e6fb..d2810095 100644 --- a/techsupport_bot/commands/voting.py +++ b/techsupport_bot/commands/voting.py @@ -4,7 +4,7 @@ Voting This file contains 1 commands: - /voting + /vote """ from __future__ import annotations @@ -17,8 +17,7 @@ import discord import munch import ui -import ui.persistent_voting -from core import cogs, extensionconfig +from core import auxiliary, cogs, extensionconfig from discord import app_commands if TYPE_CHECKING: @@ -33,19 +32,20 @@ async def setup(bot: bot.TechSupportBot) -> None: """ config = extensionconfig.ExtensionConfig() config.add( - key="votes_channel_id", - datatype="str", - title="Votes channel", - description="The forum channel id as a string to start votes in", - default="", + key="votes_channel_roles", + datatype="dict[str, list[str]]", + title="Votes channels → allowed roles", + description=( + "Map of forum channel IDs to a list of role IDs. " + "User must have at least one role from the list." + ), + default={}, ) config.add( - key="ping_role_id", + key="active_role_id", datatype="str", - title="The role to ping when starting a vote", - description=( - "The role to ping when starting a vote, which will always be pinged" - ), + title="Active voter role", + description="User must have this role to start or participate in votes", default="", ) await bot.add_cog(Voting(bot=bot, extension_name="voting")) @@ -55,7 +55,27 @@ async def setup(bot: bot.TechSupportBot) -> None: class Voting(cogs.LoopCog): """The class that holds the core voting system""" - @app_commands.checks.has_permissions(manage_nicknames=True) + VOTE_CONFIG = { + "yes": { + "ids_field": "vote_ids_yes", + "count_field": "votes_yes", + "already_msg": "You have already voted yes", + "success_msg": "Your vote for yes has been counted", + }, + "no": { + "ids_field": "vote_ids_no", + "count_field": "votes_no", + "already_msg": "You have already voted no", + "success_msg": "Your vote for no has been counted", + }, + "abstain": { + "ids_field": "vote_ids_abstain", + "count_field": "votes_abstain", + "already_msg": "You have already voted to abstain", + "success_msg": "Your vote to abstain has been counted", + }, + } + @app_commands.command( name="vote", description="Starts a yes/no vote that runs for 72 hours", @@ -66,8 +86,9 @@ class Voting(cogs.LoopCog): async def votingbutton( self: Self, interaction: discord.Interaction, + channel: str, blind: bool = False, - anonymous: bool = False, + anonymous: bool = True, ) -> None: """Will open a modal @@ -79,27 +100,52 @@ async def votingbutton( This also hides who voted for what forever, and triggers it to be deleted from the database upon completion of the vote """ + config = self.bot.guild_configs[str(interaction.guild.id)] + channel = await interaction.guild.fetch_channel(int(channel)) + + if not self.user_can_use_vote_channel( + member=interaction.user, + channel=channel, + config=config, + ): + embed = auxiliary.prepare_deny_embed( + "You do not have rights to start that vote!" + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + return form = ui.VoteCreation() await interaction.response.send_modal(form) await form.wait() - config = self.bot.guild_configs[str(interaction.guild.id)] - channel = await interaction.guild.fetch_channel( - int(config.extensions.voting.votes_channel_id.value) - ) + # Fetch all roles from the guild roles = await interaction.guild.fetch_roles() - role = next( - role - for role in roles - if role.id == int(config.extensions.voting.ping_role_id.value) + + # Get the allowed role IDs for this channel from the config + channel_role_map: dict[str, list[str]] = ( + config.extensions.voting.votes_channel_roles.value ) + allowed_role_ids = channel_role_map.get(str(channel.id), []) + + # Build a list of discord.Role objects + ping_roles: list[discord.Role] = [ + role for role in roles if str(role.id) in allowed_role_ids + ] + + # Build the mention string + roles_to_ping = " ".join(role.mention for role in ping_roles) + + eligible_voters = await self.calculate_eligible_voters( + channel, interaction.guild + ) + eligible_voters = "," + ",".join(str(voter.id) for voter in eligible_voters) vote = await self.bot.models.Votes( guild_id=str(interaction.guild.id), message_id="0", vote_owner_id=str(interaction.user.id), vote_description=form.vote_reason.value, + vote_ids_eligible=eligible_voters, anonymous=anonymous, blind=blind, ).create() @@ -111,7 +157,7 @@ async def votingbutton( name=f"VOTE: {form.vote_short}", allowed_mentions=discord.AllowedMentions(roles=True), embed=embed, - content=role.mention, + content=roles_to_ping, view=view, ) @@ -123,6 +169,100 @@ async def votingbutton( thread_id=str(vote_thread.id), message_id=str(vote_message.id) ).apply() + @votingbutton.autocomplete("channel") + async def vote_channel_autocomplete( + self, + interaction: discord.Interaction, + current: str, + ) -> list[app_commands.Choice[str]]: + """This is the autocomplete for the voting + It will show the user what channel(s) they can start a vote in + + Args: + interaction (discord.Interaction): The interaction that is causing the lookup + current (str): The current string that the user has typed + + Returns: + list[app_commands.Choice[str]]: The list of channels that match the current string + """ + config = self.bot.guild_configs.get(str(interaction.guild.id)) + if not config: + return [] + + member = interaction.user + if not isinstance(member, discord.Member): + return [] + + channel_role_map = config.extensions.voting.votes_channel_roles.value + + choices: list[app_commands.Choice[str]] = [] + + for channel_id in channel_role_map.keys(): + channel = interaction.guild.get_channel(int(channel_id)) + if not channel: + continue + + # Optional name filter (autocomplete) + if current.lower() not in channel.name.lower(): + continue + + if not self.user_can_use_vote_channel( + member=member, + channel=channel, + config=config, + ): + continue + + choices.append( + app_commands.Choice( + name=f"#{channel.name}", + value=str(channel.id), + ) + ) + + return choices[:25] + + def user_can_use_vote_channel( + self: Self, + member: discord.Member, + channel: discord.abc.GuildChannel, + config: munch.Munch, + ) -> bool: + """This checks if the user can start a vote in a given channel + + Args: + member (discord.Member): The member that is trying to start a vote + channel (discord.abc.GuildChannel): The channel the vote is going to be started in + config (munch.Munch): The guild config for the current guild + + Returns: + bool: True if the channel is valid, false if its not + """ + if not isinstance(channel, discord.ForumChannel): + return False + + voting_config = config.extensions.voting + + active_role_id: str = voting_config.active_role_id.value + channel_role_map: dict[str, list[str]] = voting_config.votes_channel_roles.value + + # Channel must be configured + allowed_role_ids = channel_role_map.get(str(channel.id)) + if not allowed_role_ids: + return False + + user_role_ids = {str(role.id) for role in member.roles} + + # Must have the active role + if active_role_id not in user_role_ids: + return False + + # Must have at least one channel-specific role + if not user_role_ids.intersection(allowed_role_ids): + return False + + return True + async def search_db_for_vote_by_id(self: Self, vote_id: int) -> munch.Munch: """Gets a vote entry from the database by a given vote ID @@ -149,6 +289,57 @@ async def search_db_for_vote_by_message(self: Self, message_id: str) -> munch.Mu self.bot.models.Votes.message_id == message_id ).gino.first() + async def calculate_eligible_voters( + self: Self, + channel: discord.ForumChannel, + guild: discord.Guild, + ) -> list[discord.Member]: + """Gets a list of members that are eligible to vote, based on the forum channel + + Args: + self (Self): _description_ + channel (discord.ForumChannel): The channel the vote is run in + guild (discord.Guild): The guild that the vote is run in + + Returns: + list[discord.Member]: The list of eligible voters + """ + config = self.bot.guild_configs[str(guild.id)] + voting_config = config.extensions.voting + + channel_role_map: dict[str, list[str]] = voting_config.votes_channel_roles.value + active_role_id: str = voting_config.active_role_id.value + + active_role = guild.get_role(int(active_role_id)) + if not active_role: + return [] + + channel_role_ids = channel_role_map.get(str(channel.id)) + if not channel_role_ids: + return [] + + channel_roles = [ + guild.get_role(int(role_id)) + for role_id in channel_role_ids + if guild.get_role(int(role_id)) is not None + ] + + if not channel_roles: + return [] + + # Members with the active role + active_members = set(active_role.members) + + # Members with ANY channel role + channel_members: set[discord.Member] = set() + for role in channel_roles: + channel_members.update(role.members) + + # Voters must have both roles + eligible_members = active_members & channel_members + + return [member for member in eligible_members if not member.bot] + async def build_vote_embed( self: Self, vote_id: int, guild: discord.Guild ) -> discord.Embed: @@ -179,20 +370,33 @@ async def build_vote_embed( ), inline=False, ) + embed.add_field( + name="Eligible voters", + value=await self.make_named_eligible_list(guild, db_entry), + inline=False, + ) embed.add_field( name="Votes", value=await self.make_fancy_voting_list( guild, db_entry.vote_ids_yes.split(","), db_entry.vote_ids_no.split(","), + db_entry.vote_ids_abstain.split(","), (db_entry.vote_active and hide) or db_entry.anonymous, ), ) print_yes_votes = "?" if (hide and db_entry.vote_active) else db_entry.votes_yes print_no_votes = "?" if (hide and db_entry.vote_active) else db_entry.votes_no + print_abstain_votes = ( + "?" if (hide and db_entry.vote_active) else db_entry.votes_abstain + ) embed.add_field( name="Vote counts", - value=f"Votes for yes: {print_yes_votes}\nVotes for no: {print_no_votes}", + value=( + f"Votes for yes: {print_yes_votes}\n" + f"Votes for no: {print_no_votes}\n" + f"Votes to abstain: {print_abstain_votes}" + ), ) footer_str = f"Vote ID: {db_entry.vote_id}. " if db_entry.blind: @@ -200,13 +404,34 @@ async def build_vote_embed( if db_entry.anonymous: footer_str += "This vote is anonymous. " embed.set_footer(text=footer_str) + embed.color = discord.Color.blurple() return embed + async def make_named_eligible_list( + self: Self, guild: discord.Guild, db_entry: munch.Munch + ) -> str: + """This builds a pretty list of eligible voters + This uses the vote_ids_eligible + + Args: + guild (discord.Guild): The guild the vote is in + db_entry (munch.Munch): The db_entry for the vote + + Returns: + str: A comma separated string of names + """ + voter_ids = (v for v in db_entry.vote_ids_eligible.split(",") if v) + voter_names = [ + (await guild.fetch_member(int(v))).display_name for v in voter_ids + ] + return ", ".join(sorted(voter_names, key=str.lower)) + async def make_fancy_voting_list( self: Self, guild: discord.Guild, voters_yes: list[str], voters_no: list[str], + voters_abstain: list[str], should_hide: bool, ) -> str: """This makes a new line seperated string to be used in the "Votes" field @@ -216,12 +441,13 @@ async def make_fancy_voting_list( guild (discord.Guild): The guild this vote is taking place in voters_yes (list[str]): The list of IDs of yes votes voters_no (list[str]): The list of IDs of no votes + voters_abstain (list[str]): The list of IDs of abstian votes should_hide (bool): Should who voted for what be hidden Returns: str: The prepared string, that respects blind/anonymous """ - voters = voters_yes + voters_no + voters = voters_yes + voters_no + voters_abstain final_str = [] for user in voters: if len(user) == 0: @@ -231,91 +457,58 @@ async def make_fancy_voting_list( final_str.append(f"{user_object.display_name} - ?") elif user in voters_yes: final_str.append(f"{user_object.display_name} - yes") - else: + elif user in voters_no: final_str.append(f"{user_object.display_name} - no") + else: + final_str.append(f"{user_object.display_name} - abstain") final_str.sort() return "\n".join(final_str) - async def register_yes_vote( + async def register_vote( self: Self, interaction: discord.Interaction, view: discord.ui.View, + vote_type: str, ) -> None: - """This updates the vote database when someone votes yes + vote_config = self.VOTE_CONFIG[vote_type] + user_id = str(interaction.user.id) - Args: - interaction (discord.Interaction): The interaction that started the vote - view (discord.ui.View): The view that was interacted with - """ db_entry = await self.search_db_for_vote_by_message(str(interaction.message.id)) - # Update vote_ids_yes - vote_ids_yes = db_entry.vote_ids_yes.split(",") - if str(interaction.user.id) in vote_ids_yes: + # Check if voter is allowed to vote + vote_ids_eligible = db_entry.vote_ids_eligible.split(",") + if user_id not in vote_ids_eligible: await interaction.response.send_message( - "You have already voted yes", ephemeral=True + "You are not eligible to vote here.", ephemeral=True ) - return # Already voted yes, don't do anything more - - db_entry = self.clear_vote_record(db_entry, str(interaction.user.id)) - - vote_ids_yes.append(str(interaction.user.id)) - db_entry.vote_ids_yes = ",".join(vote_ids_yes) - - # Increment votes_yes - db_entry.votes_yes += 1 + return - # Update vote_ids_all - vote_ids_all = db_entry.vote_ids_all.split(",") - vote_ids_all.append(str(interaction.user.id)) - db_entry.vote_ids_all = ",".join(vote_ids_all) + # Get the correct vote_ids field dynamically + vote_ids = getattr(db_entry, vote_config["ids_field"]).split(",") - await db_entry.update( - vote_ids_no=db_entry.vote_ids_no, - votes_no=db_entry.votes_no, - vote_ids_yes=db_entry.vote_ids_yes, - votes_yes=db_entry.votes_yes, - vote_ids_all=db_entry.vote_ids_all, - ).apply() - - embed = await self.build_vote_embed(db_entry.vote_id, interaction.guild) - await interaction.message.edit(embed=embed, view=view) - await interaction.response.send_message( - "Your vote for yes has been counted", ephemeral=True - ) - - async def register_no_vote( - self: Self, - interaction: discord.Interaction, - view: discord.ui.View, - ) -> None: - """This updates the vote database when someone votes no - - Args: - interaction (discord.Interaction): The interaction that started the vote - view (discord.ui.View): The view that was interacted with - """ - db_entry = await self.search_db_for_vote_by_message(str(interaction.message.id)) - - # Update vote_ids_no - vote_ids_no = db_entry.vote_ids_no.split(",") - if str(interaction.user.id) in vote_ids_no: + if user_id in vote_ids: await interaction.response.send_message( - "You have already voted no", ephemeral=True + vote_config["already_msg"], ephemeral=True ) - return # Already voted no, don't do anything more + return - db_entry = self.clear_vote_record(db_entry, str(interaction.user.id)) + # Remove user from any previous vote + db_entry = self.clear_vote_record(db_entry, user_id) - vote_ids_no.append(str(interaction.user.id)) - db_entry.vote_ids_no = ",".join(vote_ids_no) + # Add vote + vote_ids.append(user_id) + setattr(db_entry, vote_config["ids_field"], ",".join(vote_ids)) - # Increment votes_no - db_entry.votes_no += 1 + # Increment counter + setattr( + db_entry, + vote_config["count_field"], + getattr(db_entry, vote_config["count_field"]) + 1, + ) # Update vote_ids_all vote_ids_all = db_entry.vote_ids_all.split(",") - vote_ids_all.append(str(interaction.user.id)) + vote_ids_all.append(user_id) db_entry.vote_ids_all = ",".join(vote_ids_all) await db_entry.update( @@ -323,13 +516,16 @@ async def register_no_vote( votes_no=db_entry.votes_no, vote_ids_yes=db_entry.vote_ids_yes, votes_yes=db_entry.votes_yes, + vote_ids_abstain=db_entry.vote_ids_abstain, + votes_abstain=db_entry.votes_abstain, vote_ids_all=db_entry.vote_ids_all, ).apply() embed = await self.build_vote_embed(db_entry.vote_id, interaction.guild) await interaction.message.edit(embed=embed, view=view) + await interaction.response.send_message( - "Your vote for no has been counted", ephemeral=True + vote_config["success_msg"], ephemeral=True ) async def clear_vote( @@ -345,6 +541,14 @@ async def clear_vote( """ db_entry = await self.search_db_for_vote_by_message(str(interaction.message.id)) + # Check if voter is allowed to vote + vote_ids_eligible = db_entry.vote_ids_eligible.split(",") + if str(interaction.user.id) not in vote_ids_eligible: + await interaction.response.send_message( + "You are not eligible to vote here.", ephemeral=True + ) + return + db_entry = self.clear_vote_record(db_entry, str(interaction.user.id)) await db_entry.update( @@ -352,6 +556,8 @@ async def clear_vote( votes_no=db_entry.votes_no, vote_ids_yes=db_entry.vote_ids_yes, votes_yes=db_entry.votes_yes, + vote_ids_abstain=db_entry.vote_ids_abstain, + votes_abstain=db_entry.votes_abstain, vote_ids_all=db_entry.vote_ids_all, ).apply() @@ -388,6 +594,13 @@ def clear_vote_record( db_entry.votes_no -= 1 db_entry.vote_ids_no = ",".join(vote_ids_no) + # If there is a vote for abstain, remote it + vote_ids_abstain = db_entry.vote_ids_abstain.split(",") + if user_id in vote_ids_abstain: + vote_ids_abstain.remove(user_id) + db_entry.votes_abstain -= 1 + db_entry.vote_ids_abstain = ",".join(vote_ids_abstain) + # Remove from vote id all vote_ids_all = db_entry.vote_ids_all.split(",") if user_id in vote_ids_all: @@ -437,7 +650,9 @@ async def end_vote(self: Self, vote: munch.Munch, guild: discord.Guild) -> None: embed = await self.build_vote_embed(vote.vote_id, guild) # If the vote is anonymous, at this point we need to clear the vote record forever if vote.anonymous: - await vote.update(vote_ids_yes="", vote_ids_no="").apply() + await vote.update( + vote_ids_yes="", vote_ids_no="", vote_ids_abstain="" + ).apply() channel = await guild.fetch_channel(int(vote.thread_id)) message = await channel.fetch_message(int(vote.message_id)) diff --git a/techsupport_bot/core/databases.py b/techsupport_bot/core/databases.py index db90914e..f3a9281f 100644 --- a/techsupport_bot/core/databases.py +++ b/techsupport_bot/core/databases.py @@ -333,10 +333,12 @@ class Votes(bot.db.Model): vote_description (str): The long form description of the vote vote_ids_yes (str): The comma separated list of who has voted yes vote_ids_no (str): The comma separated list of who has voted no + vote_ids_abstain (str): The comma separated list of who have abstained vote_ids_all (str): The comma separated list of who has voted + vote_ids_eligible (str): The comma separated list of all who can vote votes_yes (int): The number of votes for yes votes_no (int): The number of votes for no - votes_total (int): The number of votes + votes_abstain (int): The number of votes that have abstained start_time (datetime.datetime): The start time of the vote vote_active (bool): If the vote is current active or not blind (bool): If the vote needs to be blind @@ -353,10 +355,12 @@ class Votes(bot.db.Model): vote_description: str = bot.db.Column(bot.db.String) vote_ids_yes: str = bot.db.Column(bot.db.String, default="") vote_ids_no: str = bot.db.Column(bot.db.String, default="") + vote_ids_abstain: str = bot.db.Column(bot.db.String, default="") vote_ids_all: str = bot.db.Column(bot.db.String, default="") + vote_ids_eligible: str = bot.db.Column(bot.db.String, default="") votes_yes: int = bot.db.Column(bot.db.Integer, default=0) votes_no: int = bot.db.Column(bot.db.Integer, default=0) - votes_total: int = bot.db.Column(bot.db.Integer, default=0) + votes_abstain: int = bot.db.Column(bot.db.Integer, default=0) start_time: datetime.datetime = bot.db.Column( bot.db.DateTime, default=datetime.datetime.utcnow ) diff --git a/techsupport_bot/ui/persistent_voting.py b/techsupport_bot/ui/persistent_voting.py index 3312227d..9e58dfec 100644 --- a/techsupport_bot/ui/persistent_voting.py +++ b/techsupport_bot/ui/persistent_voting.py @@ -17,6 +17,7 @@ def __init__(self: Self) -> None: label="Yes, make changes", style=discord.ButtonStyle.green, custom_id="persistent_voting_view:yes", + row=0, ) async def yes_button( self: Self, interaction: discord.Interaction, button: discord.ui.Button @@ -29,12 +30,13 @@ async def yes_button( button (discord.ui.Button): The button object itself """ cog = interaction.client.get_cog("Voting") - await cog.register_yes_vote(interaction, self) + await cog.register_vote(interaction, self, "yes") @discord.ui.button( label="No, don't make changes", style=discord.ButtonStyle.red, custom_id="persistent_voting_view:no", + row=0, ) async def no_button( self: Self, interaction: discord.Interaction, button: discord.ui.Button @@ -47,12 +49,32 @@ async def no_button( button (discord.ui.Button): The button object itself """ cog = interaction.client.get_cog("Voting") - await cog.register_no_vote(interaction, self) + await cog.register_vote(interaction, self, "no") @discord.ui.button( - label="Clear vote", + label="Abstain from voting", + style=discord.ButtonStyle.blurple, + custom_id="persistent_voting_view:abstain", + row=1, + ) + async def abstain_button( + self: Self, interaction: discord.Interaction, button: discord.ui.Button + ) -> None: + """The button that is for voting yes. + Calls the yes function in the main commands/voting.py file + + Args: + interaction (discord.Interaction): The interaction created when the button was pressed + button (discord.ui.Button): The button object itself + """ + cog = interaction.client.get_cog("Voting") + await cog.register_vote(interaction, self, "abstain") + + @discord.ui.button( + label="Remove your vote", style=discord.ButtonStyle.grey, custom_id="persistent_voting_view:clear", + row=1, ) async def clear_button( self: Self, interaction: discord.Interaction, button: discord.ui.Button