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
2 changes: 2 additions & 0 deletions .test.env
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ DATABASE__PASSWORD=not_a_password
HTB_API_SECRET=9BR5vXIXhcaasZGXujkEDvqxFyinPA
START_WEBHOOK_SERVER=True
WEBHOOK_TOKEN=JrvjfD0E-KV91-Bt8Y-ZLlg-uTEMk8SZXYvc
VOTE_HMAC_SECRET=test-vote-hmac-secret

# Sentry
SENTRY_DSN=
Expand All @@ -31,6 +32,7 @@ CHANNEL__SPOILER=2769521890099371011
CHANNEL__BOT_LOGS=1105517088266788925
CHANNEL__UNVERIFIED_BOT_COMMANDS=1430556712313688225
CHANNEL__HOW_TO_VERIFY=1432333413980835840
CHANNEL__VOTE=1432333413980835841

# Roles
ROLE__VERIFIED=1333333333333333337
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,11 @@ To set up and deploy the Discord bot, follow these steps:
| BOT__NAME | The name of the bot | "Hackster" |
| BOT__TOKEN | The token of the bot | *Required |
| CHANNEL__DEVLOG | The devlog channel id | 0 |
| CHANNEL__VOTE | Channel for `/admin vote` polls; 0 disables the command | 0 |
| DEBUG | Toggles debug mode | False |
| DEV_GUILD_IDS | The dev servers of the bot | [] |
| GUILD_IDS | The servers of the bot | *Required |
| VOTE_HMAC_SECRET | Key for hashing voter ids in `/admin vote`; empty disables the command | "" |

4. Now you are done! You can run the project using

Expand Down
77 changes: 77 additions & 0 deletions alembic/versions/e1a2b3c4d5e6_add_anonymous_vote_tables.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Add anonymous vote tables

Revision ID: e1a2b3c4d5e6
Revises: c8a4e2d6b9f1
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 = "c8a4e2d6b9f1"
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.Column("published_at", mysql.BIGINT(display_width=18), nullable=True),
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_hash", sa.String(length=64), 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_hash",
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
197 changes: 197 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 asyncio
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 _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."""
found: dict[int, discord.Member] = {}
for user_id in member_ids:
member = ctx.guild.get_member(user_id)
if member is not None:
found[user_id] = member

uncached = [user_id for user_id in member_ids if user_id not in found]
if uncached:
# One gateway request for every nominee the cache missed, instead of a REST call each.
try:
members = await ctx.guild.query_members(user_ids=uncached, limit=len(uncached))
except asyncio.TimeoutError:
raise ValueError("Discord did not answer the member lookup in time. Try again.") from None
for member in members:
found[member.id] = member

resolved = [(found[user_id].id, found[user_id].display_name) for user_id in member_ids if user_id in found]
missing = [str(user_id) for user_id in member_ids if user_id not in found]
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,66 @@ 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)

if not settings.channels.VOTE or not settings.VOTE_HMAC_SECRET:
return await ctx.respond(
"Anonymous votes are not set up yet. CHANNEL__VOTE and VOTE_HMAC_SECRET need to be configured.",
ephemeral=True,
)
# The poll shows nominees and, at close, their tallies, so it only goes in the staff vote channel.
if ctx.channel.id != settings.channels.VOTE:
return await ctx.respond(f"Start votes in <#{settings.channels.VOTE}>.", 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)
Comment thread
ToxicBiohazard marked this conversation as resolved.
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)

# If the bot stops before this write, the poll is live but the row keeps message_id NULL.
# The session still closes on schedule, but the results go out as a new message and the
# original poll keeps a select menu that no longer records votes.
await _attach_poll_message(session_id, message.id)
Comment thread
ToxicBiohazard marked this conversation as resolved.
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
18 changes: 18 additions & 0 deletions src/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ class ChannelsSettings(BaseModel):
BOT_LOGS: int
UNVERIFIED_BOT_COMMANDS: int = 0
HOW_TO_VERIFY: int = 0
# Staff channel /admin vote posts its polls to. 0 leaves the command disabled.
VOTE: int = 0

@field_validator(
"DEVLOG",
Expand All @@ -94,6 +96,7 @@ class ChannelsSettings(BaseModel):
"BOT_LOGS",
"UNVERIFIED_BOT_COMMANDS",
"HOW_TO_VERIFY",
"VOTE",
)
@classmethod
def check_ids_format(cls, value: int) -> int:
Expand Down Expand Up @@ -205,6 +208,8 @@ class Global(BaseSettings):

HTB_URL: str = "https://labs.hackthebox.com"
HTB_API_SECRET: str | None = None
# Keys the voter hashes stored for anonymous votes. Unset leaves /admin vote disabled.
VOTE_HMAC_SECRET: str = ""

START_WEBHOOK_SERVER: bool = False
WEBHOOK_PORT: int = 1337
Expand Down Expand Up @@ -252,6 +257,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
Loading
Loading