Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions alembic/versions/e1a2b3c4d5e6_add_anonymous_vote_tables.py
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")
6 changes: 6 additions & 0 deletions src/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,19 @@ async def on_ready(self) -> None:

async def _register_persistent_views(self) -> None:
"""Re-register persistent UI views so buttons survive bot restarts."""
from src.views.anonymous_vote import register_anonymous_vote_views
from src.views.bandecisionview import register_ban_views

try:
await register_ban_views(self)
except Exception:
logger.exception("Failed to register persistent ban decision views")

try:
await register_anonymous_vote_views(self)
except Exception:
logger.exception("Failed to register persistent anonymous vote views")

async def on_application_command(self, ctx: ApplicationContext) -> None:
"""A global handler cog."""
logger.debug(f"Command '{ctx.command}' received.")
Expand Down
185 changes: 185 additions & 0 deletions src/cmds/core/admin.py
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)
Comment on lines +68 to +69

Copy link
Copy Markdown
Member

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.

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):
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 ctx.channel.id against the relevant settings.channels entries before we get this far? Fine to leave as is if the intent is that the starter always picks deliberately, but right now nothing stops it.

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 ctx.channel.send returning and this update committing, the poll is live in the channel while the row keeps message_id = NULL. On restart the session is picked up as open, and at close _edit_poll_with_results bails on if not message_id and posts results as a fresh message, leaving the original poll sitting there with controls that still look usable.

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 message_id as always populated for a poll that got posted.

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."""
Expand Down
13 changes: 13 additions & 0 deletions src/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,19 @@ def role_groups(self) -> dict[str, list[int]]:
],
"ALL_HTB_STAFF": [self.roles.HTB_STAFF],
"ALL_HTB_SUPPORT": [self.roles.HTB_SUPPORT],
"VOTE_STARTERS": [
self.roles.ADMINISTRATOR,
self.roles.COMMUNITY_MANAGER,
self.roles.COMMUNITY_TEAM,
],
"VOTE_CASTERS": [
self.roles.ADMINISTRATOR,
self.roles.COMMUNITY_MANAGER,
self.roles.COMMUNITY_TEAM,
self.roles.SR_MODERATOR,
self.roles.MODERATOR,
self.roles.JR_MODERATOR,
],
}


Expand Down
1 change: 1 addition & 0 deletions src/database/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# flake8: noqa
from src.database.base_class import Base # noqa

from .anonymous_vote import AnonymousVoteBallot, AnonymousVoteCandidate, AnonymousVoteSession
from .ban import Ban
from .ctf import Ctf
from .dynamic_role import DynamicRole, RoleCategory
Expand Down
Loading