Skip to content

Add anonymous multi-user vote command - #191

Open
MetaspIoit wants to merge 2 commits into
hackthebox:mainfrom
MetaspIoit:votes
Open

Add anonymous multi-user vote command#191
MetaspIoit wants to merge 2 commits into
hackthebox:mainfrom
MetaspIoit:votes

Conversation

@MetaspIoit

Copy link
Copy Markdown

Summary

  • Adds /admin vote for Administrator, Community Manager, and Community Team to start a timed anonymous poll over multiple Discord members
  • Eligible staff (Admin/CM/CT + mod roles) cast Approve/Reject votes; identities stay private and exact ✓/✗ tallies stay hidden until auto-close
  • Public embed lists nominees and shows a neutral activity box per ballot cast; results post automatically when the duration ends
  • Includes DB models, Alembic migration, persistent view registration/reschedule on restart, and role-group config coverage

Test plan

  • Run migrations (alembic upgrade head)
  • Start bot and confirm /admin vote appears for Admin/CM/CT only
  • Start a vote with multiple members and a short duration (e.g. 2m)
  • As a mod role, select a nominee and Approve/Reject; confirm ephemeral confirmation and a neutral activity box appears next to that nominee
  • Confirm changing your own vote does not add another box
  • Confirm unauthorized users cannot vote
  • Wait for close and confirm results show approve/reject totals with no voter names
  • Restart bot mid-vote and confirm buttons still work and the poll still auto-closes

Made with Cursor

Allow Admin/CM/Community Team to start timed anonymous polls so staff can approve or reject nominees without revealing voter identity or live tallies until close.

Co-authored-by: Cursor <cursoragent@cursor.com>
@MetaspIoit

Copy link
Copy Markdown
Author

can't code so had to use cursor. This is to help FalconSpy with our CC program via discord to keep moderation team votes anonymous.

PLEASE ASSESS AND VERIFY CODE BEFORE PUSHING TO MAIN.

@dimoschi

Copy link
Copy Markdown
Contributor

@MetaspIoit I've reviewed the PR and got some comments. Do you prefer me to post them, so you can try to fix them, or do you prefer me to take over?

@codecov

codecov Bot commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.60724% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 69.79%. Comparing base (1abfd4b) to head (700467d).

Files with missing lines Patch % Lines
src/bot.py 0.00% 5 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #191      +/-   ##
==========================================
+ Coverage   66.54%   69.79%   +3.25%     
==========================================
  Files          54       56       +2     
  Lines        3177     3536     +359     
==========================================
+ Hits         2114     2468     +354     
- Misses       1063     1068       +5     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Concurrency and correctness fixes on top of the initial implementation, plus
tests for the command and the view.

Voting:
- Read the chosen nominee from each interaction's own payload. py-cord shares
  the Select item across all voters and overwrites its state per interaction
  while callbacks run in later tasks, so two voters could cross nominees.
- Move Approve/Reject onto a private per-voter ballot, replacing a module-level
  dict of pending selections that was lost on restart.
- Re-check VOTE_CASTERS when a ballot is cast, not only when it is issued, so a
  voter whose role is revoked mid-poll cannot still cast.
- Give the ballot an on_timeout. A finished view is evicted from the ViewStore,
  so a later click received no response at all and Discord showed 'This
  interaction failed' on buttons that still looked live.
- Defer before database work in every callback; AsyncSessionLocal uses NullPool
  and Discord's initial-response deadline is 3 seconds.

Persistence:
- Write ballots as a single upsert so a double-click cannot race
  uq_anonymous_vote_ballot_session_candidate_voter.
- Claim the close with a conditional UPDATE. The previous read-check-write
  straddled two awaits, and on_ready reschedules a close on every reconnect, so
  a long-running vote could publish its results more than once.
- Store closes_at as BIGINT epoch seconds, matching Ban.unban_time, instead of a
  TIMESTAMP that caps at 2038 and round-trips through session timezones.
- Delete the session row when the poll cannot be posted, rather than leaving it
  orphaned with no message id and no scheduled close.

Input handling:
- Bound topic to 200 characters. build_results_embed prefixes 'Results: ', so a
  longer topic exceeded the 256-character embed title limit and failed at close
  time, losing the tallies.
- Cap vote duration at 30 days and require nominee IDs to be snowflake-shaped.

Adds 72 tests covering the command and the view, including the approve/reject
tally, the activity-box truncation boundary, and the concurrency guards.
@dimoschi

Copy link
Copy Markdown
Contributor

Hi @MetaspIoit — thanks for this, the feature and the data model are a good shape and I wanted it to land. I've pushed a commit to your branch (700467d) rather than leave a long review, since most of it needed code to explain. Summary of what changed and why, and there are two things at the bottom I'd like your opinion on rather than assume.

Concurrency

  • Nominee selection could cross voters. The chosen nominee was read from the Select component, but py-cord shares that item across everyone using the message and overwrites its state per interaction (ViewStore.dispatch calls refresh_state synchronously, then runs the callback in a later task). Two people clicking at once could get each other's nominee. It now reads from each interaction's own payload.
  • Ballot writes raced the unique constraint. The select-then-insert meant two clicks in flight both saw no ballot and both inserted, tripping uq_anonymous_vote_ballot_session_candidate_voter. It's a single upsert now.
  • Closing could publish twice. The read-check-write in the close path straddled two awaits, and on_ready fires on every reconnect and reschedules a close — so a long-running vote accumulated closers that all fired together. The close is now claimed with a conditional UPDATE.

Discord API constraints

  • Every callback now defers before touching the database. AsyncSessionLocal uses NullPool, so each session opens a fresh connection, and the initial-response deadline is 3 seconds. /admin vote defers first too, since resolving 25 uncached nominees costs 25 sequential fetch_member calls.
  • topic is bounded to 200 characters. build_results_embed prefixes "Results: ", so a longer topic pushed the embed title past the 256-character limit and failed at close time, losing the tallies with no way to recover them.
  • A failed poll post no longer orphans the session row. If channel.send failed (missing Embed Links, say) the session was already committed, schedule_vote_close never ran, and the row resurfaced on the next restart as a poll nobody had seen. It's rolled back now.

Storage

closes_at moved from MySQL TIMESTAMP to BIGINT epoch seconds, matching Ban.unban_time. TIMESTAMP caps at 2038 and round-trips through session-timezone conversion, and validate_duration already hands you an epoch int. I edited the existing migration in place rather than adding a second one, since it hasn't shipped. Verified against MariaDB 10.11.2: full chain up, schema matches the models, downgrade and re-upgrade both clean.

Tests

Added 72 tests for the command and the view. Worth flagging one thing I got wrong first time: the tally logic (_ballot_counts_by_candidate, the activity-box truncation, the approve/reject counting) was initially at 95% line coverage with the arithmetic never actually executing, because every fixture defaulted to an empty ballot list. That's covered properly now, boundaries included.


Two things that were your calls, not mine

I'd rather ask than quietly redesign, and I'm happy to revert either.

1. Approve/Reject moved from public buttons to a private per-voter ballot. The crossing-nominees bug only strictly required reading from interaction.data. Moving the buttons into an ephemeral view was a further step, taken because the pending selection was otherwise held in a module-level dict that didn't survive a restart. It does change what the poll looks like, and that was your design decision. If you prefer the public buttons, the race fix stands on its own without it.

2. Vote duration is capped at 30 days. Nothing forced this — validate_duration accepts anything and the scheduler handles long sleeps. It just avoids holding a pending task for years. Easy to drop.

One note on the test plan

The checklist in the description now describes the old flow. If you're working through it: step 4 gives you a private ballot rather than buttons on the poll message, and there are two new paths worth a look — duration:50y should be refused with the cap message and start nothing, and a ballot left open for ~14 minutes should disable itself with an explanation rather than failing silently.

Known limitation, unchanged from your design

With a small pool of eligible voters, the live per-nominee activity boxes leak participation — you can tell how many people voted on each nominee, though not which way. The approve/reject split stays hidden until close. I left this as you built it; say the word if you'd prefer an aggregate count or nothing at all until close.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants