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
+2
View File
@@ -10,6 +10,7 @@ from nio import (
InviteMemberEvent, InviteMemberEvent,
LoginResponse, LoginResponse,
ReactionEvent, ReactionEvent,
RedactionEvent,
RoomMemberEvent, RoomMemberEvent,
RoomMessageText, RoomMessageText,
UnknownEvent, UnknownEvent,
@@ -146,6 +147,7 @@ async def main():
client.add_event_callback(callbacks.message, RoomMessageText) client.add_event_callback(callbacks.message, RoomMessageText)
client.add_event_callback(callbacks.reaction, ReactionEvent) client.add_event_callback(callbacks.reaction, ReactionEvent)
client.add_event_callback(callbacks.unknown_event, UnknownEvent) client.add_event_callback(callbacks.unknown_event, UnknownEvent)
client.add_event_callback(callbacks.redaction, RedactionEvent)
client.add_event_callback(callbacks.member, RoomMemberEvent) client.add_event_callback(callbacks.member, RoomMemberEvent)
# Accept invites only from trusted users, and decline the rest so they do # Accept invites only from trusted users, and decline the rest so they do
+46
View File
@@ -13,6 +13,14 @@ from commands import (
record_acronym_vote, record_acronym_vote,
record_nhie_reaction, record_nhie_reaction,
record_hottake_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") logger = logging.getLogger("matrixbot")
@@ -33,11 +41,25 @@ def handle_command_errors(func):
return wrapper 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: class Callbacks:
def __init__(self, client: AsyncClient): def __init__(self, client: AsyncClient):
self.client = client self.client = client
# Track the sync token so we ignore old messages on startup # Track the sync token so we ignore old messages on startup
self.startup_sync_token = None 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): async def message(self, room, event):
# Ignore messages from before the bot started # Ignore messages from before the bot started
@@ -97,6 +119,8 @@ class Callbacks:
record_acronym_vote(reacted_event_id, event.sender, key) record_acronym_vote(reacted_event_id, event.sender, key)
record_nhie_reaction(reacted_event_id, event.sender, key) record_nhie_reaction(reacted_event_id, event.sender, key)
record_hottake_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): async def unknown_event(self, room, event):
"""Fallback handler for UnknownEvent — catches any m.reaction not parsed by nio.""" """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_acronym_vote(reacted_event_id, event.sender, key)
record_nhie_reaction(reacted_event_id, event.sender, key) record_nhie_reaction(reacted_event_id, event.sender, key)
record_hottake_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): async def member(self, room, event):
"""Handle m.room.member events. """Handle m.room.member events.
+36
View File
@@ -1842,6 +1842,15 @@ def record_wyr_vote(event_id: str, sender: str, key: str) -> None:
poll["votes"][key].add(sender) poll["votes"][key].add(sender)
def unrecord_wyr_vote(event_id: str, sender: str) -> None:
"""Called from callbacks when a reaction is removed from a WYR poll message."""
poll = _WYR_POLLS.get(event_id)
if not poll:
return
for bucket in poll["votes"].values():
bucket.discard(sender)
async def _generate_wyr() -> dict | None: async def _generate_wyr() -> dict | None:
# Few-shot examples anchor the format so the model doesn't drift # Few-shot examples anchor the format so the model doesn't drift
examples = [ examples = [
@@ -2680,6 +2689,17 @@ def record_acronym_vote(event_id: str, sender: str, key: str) -> None:
game.setdefault("votes", {})[sender] = idx # one vote per person game.setdefault("votes", {})[sender] = idx # one vote per person
def unrecord_acronym_vote(event_id: str, sender: str) -> None:
"""Record a numbered-emoji vote removal on an acronym poll."""
room_id = _ACRONYM_POLL_IDS.get(event_id)
if room_id is None:
return
game = _ACRONYM_GAMES.get(room_id)
if not game or game.get("phase") != "voting":
return
game.get("votes", {}).pop(sender, None)
@command("acronym", "AI picks an acronym — submit the funniest expansion with !ac, then vote!") @command("acronym", "AI picks an acronym — submit the funniest expansion with !ac, then vote!")
async def cmd_acronym(client: AsyncClient, room_id: str, sender: str, args: str): async def cmd_acronym(client: AsyncClient, room_id: str, sender: str, args: str):
if room_id in _ACRONYM_GAMES: if room_id in _ACRONYM_GAMES:
@@ -3036,6 +3056,14 @@ def record_nhie_reaction(event_id: str, sender: str, key: str) -> None:
poll["have"].discard(sender) poll["have"].discard(sender)
def unrecord_nhie_reaction(event_id: str, sender: str) -> None:
poll = _NHIE_POLLS.get(event_id)
if not poll:
return
poll["have"].discard(sender)
poll["never"].discard(sender)
_NHIE_TOPICS = [ _NHIE_TOPICS = [
"travel", "food", "social situations", "school or work", "technology", "travel", "food", "social situations", "school or work", "technology",
"outdoor adventures", "relationships", "embarrassing moments", "outdoor adventures", "relationships", "embarrassing moments",
@@ -3138,6 +3166,14 @@ def record_hottake_reaction(event_id: str, sender: str, key: str) -> None:
poll["agree"].discard(sender) poll["agree"].discard(sender)
def unrecord_hottake_reaction(event_id: str, sender: str) -> None:
poll = _HOTTAKE_POLLS.get(event_id)
if not poll:
return
poll["agree"].discard(sender)
poll["disagree"].discard(sender)
_HOTTAKE_TOPICS = [ _HOTTAKE_TOPICS = [
"food and cooking", "music genres", "social media and technology", "food and cooking", "music genres", "social media and technology",
"sports and fitness", "video games", "movies and TV shows", "sports and fitness", "video games", "movies and TV shows",