fix(bot): un-reacting from a poll never un-counted the vote
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:
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user