fix(bot): un-reacting from a poll never un-counted the vote
Lint / Shell (shellcheck) (push) Successful in 15s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 15s
Lint / Python deps (pip-audit) (push) Successful in 3m0s
Lint / Secret scan (gitleaks) (push) Successful in 10s

wyr/acronym/nhie/hottake tracked votes only on reaction-add, using
"add to bucket A, remove from bucket B" logic that self-corrects when
switching reactions but not when a reaction is simply removed — nio
never subscribed to RedactionEvent at all, so an un-react was
invisible to the bot. Fixes LotusGuild/matrix#6 (repro: react agree
and disagree, remove disagree, still counted as disagree).

Adds unrecord_* counterparts to each record_* vote function, a
reaction_id -> (poll_event_id, sender) index in Callbacks (only
populated for reactions on messages we're actually tracking, so it
stays bounded) so a later redaction can be traced back to what to
un-count, and wires up RedactionEvent -> callbacks.redaction in bot.py.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-31 21:19:06 -04:00
co-authored by Claude Sonnet 5
parent cccd0084fb
commit ea7ecf3f73
3 changed files with 84 additions and 0 deletions
+46
View File
@@ -13,6 +13,14 @@ from commands import (
record_acronym_vote,
record_nhie_reaction,
record_hottake_reaction,
unrecord_wyr_vote,
unrecord_acronym_vote,
unrecord_nhie_reaction,
unrecord_hottake_reaction,
_WYR_POLLS,
_ACRONYM_POLL_IDS,
_NHIE_POLLS,
_HOTTAKE_POLLS,
)
logger = logging.getLogger("matrixbot")
@@ -33,11 +41,25 @@ def handle_command_errors(func):
return wrapper
def _is_tracked_poll(event_id: str) -> bool:
return (
event_id in _WYR_POLLS
or event_id in _ACRONYM_POLL_IDS
or event_id in _NHIE_POLLS
or event_id in _HOTTAKE_POLLS
)
class Callbacks:
def __init__(self, client: AsyncClient):
self.client = client
# Track the sync token so we ignore old messages on startup
self.startup_sync_token = None
# reaction event_id -> (poll message event_id, sender), so an
# un-react (m.room.redaction of the reaction) can be traced back to
# which poll/sender to remove — only populated for reactions on a
# message we're actually tracking, so it stays bounded.
self._reaction_index: dict[str, tuple[str, str]] = {}
async def message(self, room, event):
# Ignore messages from before the bot started
@@ -97,6 +119,8 @@ class Callbacks:
record_acronym_vote(reacted_event_id, event.sender, key)
record_nhie_reaction(reacted_event_id, event.sender, key)
record_hottake_reaction(reacted_event_id, event.sender, key)
if _is_tracked_poll(reacted_event_id):
self._reaction_index[event.event_id] = (reacted_event_id, event.sender)
async def unknown_event(self, room, event):
"""Fallback handler for UnknownEvent — catches any m.reaction not parsed by nio."""
@@ -120,6 +144,28 @@ class Callbacks:
record_acronym_vote(reacted_event_id, event.sender, key)
record_nhie_reaction(reacted_event_id, event.sender, key)
record_hottake_reaction(reacted_event_id, event.sender, key)
if _is_tracked_poll(reacted_event_id):
self._reaction_index[event.event_id] = (reacted_event_id, event.sender)
async def redaction(self, room, event):
"""Handle m.room.redaction — an un-react. Reaction adds are tracked
via `reaction`/`unknown_event` above; this is their counterpart so a
removed vote doesn't stay counted forever (it previously only
self-corrected when a user switched to a different reaction, not
when they simply removed one — see LotusGuild/matrix#6)."""
if self.startup_sync_token is None:
return
entry = self._reaction_index.pop(event.redacts, None)
if entry is None:
return
reacted_event_id, sender = entry
logger.info("reaction removed: target=%s sender=%s", reacted_event_id[:16], sender)
unrecord_wyr_vote(reacted_event_id, sender)
unrecord_acronym_vote(reacted_event_id, sender)
unrecord_nhie_reaction(reacted_event_id, sender)
unrecord_hottake_reaction(reacted_event_id, sender)
async def member(self, room, event):
"""Handle m.room.member events.