From 08b85fc218ef5b19ea4d5baf21e66b913ce53367 Mon Sep 17 00:00:00 2001 From: ajax146 <31014239+ajax146@users.noreply.github.com> Date: Thu, 16 Oct 2025 09:36:00 -0400 Subject: [PATCH 1/8] Add abstain feature to voting --- techsupport_bot/commands/voting.py | 98 +++++++++++++++++++++---- techsupport_bot/core/databases.py | 4 + techsupport_bot/ui/persistent_voting.py | 23 +++++- 3 files changed, 111 insertions(+), 14 deletions(-) diff --git a/techsupport_bot/commands/voting.py b/techsupport_bot/commands/voting.py index ce64e6fbb..f8027b8ff 100644 --- a/techsupport_bot/commands/voting.py +++ b/techsupport_bot/commands/voting.py @@ -17,7 +17,6 @@ import discord import munch import ui -import ui.persistent_voting from core import cogs, extensionconfig from discord import app_commands @@ -40,13 +39,13 @@ async def setup(bot: bot.TechSupportBot) -> None: default="", ) config.add( - key="ping_role_id", + key="ping_role_ids", datatype="str", - title="The role to ping when starting a vote", + title="The list of roles to ping when starting a vote", description=( - "The role to ping when starting a vote, which will always be pinged" + "The list of roles to ping when starting a vote, which will always be pinged" ), - default="", + default=[], ) await bot.add_cog(Voting(bot=bot, extension_name="voting")) bot.add_extension_config("voting", config) @@ -89,10 +88,10 @@ async def votingbutton( int(config.extensions.voting.votes_channel_id.value) ) roles = await interaction.guild.fetch_roles() - role = next( - role + roles_to_ping = " ".join( + role.mention for role in roles - if role.id == int(config.extensions.voting.ping_role_id.value) + if str(role.id) in config.extensions.voting.ping_role_ids.value ) vote = await self.bot.models.Votes( @@ -111,7 +110,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, ) @@ -185,14 +184,18 @@ async def build_vote_embed( 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}\nVotes for no: {print_no_votes}\nVotes to abstain: {print_abstain_votes}", ) footer_str = f"Vote ID: {db_entry.vote_id}. " if db_entry.blind: @@ -207,6 +210,7 @@ async def make_fancy_voting_list( 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 +220,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,8 +236,10 @@ 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) @@ -275,6 +282,8 @@ async def register_yes_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() @@ -284,6 +293,56 @@ async def register_yes_vote( "Your vote for yes has been counted", ephemeral=True ) + async def register_abstain_vote( + self: Self, + interaction: discord.Interaction, + view: discord.ui.View, + ) -> None: + """This updates the vote database when someone votes to abstain + + 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_abstain + vote_ids_abstain = db_entry.vote_ids_abstain.split(",") + if str(interaction.user.id) in vote_ids_abstain: + await interaction.response.send_message( + "You have already voted to abstian", ephemeral=True + ) + return # Already voted to abstian, don't do anything more + + db_entry = self.clear_vote_record(db_entry, str(interaction.user.id)) + + vote_ids_abstain.append(str(interaction.user.id)) + db_entry.vote_ids_abstain = ",".join(vote_ids_abstain) + + # Increment votes_abstian + db_entry.votes_abstain += 1 + + # 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) + + 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_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 to abstain has been counted", ephemeral=True + ) + async def register_no_vote( self: Self, interaction: discord.Interaction, @@ -323,6 +382,8 @@ 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() @@ -352,6 +413,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 +451,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 +507,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 7f5be522a..785ae7784 100644 --- a/techsupport_bot/core/databases.py +++ b/techsupport_bot/core/databases.py @@ -333,9 +333,11 @@ 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 votes_yes (int): The number of votes for yes votes_no (int): The number of votes for no + votes_abstain (int): The number of votes that have abstained votes_total (int): The number of votes start_time (datetime.datetime): The start time of the vote vote_active (bool): If the vote is current active or not @@ -353,9 +355,11 @@ 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="") votes_yes: int = bot.db.Column(bot.db.Integer, default=0) votes_no: int = bot.db.Column(bot.db.Integer, default=0) + votes_abstain: int = bot.db.Column(bot.db.Integer, default=0) votes_total: 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 3312227db..e300caa0a 100644 --- a/techsupport_bot/ui/persistent_voting.py +++ b/techsupport_bot/ui/persistent_voting.py @@ -31,10 +31,30 @@ async def yes_button( cog = interaction.client.get_cog("Voting") await cog.register_yes_vote(interaction, self) + @discord.ui.button( + 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_abstain_vote(interaction, self) + @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 @@ -50,9 +70,10 @@ async def no_button( await cog.register_no_vote(interaction, self) @discord.ui.button( - label="Clear vote", + 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 From 5d9581358f8eca77f28152c68f0376828df25f69 Mon Sep 17 00:00:00 2001 From: ajax146 <31014239+ajax146@users.noreply.github.com> Date: Thu, 16 Oct 2025 09:43:17 -0400 Subject: [PATCH 2/8] Formatting --- techsupport_bot/commands/voting.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/techsupport_bot/commands/voting.py b/techsupport_bot/commands/voting.py index f8027b8ff..7ff78c274 100644 --- a/techsupport_bot/commands/voting.py +++ b/techsupport_bot/commands/voting.py @@ -195,7 +195,11 @@ async def build_vote_embed( ) embed.add_field( name="Vote counts", - value=f"Votes for yes: {print_yes_votes}\nVotes for no: {print_no_votes}\nVotes to abstain: {print_abstain_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: From a527d80958bc0e3d2143e5647c1dc030c41ae6eb Mon Sep 17 00:00:00 2001 From: ajax146 <31014239+ajax146@users.noreply.github.com> Date: Mon, 5 Jan 2026 11:37:25 -0500 Subject: [PATCH 3/8] Make a bunch of improvements to voting --- techsupport_bot/commands/voting.py | 211 ++++++++++-------------- techsupport_bot/ui/persistent_voting.py | 6 +- 2 files changed, 91 insertions(+), 126 deletions(-) diff --git a/techsupport_bot/commands/voting.py b/techsupport_bot/commands/voting.py index 7ff78c274..e35743e91 100644 --- a/techsupport_bot/commands/voting.py +++ b/techsupport_bot/commands/voting.py @@ -47,6 +47,13 @@ async def setup(bot: bot.TechSupportBot) -> None: ), default=[], ) + config.add( + key="votes_channel_ids", + datatype="list[str]", + title="Votes channels", + description="Forum channels where votes may be started", + default=[], + ) await bot.add_cog(Voting(bot=bot, extension_name="voting")) bot.add_extension_config("voting", config) @@ -54,7 +61,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", @@ -65,8 +92,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,14 +107,16 @@ async def votingbutton( from the database upon completion of the vote """ + # Check if user is active + # Check if user is allowed to vote in given channel + 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) - ) + channel = await interaction.guild.fetch_channel(int(channel)) + roles = await interaction.guild.fetch_roles() roles_to_ping = " ".join( role.mention @@ -122,6 +152,37 @@ 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]]: + config = self.bot.guild_configs.get(str(interaction.guild.id)) + if not config: + return [] + + channel_ids = config.extensions.voting.votes_channel_ids.value + + choices: list[app_commands.Choice[str]] = [] + + for channel_id in channel_ids: + channel = interaction.guild.get_channel(int(channel_id)) + if not channel: + continue + + if current.lower() not in channel.name.lower(): + continue + + choices.append( + app_commands.Choice( + name=f"#{channel.name}", + value=str(channel.id), + ) + ) + + return choices[:25] # Just in case, 25 is the limit + 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 @@ -247,138 +308,43 @@ async def make_fancy_voting_list( 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, # "yes" | "no" | "abstain" ) -> None: - """This updates the vote database when someone votes yes + 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: - await interaction.response.send_message( - "You have already voted yes", 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 - - # 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, 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_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 yes has been counted", ephemeral=True - ) - - async def register_abstain_vote( - self: Self, - interaction: discord.Interaction, - view: discord.ui.View, - ) -> None: - """This updates the vote database when someone votes to abstain - - 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_abstain - vote_ids_abstain = db_entry.vote_ids_abstain.split(",") - if str(interaction.user.id) in vote_ids_abstain: + if user_id in vote_ids: await interaction.response.send_message( - "You have already voted to abstian", ephemeral=True + config["already_msg"], ephemeral=True ) - return # Already voted to abstian, don't do anything more - - db_entry = self.clear_vote_record(db_entry, str(interaction.user.id)) - - vote_ids_abstain.append(str(interaction.user.id)) - db_entry.vote_ids_abstain = ",".join(vote_ids_abstain) + return - # Increment votes_abstian - db_entry.votes_abstain += 1 + # Remove user from any previous vote + db_entry = self.clear_vote_record(db_entry, user_id) - # 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) - - 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_abstain=db_entry.vote_ids_abstain, - votes_abstain=db_entry.votes_abstain, - vote_ids_all=db_entry.vote_ids_all, - ).apply() + # Add vote + vote_ids.append(user_id) + setattr(db_entry, config["ids_field"], ",".join(vote_ids)) - 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 to abstain has been counted", ephemeral=True + # Increment counter + setattr( + db_entry, + config["count_field"], + getattr(db_entry, config["count_field"]) + 1, ) - 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: - await interaction.response.send_message( - "You have already voted no", ephemeral=True - ) - return # Already voted no, don't do anything more - - db_entry = self.clear_vote_record(db_entry, str(interaction.user.id)) - - vote_ids_no.append(str(interaction.user.id)) - db_entry.vote_ids_no = ",".join(vote_ids_no) - - # Increment votes_no - db_entry.votes_no += 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( @@ -393,9 +359,8 @@ async def register_no_vote( 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 - ) + + await interaction.response.send_message(config["success_msg"], ephemeral=True) async def clear_vote( self: Self, diff --git a/techsupport_bot/ui/persistent_voting.py b/techsupport_bot/ui/persistent_voting.py index e300caa0a..2260c761a 100644 --- a/techsupport_bot/ui/persistent_voting.py +++ b/techsupport_bot/ui/persistent_voting.py @@ -29,7 +29,7 @@ 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="Abstain from voting", @@ -48,7 +48,7 @@ async def abstain_button( button (discord.ui.Button): The button object itself """ cog = interaction.client.get_cog("Voting") - await cog.register_abstain_vote(interaction, self) + await cog.register_vote(interaction, self, "abstain") @discord.ui.button( label="No, don't make changes", @@ -67,7 +67,7 @@ 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="Remove your vote", From 97b055a9062ae2fca225f64444cf4908a24d41b2 Mon Sep 17 00:00:00 2001 From: ajax146 <31014239+ajax146@users.noreply.github.com> Date: Mon, 5 Jan 2026 14:48:21 -0500 Subject: [PATCH 4/8] Get channel selection and perms working --- techsupport_bot/commands/voting.py | 132 ++++++++++++++++++++++------- 1 file changed, 101 insertions(+), 31 deletions(-) diff --git a/techsupport_bot/commands/voting.py b/techsupport_bot/commands/voting.py index e35743e91..1c3c029aa 100644 --- a/techsupport_bot/commands/voting.py +++ b/techsupport_bot/commands/voting.py @@ -17,7 +17,7 @@ import discord import munch import ui -from core import cogs, extensionconfig +from core import auxiliary, cogs, extensionconfig from discord import app_commands if TYPE_CHECKING: @@ -32,27 +32,21 @@ 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="", - ) - config.add( - key="ping_role_ids", - datatype="str", - title="The list of roles to ping when starting a vote", + key="votes_channel_roles", + datatype="dict[str, list[str]]", + title="Votes channels → allowed roles", description=( - "The list of roles to ping when starting a vote, which will always be pinged" + "Map of forum channel IDs to a list of role IDs. " + "User must have at least one role from the list." ), - default=[], + default={}, ) config.add( - key="votes_channel_ids", - datatype="list[str]", - title="Votes channels", - description="Forum channels where votes may be started", - default=[], + key="active_role_id", + datatype="str", + 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")) bot.add_extension_config("voting", config) @@ -106,23 +100,40 @@ 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)) - # Check if user is active - # Check if user is allowed to vote in given 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(channel)) - + # Fetch all roles from the guild roles = await interaction.guild.fetch_roles() - roles_to_ping = " ".join( - role.mention - for role in roles - if str(role.id) in config.extensions.voting.ping_role_ids.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) vote = await self.bot.models.Votes( guild_id=str(interaction.guild.id), @@ -162,16 +173,25 @@ async def vote_channel_autocomplete( if not config: return [] - channel_ids = config.extensions.voting.votes_channel_ids.value + 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_ids: + for channel_id in channel_role_map.keys(): channel = interaction.guild.get_channel(int(channel_id)) if not channel: continue - if current.lower() not in channel.name.lower(): + if not self.user_can_use_vote_channel( + member=member, + channel=channel, + config=config, + name_filter=current, + ): continue choices.append( @@ -181,7 +201,57 @@ async def vote_channel_autocomplete( ) ) - return choices[:25] # Just in case, 25 is the limit + if len(choices) >= 25: + break + + return choices + + def user_can_use_vote_channel( + self: Self, + *, + member: discord.Member, + channel: discord.abc.GuildChannel, + config, + name_filter: str | None = None, + ) -> bool: + """ + Returns True if the user is allowed to use this channel for voting. + + Conditions: + - User has active_role_id + - Channel exists + - Channel is a ForumChannel + - User has at least one role mapped to the channel + - Channel name matches name_filter (if provided) + """ + 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 + + # Optional name filter (autocomplete) + if name_filter and name_filter.lower() not in channel.name.lower(): + 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 From 14a93aaaa8717622ab76fc8db8b874eb04d5fec3 Mon Sep 17 00:00:00 2001 From: ajax146 <31014239+ajax146@users.noreply.github.com> Date: Mon, 5 Jan 2026 14:52:39 -0500 Subject: [PATCH 5/8] Formatting changes --- techsupport_bot/commands/voting.py | 41 ++++++++++++++++++------------ 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/techsupport_bot/commands/voting.py b/techsupport_bot/commands/voting.py index 1c3c029aa..8b440288f 100644 --- a/techsupport_bot/commands/voting.py +++ b/techsupport_bot/commands/voting.py @@ -169,6 +169,16 @@ async def vote_channel_autocomplete( 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 [] @@ -186,6 +196,10 @@ async def vote_channel_autocomplete( if not channel: continue + # Optional name filter (autocomplete) + if current.lower() not in channel.name.lower(): + return False + if not self.user_can_use_vote_channel( member=member, channel=channel, @@ -208,21 +222,20 @@ async def vote_channel_autocomplete( def user_can_use_vote_channel( self: Self, - *, member: discord.Member, channel: discord.abc.GuildChannel, - config, - name_filter: str | None = None, + config: munch.Munch, ) -> bool: - """ - Returns True if the user is allowed to use this channel for voting. - - Conditions: - - User has active_role_id - - Channel exists - - Channel is a ForumChannel - - User has at least one role mapped to the channel - - Channel name matches name_filter (if provided) + """This checks if the user can start a vote in a given channel + + Args: + self (Self): _description_ + 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 @@ -247,10 +260,6 @@ def user_can_use_vote_channel( if not user_role_ids.intersection(allowed_role_ids): return False - # Optional name filter (autocomplete) - if name_filter and name_filter.lower() not in channel.name.lower(): - return False - return True async def search_db_for_vote_by_id(self: Self, vote_id: int) -> munch.Munch: From eb50dca05fa5ea4746bafe9d1ac5ec72411c87a7 Mon Sep 17 00:00:00 2001 From: ajax146 <31014239+ajax146@users.noreply.github.com> Date: Mon, 5 Jan 2026 14:53:23 -0500 Subject: [PATCH 6/8] Fix some bugs --- techsupport_bot/commands/voting.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/techsupport_bot/commands/voting.py b/techsupport_bot/commands/voting.py index 8b440288f..43d3d15a9 100644 --- a/techsupport_bot/commands/voting.py +++ b/techsupport_bot/commands/voting.py @@ -198,7 +198,7 @@ async def vote_channel_autocomplete( # Optional name filter (autocomplete) if current.lower() not in channel.name.lower(): - return False + continue if not self.user_can_use_vote_channel( member=member, @@ -215,10 +215,7 @@ async def vote_channel_autocomplete( ) ) - if len(choices) >= 25: - break - - return choices + return choices[:25] def user_can_use_vote_channel( self: Self, From 1907c9b32463d172d943d8e7f460c692970dae6d Mon Sep 17 00:00:00 2001 From: ajax146 <31014239+ajax146@users.noreply.github.com> Date: Mon, 5 Jan 2026 17:29:09 -0500 Subject: [PATCH 7/8] More changes --- techsupport_bot/commands/voting.py | 32 +++++++++++++++--------- techsupport_bot/core/databases.py | 4 +-- techsupport_bot/ui/persistent_voting.py | 33 +++++++++++++------------ 3 files changed, 40 insertions(+), 29 deletions(-) diff --git a/techsupport_bot/commands/voting.py b/techsupport_bot/commands/voting.py index 43d3d15a9..031bf848f 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 @@ -204,7 +204,6 @@ async def vote_channel_autocomplete( member=member, channel=channel, config=config, - name_filter=current, ): continue @@ -226,7 +225,6 @@ def user_can_use_vote_channel( """This checks if the user can start a vote in a given channel Args: - self (Self): _description_ 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 @@ -285,6 +283,16 @@ 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 + ): + # Only needs to be run at the start of the vote + config = self.bot.guild_configs[str(guild.id)] + # Get list of role IDs associated with the channel + # Get role ID of regular + # Produce union of individuals who have both + ... + async def build_vote_embed( self: Self, vote_id: int, guild: discord.Guild ) -> discord.Embed: @@ -388,19 +396,19 @@ async def register_vote( self: Self, interaction: discord.Interaction, view: discord.ui.View, - vote_type: str, # "yes" | "no" | "abstain" + vote_type: str, ) -> None: - config = self.VOTE_CONFIG[vote_type] + vote_config = self.VOTE_CONFIG[vote_type] user_id = str(interaction.user.id) db_entry = await self.search_db_for_vote_by_message(str(interaction.message.id)) # Get the correct vote_ids field dynamically - vote_ids = getattr(db_entry, config["ids_field"]).split(",") + vote_ids = getattr(db_entry, vote_config["ids_field"]).split(",") if user_id in vote_ids: await interaction.response.send_message( - config["already_msg"], ephemeral=True + vote_config["already_msg"], ephemeral=True ) return @@ -409,13 +417,13 @@ async def register_vote( # Add vote vote_ids.append(user_id) - setattr(db_entry, config["ids_field"], ",".join(vote_ids)) + setattr(db_entry, vote_config["ids_field"], ",".join(vote_ids)) # Increment counter setattr( db_entry, - config["count_field"], - getattr(db_entry, config["count_field"]) + 1, + vote_config["count_field"], + getattr(db_entry, vote_config["count_field"]) + 1, ) # Update vote_ids_all @@ -436,7 +444,9 @@ async def register_vote( 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(config["success_msg"], ephemeral=True) + await interaction.response.send_message( + vote_config["success_msg"], ephemeral=True + ) async def clear_vote( self: Self, diff --git a/techsupport_bot/core/databases.py b/techsupport_bot/core/databases.py index d936502ce..f3a9281fb 100644 --- a/techsupport_bot/core/databases.py +++ b/techsupport_bot/core/databases.py @@ -335,10 +335,10 @@ class Votes(bot.db.Model): 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_abstain (int): The number of votes that have abstained - votes_total (int): The number of votes 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 @@ -357,10 +357,10 @@ class Votes(bot.db.Model): 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_abstain: int = bot.db.Column(bot.db.Integer, default=0) - votes_total: 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 2260c761a..9e58dfec6 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 @@ -32,42 +33,42 @@ async def yes_button( await cog.register_vote(interaction, self, "yes") @discord.ui.button( - label="Abstain from voting", - style=discord.ButtonStyle.blurple, - custom_id="persistent_voting_view:abstain", - row=1, + label="No, don't make changes", + style=discord.ButtonStyle.red, + custom_id="persistent_voting_view:no", + row=0, ) - async def abstain_button( + async def no_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 + """The button that is for voting no. + Calls the no 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") + await cog.register_vote(interaction, self, "no") @discord.ui.button( - label="No, don't make changes", - style=discord.ButtonStyle.red, - custom_id="persistent_voting_view:no", - row=0, + label="Abstain from voting", + style=discord.ButtonStyle.blurple, + custom_id="persistent_voting_view:abstain", + row=1, ) - async def no_button( + async def abstain_button( self: Self, interaction: discord.Interaction, button: discord.ui.Button ) -> None: - """The button that is for voting no. - Calls the no function in the main commands/voting.py file + """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, "no") + await cog.register_vote(interaction, self, "abstain") @discord.ui.button( label="Remove your vote", From 4265b66f1c511abf72833f2cf2fd74f95e759fe3 Mon Sep 17 00:00:00 2001 From: ajax146 <31014239+ajax146@users.noreply.github.com> Date: Mon, 5 Jan 2026 20:22:32 -0500 Subject: [PATCH 8/8] Eligible voters implemented --- techsupport_bot/commands/voting.py | 102 +++++++++++++++++++++++++++-- 1 file changed, 95 insertions(+), 7 deletions(-) diff --git a/techsupport_bot/commands/voting.py b/techsupport_bot/commands/voting.py index 031bf848f..d28100950 100644 --- a/techsupport_bot/commands/voting.py +++ b/techsupport_bot/commands/voting.py @@ -135,11 +135,17 @@ async def votingbutton( # 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() @@ -284,14 +290,55 @@ async def search_db_for_vote_by_message(self: Self, message_id: str) -> munch.Mu ).gino.first() async def calculate_eligible_voters( - self: Self, channel: discord.ForumChannel, guild: discord.Guild - ): - # Only needs to be run at the start of the vote + 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)] - # Get list of role IDs associated with the channel - # Get role ID of regular - # Produce union of individuals who have both - ... + 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 @@ -323,6 +370,11 @@ 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( @@ -352,8 +404,28 @@ 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, @@ -403,6 +475,14 @@ async def register_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 user_id not in vote_ids_eligible: + await interaction.response.send_message( + "You are not eligible to vote here.", ephemeral=True + ) + return + # Get the correct vote_ids field dynamically vote_ids = getattr(db_entry, vote_config["ids_field"]).split(",") @@ -461,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(