[Bot] Acknowledge Discord votes and RSVPs, and fix the Discord/web vote asymmetry #371
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Impact: HIGH
Found in the August 2026 session lifecycle review (#319).
What the user experiences
A player reacts to a Discord poll or RSVP message. Nothing happens — no confirmation, no error, nothing. If the vote failed to record (a transient API error, a stale mapping, a slot index out of range, a Redis outage), it looks exactly the same as a vote that succeeded: silence. Separately, a vote cast in the web app fires a visible "🗳️ Vote Update" embed in Discord, but a vote cast in Discord fires nothing back — the two paths behave completely differently for the same action. A GM also cannot add one more time slot to an already-open vote; the only tool that touches the slate is "Reopen for voting", which replaces it entirely and discards existing votes.
Evidence
bot/questboard_bot/cogs/voting.py:208-216— a successful vote onlylog.infos; a failed vote onlylog.warnings. No Discord-visible acknowledgement either way.bot/questboard_bot/cogs/voting.py:85-86— an unmappedmessage_idis a barereturn, indistinguishable from any other silent path.webapp/backend/app/routers/votes.py:83-128enqueues a vote-update notification;webapp/backend/app/routers/bot.py:207-249(the Discord-vote path) does not — so web votes get an embed and Discord votes get silence.bot/questboard_bot/cogs/voting.py:127-130— "add reaction = yes, remove reaction = no" is never explained to the user, and there is no way to express "maybe" from Discord even though the API supports it (bot/questboard_bot/api_client.py:238).webapp/backend/app/routers/votes.py:65-69rejects votes on a closed poll; the bot'sbot_submit_votepath has no equivalent status check, and the poll message is never edited or locked after confirmation — a reaction cast a week after confirmation silently upserts onto a confirmed session.redis_urldefaults to""and any outage falls back to an in-memory dict (bot/questboard_bot/cogs/notifications.py:133-137,162-164); in that state every reaction on every existing poll resolves tomapping is Noneand is dropped with no log line at all (voting.py:85-86). Polls look alive and are dead.webapp/frontend/src/api/sessions.js—addTimeslot/removeTimeslotare exported and never called anywhere in the frontend; the only way to change a live vote's slate is the destructive full reopen (SessionDetail.jsx:920-937).Why it matters for a hosted product
A lost vote is indistinguishable from a recorded one on the only channel most self-hosted groups actually vote through. A GM cannot tell whether the product is working, and a customer running Redis without persistence can have every open poll quietly dead without any signal.
Proposed fix
Add an ephemeral confirmation (or a bot reaction) on successful vote/RSVP reactions, and an ephemeral error on failure (
voting.py:208-216, the audit's P18). Enqueue the vote-update notification from the Discord-vote path (routers/bot.py:207-249) the same way the web route does, so both paths behave alike. Reject reactions on a closed poll the same way the web route already does. Log (not silently drop) the Redis-degraded-to-memory case. ExposeaddTimeslot/removeTimeslotas an "Add another time option" control on the open vote inSessionDetail, distinct from the destructive reopen.Acceptance criteria
SessionDetailexposes a control to add or remove a single time slot on an open vote without discarding existing votes.Picking this up as v4.3.0 phase 5, lane A (#514), right after #369. Decisions: reactions are not interactions, so the acknowledgement is a short channel message mentioning the player that deletes itself (15 s on success, 30 s with the reason on failure), never a DM; the Discord-vote path enqueues the same vote-update notification the web path does; a closed poll returns 409, the reaction is removed and the poll message is edited once to say the session is confirmed; the Redis in-memory fallback logs loudly;
SessionDetailgains add/remove of a single time slot on an open vote using the existing API.Done in PR #523 (auto-merging on green); ships with v4.3.0.
The issue's evidence was partly overtaken by #409, which had added DM-on-failure. Since reactions are not interactions there is no ephemeral reply, success needed acknowledging too (a DM per vote is unusable), and DMs are closed for a large share of users (#392), so all acknowledgement moved into the channel and DMs are gone from the cog entirely, including the unlinked-user prompt, which is now a self-deleting channel note. Every reaction gets a self-deleting message mentioning the player: 15 s on success ("your yes for Option A 🇦 is recorded"), 30 s on failure with the reason. The ❗-on-the-message fallback from #409 survives for when the channel send fails.
Symmetry: the vote-update notification was inline in
routers/votes.py; it is nowvote_service.notify_vote_cast, called by both the web and bot paths in the same commit.Closed polls:
bot_submit_votehad no status check at all, so a reaction on a confirmed session upserted a vote into a decision already made. It answers 409 now with a sentence naming the confirmed time (a distinct code from the 400 used for a malformed request, so the bot can tell "too late" from "nonsense"),api_clientraisesVotingClosedErroron top of #373'sQuestBoardApiError, and the bot removes the reaction and repeats the sentence with no retry offer. Confirming a session relabels the poll "🔒 Voting closed" so it stops inviting reactions; the option list stays as the record of what the table was asked, only the "How to vote" block goes."Maybe" scoped down, as the plan allowed: a third reaction per slot means ten seeded reactions on a five-option poll, ten to clear per rebuild, mutual exclusion between each pair, and about five more seconds of rate-limited seeding per post. The poll carries a one-line legend instead, which also finally states the rule that only ever lived in a docstring: add = yes, remove = no, maybe → the web app.
Slate editing needed more than wiring up the unused API calls. The bot's reaction→slot mapping is positional, so an edit that did not reach Discord would leave 🇧 pointing at whatever now sits second: a vote recorded against a time the voter did not pick. Both timeslot endpoints publish a new
session_slate_changedevent, and the bot rebuilds the poll embed, reactions and mapping in place, mapping first, so a reaction landing mid-rebuild is read against the slate the embed already shows.SessionDetailgets "+ Add another time option" and a per-slot Remove, never down below two options, with a confirm that names how many votes it would delete.Redis fallback: the in-memory fallback was completely silent with no
REDIS_URL. It warns once, loudly, naming the consequence, and every dropped tracked reaction is logged with its message id.No
BOT_CONTRACT_VERSIONbump, on the judgement that neither change makes an older bot get anything wrong:session_slate_changedis ignored as an unknown event type, and the 409 becomes the transport failure an older bot already handles (it says the vote did not count, which is true, with retry advice that cannot help, still better than the silent acceptance it replaces). Bot 432 tests pass (test_poll_lifecycle.pynew), backend 2405 / 13 skipped (test_discord_vote_symmetry.pynew), frontend 539.