-
Notifications
You must be signed in to change notification settings - Fork 20
Add anonymous multi-user vote command #191
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| """Add anonymous vote tables | ||
|
|
||
| Revision ID: e1a2b3c4d5e6 | ||
| Revises: d4f8c2a6e1b7 | ||
| Create Date: 2026-07-31 13:53:00.000000 | ||
|
|
||
| """ | ||
| import sqlalchemy as sa | ||
| from sqlalchemy.dialects import mysql | ||
|
|
||
| from alembic import op | ||
|
|
||
| # revision identifiers, used by Alembic. | ||
| revision = "e1a2b3c4d5e6" | ||
| down_revision = "d4f8c2a6e1b7" | ||
| branch_labels = None | ||
| depends_on = None | ||
|
|
||
|
|
||
| def upgrade() -> None: | ||
| op.create_table( | ||
| "anonymous_vote_session", | ||
| sa.Column("id", sa.Integer(), nullable=False), | ||
| sa.Column("guild_id", mysql.BIGINT(display_width=18), nullable=False), | ||
| sa.Column("channel_id", mysql.BIGINT(display_width=18), nullable=False), | ||
| sa.Column("message_id", mysql.BIGINT(display_width=18), nullable=True), | ||
| sa.Column("topic", mysql.TEXT(), nullable=True), | ||
| sa.Column("created_by_id", mysql.BIGINT(display_width=18), nullable=False), | ||
| sa.Column("closes_at", mysql.BIGINT(display_width=18), nullable=False), | ||
| sa.Column("closed", sa.Boolean(), nullable=False), | ||
| sa.PrimaryKeyConstraint("id"), | ||
| ) | ||
| op.create_table( | ||
| "anonymous_vote_candidate", | ||
| sa.Column("id", sa.Integer(), nullable=False), | ||
| sa.Column("session_id", sa.Integer(), nullable=False), | ||
| sa.Column("user_id", mysql.BIGINT(display_width=18), nullable=False), | ||
| sa.Column("display_name", mysql.TEXT(), nullable=False), | ||
| sa.ForeignKeyConstraint( | ||
| ["session_id"], | ||
| ["anonymous_vote_session.id"], | ||
| ondelete="CASCADE", | ||
| ), | ||
| sa.PrimaryKeyConstraint("id"), | ||
| ) | ||
| op.create_table( | ||
| "anonymous_vote_ballot", | ||
| sa.Column("id", sa.Integer(), nullable=False), | ||
| sa.Column("session_id", sa.Integer(), nullable=False), | ||
| sa.Column("candidate_id", sa.Integer(), nullable=False), | ||
| sa.Column("voter_id", mysql.BIGINT(display_width=18), nullable=False), | ||
| sa.Column("choice", sa.String(length=16), nullable=False), | ||
| sa.ForeignKeyConstraint( | ||
| ["candidate_id"], | ||
| ["anonymous_vote_candidate.id"], | ||
| ondelete="CASCADE", | ||
| ), | ||
| sa.ForeignKeyConstraint( | ||
| ["session_id"], | ||
| ["anonymous_vote_session.id"], | ||
| ondelete="CASCADE", | ||
| ), | ||
| sa.PrimaryKeyConstraint("id"), | ||
| sa.UniqueConstraint( | ||
| "session_id", | ||
| "candidate_id", | ||
| "voter_id", | ||
| name="uq_anonymous_vote_ballot_session_candidate_voter", | ||
| ), | ||
| ) | ||
|
|
||
|
|
||
| def downgrade() -> None: | ||
| op.drop_table("anonymous_vote_ballot") | ||
| op.drop_table("anonymous_vote_candidate") | ||
| op.drop_table("anonymous_vote_session") |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,19 +1,156 @@ | ||
| """Admin command group for bot administration commands.""" | ||
|
|
||
| import logging | ||
| import re | ||
| import time | ||
|
|
||
| import discord | ||
| from discord import ApplicationContext, Interaction, Option, WebhookMessage | ||
| from discord.ext import commands | ||
| from discord.ext.commands import has_any_role | ||
| from sqlalchemy import delete, select | ||
| from sqlalchemy.orm import selectinload | ||
|
|
||
| from src.bot import Bot | ||
| from src.core import settings | ||
| from src.database.models import AnonymousVoteCandidate, AnonymousVoteSession | ||
| from src.database.models.dynamic_role import RoleCategory | ||
| from src.database.session import AsyncSessionLocal | ||
| from src.helpers.duration import validate_duration | ||
| from src.views.anonymous_vote import ( | ||
| AnonymousVoteView, | ||
| build_poll_embed, | ||
| schedule_vote_close, | ||
| ) | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| CATEGORY_CHOICES = [c.value for c in RoleCategory] | ||
| MAX_VOTE_DURATION_SECONDS = 30 * 24 * 60 * 60 | ||
| # Discord select menus carry at most 25 options. | ||
| MAX_VOTE_NOMINEES = 25 | ||
| _MEMBER_TOKEN_RE = re.compile(r"<@!?(\d{15,20})>|(\d{15,20})") | ||
|
|
||
|
|
||
| def _parse_member_ids(raw: str) -> list[int]: | ||
| """Parse space/comma-separated mentions or snowflake IDs into unique IDs.""" | ||
| ids: list[int] = [] | ||
| for part in re.split(r"[\s,]+", raw.strip()): | ||
| if not part: | ||
| continue | ||
| match = _MEMBER_TOKEN_RE.fullmatch(part) | ||
| if not match: | ||
| raise ValueError( | ||
| f"Could not parse `{part}`. Use mentions or numeric user IDs." | ||
| ) | ||
| ids.append(int(match.group(1) or match.group(2))) | ||
| # Preserve order, drop duplicates | ||
| return list(dict.fromkeys(ids)) | ||
|
|
||
|
|
||
| async def _fetch_member(ctx: ApplicationContext, user_id: int) -> discord.Member | None: | ||
| """Return the guild member for *user_id*, or None when Discord has no such member.""" | ||
| member = ctx.guild.get_member(user_id) | ||
| if member is not None: | ||
| return member | ||
| try: | ||
| return await ctx.guild.fetch_member(user_id) | ||
| except discord.HTTPException: | ||
| return None | ||
|
|
||
|
|
||
| async def _lookup_members( | ||
| ctx: ApplicationContext, member_ids: list[int] | ||
| ) -> tuple[list[tuple[int, str]], list[str]]: | ||
| """Split nominee IDs into resolved (id, display name) pairs and unresolvable IDs.""" | ||
| resolved: list[tuple[int, str]] = [] | ||
| missing: list[str] = [] | ||
| for user_id in member_ids: | ||
| member = await _fetch_member(ctx, user_id) | ||
| if member is None: | ||
| missing.append(str(user_id)) | ||
| else: | ||
| resolved.append((member.id, member.display_name)) | ||
| return resolved, missing | ||
|
|
||
|
|
||
| async def _resolve_nominees(ctx: ApplicationContext, raw_members: str) -> list[tuple[int, str]]: | ||
| """Parse and resolve the nominee argument, raising ValueError with the user-facing reason.""" | ||
| member_ids = _parse_member_ids(raw_members) | ||
| if not member_ids: | ||
| raise ValueError("Provide at least one nominee.") | ||
| if len(member_ids) > MAX_VOTE_NOMINEES: | ||
| raise ValueError(f"Discord select menus support at most {MAX_VOTE_NOMINEES} nominees.") | ||
|
|
||
| resolved, missing = await _lookup_members(ctx, member_ids) | ||
| if missing: | ||
| raise ValueError("Could not find member(s) in this server: " + ", ".join(f"`{m}`" for m in missing)) | ||
| return resolved | ||
|
|
||
|
|
||
| def _validate_vote_duration(duration: str) -> tuple[int, str]: | ||
| """Validate the requested duration and cap how far out a vote may close.""" | ||
| closes_at_ts, error = validate_duration(duration) | ||
| if error: | ||
| return 0, error | ||
| if closes_at_ts - int(time.time()) > MAX_VOTE_DURATION_SECONDS: | ||
| return 0, "A vote can stay open for at most 30 days." | ||
| return closes_at_ts, "" | ||
|
|
||
|
|
||
| async def _create_vote_session( | ||
| ctx: ApplicationContext, | ||
| topic: str | None, | ||
| closes_at_ts: int, | ||
| nominees: list[tuple[int, str]], | ||
| ) -> tuple[int, discord.Embed, list[AnonymousVoteCandidate]]: | ||
| """Persist the session and its nominees; return the id, poll embed and candidates.""" | ||
| async with AsyncSessionLocal() as session: | ||
| vote_session = AnonymousVoteSession( | ||
| guild_id=ctx.guild.id, | ||
| channel_id=ctx.channel.id, | ||
| message_id=None, | ||
| topic=topic, | ||
| created_by_id=ctx.author.id, | ||
| closes_at=closes_at_ts, | ||
| closed=False, | ||
| ) | ||
| session.add(vote_session) | ||
| await session.flush() | ||
|
|
||
| for user_id, display_name in nominees: | ||
| session.add( | ||
| AnonymousVoteCandidate( | ||
| session_id=vote_session.id, | ||
| user_id=user_id, | ||
| display_name=display_name, | ||
| ) | ||
| ) | ||
| await session.commit() | ||
|
|
||
| loaded = await session.scalar( | ||
| select(AnonymousVoteSession) | ||
| .where(AnonymousVoteSession.id == vote_session.id) | ||
| .options(selectinload(AnonymousVoteSession.candidates)) | ||
| ) | ||
| candidates = list(loaded.candidates) | ||
| return loaded.id, build_poll_embed(loaded, candidates), candidates | ||
|
|
||
|
|
||
| async def _delete_vote_session(session_id: int) -> None: | ||
| """Drop a session that was never posted; its nominees and ballots cascade.""" | ||
| async with AsyncSessionLocal() as session: | ||
| await session.execute(delete(AnonymousVoteSession).where(AnonymousVoteSession.id == session_id)) | ||
| await session.commit() | ||
|
|
||
|
|
||
| async def _attach_poll_message(session_id: int, message_id: int) -> None: | ||
| """Record the posted message so the session can be edited and closed later.""" | ||
| async with AsyncSessionLocal() as session: | ||
| vote_session = await session.get(AnonymousVoteSession, session_id) | ||
| if vote_session: | ||
| vote_session.message_id = message_id | ||
| await session.commit() | ||
|
|
||
|
|
||
| class AdminCog(commands.Cog): | ||
|
|
@@ -149,6 +286,54 @@ async def reload(self, ctx: ApplicationContext) -> Interaction | WebhookMessage: | |
| await self.bot.role_manager.reload() | ||
| return await ctx.respond("Dynamic roles reloaded from database.", ephemeral=True) | ||
|
|
||
| @admin.command( | ||
| name="vote", | ||
| description="Start an anonymous timed vote on multiple members.", | ||
| ) | ||
| @has_any_role(*settings.role_groups.get("VOTE_STARTERS")) | ||
| async def vote( | ||
| self, | ||
| ctx: ApplicationContext, | ||
| members: Option( | ||
| str, | ||
| "Nominees as mentions or user IDs (space/comma separated, max 25)", | ||
| ), | ||
| duration: Option(str, "How long the vote stays open (e.g. 12h, 1d, 30m)"), | ||
| topic: Option(str, "Optional topic shown on the poll", required=False, max_length=200), | ||
| ) -> Interaction | WebhookMessage: | ||
| """Start an anonymous vote; tallies reveal automatically when duration ends.""" | ||
| # Resolving up to 25 nominees can outlast Discord's 3s initial-response deadline, | ||
| # after which ctx.defer() itself fails with 10062 Unknown interaction. | ||
| await ctx.defer(ephemeral=True) | ||
|
|
||
| closes_at_ts, error = _validate_vote_duration(duration) | ||
| if error: | ||
| return await ctx.respond(error, ephemeral=True) | ||
|
|
||
| try: | ||
| nominees = await _resolve_nominees(ctx, members) | ||
| except ValueError as exc: | ||
| return await ctx.respond(str(exc), ephemeral=True) | ||
|
|
||
| session_id, poll_embed, candidates = await _create_vote_session(ctx, topic, closes_at_ts, nominees) | ||
|
|
||
| view = AnonymousVoteView(session_id, self.bot, candidates) | ||
| self.bot.add_view(view) | ||
| try: | ||
| message = await ctx.channel.send(embed=poll_embed, view=view) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This posts to whatever channel the command happened to be run in, with no allowlist and no confirmation step in front of it. One wrong channel and the nominee names, the live activity boxes and the final approve/reject tallies are all readable by everyone who can see that channel, nominees included. For the CC program that feels like the worst possible version of a typo, since a rejected nominee would find out in public. Could we either pin the poll to a configured staff channel, or check |
||
| except discord.HTTPException: | ||
| logger.exception("Failed to post anonymous vote %s; rolling back session.", session_id) | ||
| await _delete_vote_session(session_id) | ||
| return await ctx.followup.send("Could not post the poll in this channel.", ephemeral=True) | ||
|
|
||
| await _attach_poll_message(session_id, message.id) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The rollback above covers the send failing, which was the important half. There is still a window here though: if the bot dies between That degrades reasonably rather than breaking, so I would not hold the PR on it. Worth a short comment in the code though, so the next person does not read |
||
| schedule_vote_close(self.bot, session_id, closes_at_ts) | ||
| return await ctx.followup.send( | ||
| f"Anonymous vote #{session_id} started in {ctx.channel.mention}. " | ||
| f"Closes <t:{closes_at_ts}:R>.", | ||
| ephemeral=True, | ||
| ) | ||
|
|
||
|
|
||
| def setup(bot: Bot) -> None: | ||
| """Load the AdminCog.""" | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Worst case this is 25 sequential round trips, which is exactly why you added the defer, so no correctness issue. Just noting that
guild.query_members(user_ids=member_ids)does the same lookup in a single gateway call and caps at 100 ids, so it would collapse the loop and be gentler on the member endpoint if you feel like it.