[Bot] Acknowledge Discord votes and RSVPs, and fix the Discord/web vote asymmetry #371
Closed
opened 2026-08-25 20:42:11 +00:00 by claude-bot
·
2 comments
No Branch/Tag specified
main
feat/1328-fill-it-in
feat/1328-fill-frontend
feat/1328-fill-backend
feat/9-foundry-push
fix/9-foundry-live
feat/9-foundry-push-frontend
renovate/celery-5.x
feat/5-foundry-connect
feat/9-foundry-push-backend
feat/9-foundry-push-module
feat/5-foundry-frontend
feat/5-foundry-backend
feat/5-foundry-module
release/v4.8.0-rc1
docs/d341-convert-draft
feat/958-update-to-version
feat/958-convert-sheet
feat/958-pathbuilder-import-frontend
fix/1360-heading-order
feat/958-pathbuilder-import-backend
feat/11-weekly-digest
feat/1341-auto-update
docs/d332-superseded
chore/1345-names-cleanup
feat/1342-mentioned-pages
feat/1328-recipes
fix/1346-pgdg-key
perf/1247-shared-recording-stream
chore/132-gitleaks-allowlist
release/v4.7.1
spike/foundry-module
release/v4.7.1-rc1
docs/decisions-d332-d337
fix/bot-certifi
chore/release-4.7.0
chore/release-4.7.0-rc4
chore/deps-4.7.0
chore/release-4.7.0-rc3
fix/1280-quote-must-say-it
fix/1257-names-row-squeeze
chore/release-4.7.0-rc2
fix/1245-pg-upgrade-count
chore/release-4.7.0-rc1
feat/1257-names-in-prep
feat/1257-be
feat/1257-fe
feat/1257-rating-fixes
design/1328-fill-it-in
feat/1257-name-splits
fix/1326-redis-503
feat/1280-fact-suggestions
feat/1280-be
feat/1280-fe
design/1280-fact-suggestions
feat/1279-infobox
feat/1279-be
feat/1279-fe
feat/1321-shelf-copies
design/1279-infobox
feat/1243-table-find
feat/1257-who-rated
feat/1243-prep-drawer
feat/1243-sheet-pickers
design/1243-browse-in-place
feat/1315-hotwords-default
feat/1242-system-filters
feat/1257-more-name-styles
feat/506-leg3-hotwords
design/1242-system-filters
fix/1302-packs-polish
feat/1241-rules-library
design/1241-rules-library
feat/1245-postgres-18
integ/4.6.2
fix/1263-map-ui-2
fix/1263-map-server
fix/1263-map-ui-1
fix/1261-editor-link-menu
fix/1229-axe-4.14-name-mismatch
renovate/axllent-mailpit-1.x
renovate/eslint-monorepo
renovate/authlib-1.x
chore/release-4.6.0
chore/release-4.6.0-rc2
fix/460-rc2-screens
fix/460-rc2-server
fix/release-prerelease-exit
chore/release-4.6.0-rc1
feat/125-campaign-templates
feat/126-calendar
feat/1181-pictures-and-files
feat/1190-event-people
integ/4.6.0
feat/1191-line-of-the-night
feat/1200-starter-packs
chore/bot-multidict-and-playwright-group
fix/1202-this-week-answered-count
fix/122-124-leftovers
fix/1178-get-routes-that-write
fix/1203-journeys-check-names
design/125-campaign-templates
feat/126-calendar-engine
design/126-in-world-calendar
fix/1189-cited-stamp-in-range
fix/1189-v4-looser-check
fix/1189-richer-beats
fix/1113-export-bind-lists
fix/1154-release-notes-by-file
chore/release-4.5.0
chore/release-4.5.0-rc12
fix/1135-wiki-markup-as-text
fix/demo-usage-cost-in-window
chore/release-4.5.0-rc11
fix/1121-sheet-consistency
fix/1121-sheet-5e-terms-and-card-hp
copy/1122-busiest-pages
fix/1121-relevant-choices
fix/1121-wiki-create-npc-save-sheet-links
fix/1029-menu-stays-on-screen
docs/prototype-transcript-bin-wording
fix/1023-editor-save-row-back-scroll
fix/1020-search-exact-first
fix/1020-table-prep
fix/1020-shell-wordmark-footer-bell
fix/1020-wiki-article-party
fix/872-admin-kept-until-phone-tables
feat/l13-milestone-level-waiting
fix/1000-1001-bell-older-setup-context
fix/999-pronouns-in-prompts
fix/l19-other-class-feats
fix/1010-transcript-window-ordinary-confirm
docs/d254-d269-decisions
fix/702-member-search
fix/680-transcript-bin
fix/993-unidentified-prompts
fix/841-redis-timeouts
fix/990-binned-not-exported
fix/830-export-prep-shelf
fix/deps-audit-2026-10-01
fix/877-title-reads
fix/798-audio-received-at
build/lane11-17-j7
build/lane11-16-levelup
build/lane11-14-seams
build/lane11-14-seed
build/lane11-15-start
build/lane11-13-old-block-out
fix/980-review-r3
build/lane11-12-block
build/lane11-8-packs
fix/demo-seed-canonical-names
build/lane11-11-hot-half
docs/l11-decisions
build/lane11-7-convert
build/lane11-10-editing
fix/npm-audit-brace-expansion
build/lane11-9-sheet-page
fix/bot-pyjwt
build/lane11-6-content
fix/lane11-rank-names
build/lane11-2b-language
build/lane11-4-js-evaluator
build/lane11-3-server
build/lane11-5a-pf2e
build/lane11-5b-5e
feat/design-admin
build/lane11-2-evaluator
build/lane11-1-sheet-pieces
build/lane11-0-drawing
fix/943-quest-objectives-fk
fix/prepsheet-shelf-flake
fix/945-leftovers
build/demo-gm-copy
fix/901-email-landings
build/lane10-8-journeys
build/demo-seed-lanes-8-10
fix/887-gm-own-confirmation
build/lane9-5-j1-admin
fix/918-audience-click
fix/useasync-overtaken
ci/939-critical-path
test/937-backend-suite-speed
chore/claude-orchestration
fix/924-xp-gm-only
fix/session-stale-poll
docs/decisions-d239-d242
fix/930-pypdf
build/lane8-journeys
build/925-quest-editor
fix/894-private-page-pictures
build/lane8-p4-drafts
build/lane8-p7-map
build/lane8-p6-handouts-rules
fix/wiki-fixture-names
build/lane10-d227-test-usage
fix/rsvp-horizon-test-tuesday
fix/media-volume-owner
build/lane8-p3-editor
fix/912-unidentified-rename
build/lane8-p5-party-quests
build/lane8-handout-takeback
build/lane10-pickers
build/lane8-p2-article
build/lane8-6-map
build/lane10-7b-email-pages
build/lane8-5-handouts
build/lane10-7a-test-the-bot-pages
build/lane8-p1-pages
build/lane8-4-quests
build/lane8-2b-feeds
build/lane8-3-parties
build/lane8-2a-links
build/lane10-5-commands
build/lane9-6-seed
build/lane10-4b-reactions
build/lane9-4d-pages
build/lane9-4c-pages
build/lane10-4a-posts
build/lane10-3-email
fix/891-web-fonts
build/lane9-4b-pages
build/lane10-6-test-the-bot
build/lane10-2-notify-out
build/lane8-1-wiki-text
build/lane9-4a-frame
design/lane10-drawings
fix/j6-budget
fix/j5-phone-flake
fix/869-test-leaks
fix/843-record-start-followups
build/lane7-all
build/lane6-all
build/lane9-b2-server
chore/changelog-fragments
docs/decisions-d218
build/lane9-a-owner-overview
fix/rule-card-level-label
fix/834-retention-semantics
fix/o3c-record-without-announcement
docs/decisions-d179
docs/decisions-d131-d170
fix/851-commit-before-response
build/lane6-j3-seed
build/lane9-b1-d67-identity
fix/841-blocking-loop
build/lane9-c-backups-audit
fix/vote-race-and-start-commit
fix/recording-announcement
fix/proposal-read-leak
fix/last-admin-guard
build/lane6-p4c-planning
build/lane6-p4b-margin
build/lane6-p4a-sheet
build/lane5-j4-seed
build/lane6-base
build/lane7-pages-talk
build/lane5-pages
build/lane7-pages-safety
build/lane7-p3-safety
build/lane7-p2-notify
build/lane6-p3-planning
build/lane6-p2-history
build/lane6-p1b-items
build/lane7-p1-threads
build/lane5-p6-faces
build/lane6-p1a-sheet
build/lane5-p5-find
build/lane5-p4a-board
build/lane5-p4b-scenes
build/lane5-p3-live
build/lane5-base
build/lane5-p2-cards
docs/lane5-decisions
build/review-r2-wide
fix/review-r1
build/lane5-p1-scenes
build/demo-seed
build/lane4-journeys-j5
build/lane4-page-review-b
build/lane4-page-review-a
build/lane4-page-read
build/lane4-journeys
build/lane4-page-sess2
build/lane4-page-sess1
build/lane4-notes
build/lane4-discord-on-approval
build/lane4-scheduling
build/lane4-versions
build/760-session-number
build/lane4-decisions
build/797-erase-no-regenerate
build/test-race-audit
build/lane3-completions
build/794-dark-only
build/796-titles-scroll
build/791-retention-anchor
build/a11y-sweep
build/shell-followups
build/retention-preview
build/753-backup-readback
docs/d91-d93
build/lane3-j1
build/ci-harness-paths-selftest
fix/useauth-test-race
build/ci-harness-paths
build/777-ai-settings-read
build/lane3-settings-c
build/lane3-settings-b
build/lane3-j6
build/lane3-sessionform
build/lane3-settings-a
build/lane3-members
build/lane3-thisweek
build/lane3-story
chore/87-align-python-3.12
v4.8.0-rc1
v4.7.1
v4.7.1-rc1
v4.7.0
v4.7.0-rc4
v4.7.0-rc3
v4.7.0-rc2
v4.7.0-rc1
v4.6.2
v4.6.2-rc4
v4.6.2-rc3
v4.6.2-rc2
v4.6.2-rc1
v4.6.1
v4.6.1-rc1
v4.6.0
v4.6.0-rc2
v4.6.0-rc1
v4.5.3
v4.5.3-rc1
v4.5.2
v4.5.2-rc1
v4.5.1
v4.5.1-rc2
v4.5.1-rc1
v4.5.0
v4.5.0-rc12
v4.5.0-rc11
v4.5.0-rc10
v4.5.0-rc9
v4.5.0-rc8
v4.5.0-rc7
v4.5.0-rc6
v4.5.0-rc5
v4.5.0-rc4
v4.5.0-rc3
v4.5.0-rc2
v4.5.0-rc1
v4.4.1
v4.4.0
v4.3.0
v4.2.3
v4.2.2
v4.2.1
v4.2.0
v4.1.2
v4.1.1
v4.1.0
v4.0.1
v4.0.0
v3.11.4
v3.11.3
v3.11.2
v3.11.1
v3.11.0
v3.10.0
v3.9.1
v3.9.0
v3.8.0
v3.7.0
v3.6.1
v3.6.0
v3.5.0
v3.4.1
v3.4.0
v3.3.0
v3.2.0
v3.1.0
v3.0.0
v2.1.0
v2.0.0
v1.1.0
v1.0.0
v0.12.0
v0.11.0
v0.10.0
v0.9.1
v0.3.3
v0.9.0
v0.8.0
v0.3.2
v0.3.1
v0.3.0
v0.7.0
v0.6.0
v0.5.0
v0.4.0
v0.2.1
v0.2.0
v0.1.1
v0.1.0
Labels
Clear labels
backend
Issue related to the backend
bot
Issue related to the bot
bug
Something is not working
duplicate
This issue or pull request already exists
enhancement
New feature
frontend
Issue related to the frontend
help wanted
Need some help
invalid
Something is wrong
question
More information is needed
recording
Issue related to the recording system
security
Security hardening / vulnerability work
wontfix
This won't be fixed
No labels
backend
bot
bug
duplicate
enhancement
frontend
help wanted
invalid
question
recording
security
wontfix
Milestone
Clear milestone
No items
Projects
Clear projects
No items
No project
Assignees
Clear assignees
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".
No due date set.
Dependencies
No dependencies set
Reference
rbrooks/Quest-Board#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.