From ea7ecf3f7340a0ec0c660014c0aaecb71419a9e8 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Mon, 31 Aug 2026 21:19:06 -0400 Subject: [PATCH] fix(bot): un-reacting from a poll never un-counted the vote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- matrixbot/bot.py | 2 ++ matrixbot/callbacks.py | 46 ++++++++++++++++++++++++++++++++++++++++++ matrixbot/commands.py | 36 +++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+) diff --git a/matrixbot/bot.py b/matrixbot/bot.py index 89366a0..971021a 100644 --- a/matrixbot/bot.py +++ b/matrixbot/bot.py @@ -10,6 +10,7 @@ from nio import ( InviteMemberEvent, LoginResponse, ReactionEvent, + RedactionEvent, RoomMemberEvent, RoomMessageText, UnknownEvent, @@ -146,6 +147,7 @@ async def main(): client.add_event_callback(callbacks.message, RoomMessageText) client.add_event_callback(callbacks.reaction, ReactionEvent) client.add_event_callback(callbacks.unknown_event, UnknownEvent) + client.add_event_callback(callbacks.redaction, RedactionEvent) client.add_event_callback(callbacks.member, RoomMemberEvent) # Accept invites only from trusted users, and decline the rest so they do diff --git a/matrixbot/callbacks.py b/matrixbot/callbacks.py index 61016db..00da613 100644 --- a/matrixbot/callbacks.py +++ b/matrixbot/callbacks.py @@ -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. diff --git a/matrixbot/commands.py b/matrixbot/commands.py index 58109ab..f704069 100644 --- a/matrixbot/commands.py +++ b/matrixbot/commands.py @@ -1842,6 +1842,15 @@ def record_wyr_vote(event_id: str, sender: str, key: str) -> None: 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: # Few-shot examples anchor the format so the model doesn't drift 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 +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!") async def cmd_acronym(client: AsyncClient, room_id: str, sender: str, args: str): 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) +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 = [ "travel", "food", "social situations", "school or work", "technology", "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) +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 = [ "food and cooking", "music genres", "social media and technology", "sports and fitness", "video games", "movies and TV shows",