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>
180 lines
6.8 KiB
Python
180 lines
6.8 KiB
Python
import logging
|
|
from functools import wraps
|
|
|
|
from nio import AsyncClient
|
|
|
|
from config import BOT_PREFIX, COMMAND_ROOMS, MATRIX_USER_ID
|
|
from commands import (
|
|
COMMANDS,
|
|
metrics,
|
|
check_scramble_answer,
|
|
check_riddle_answer,
|
|
record_wyr_vote,
|
|
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")
|
|
|
|
|
|
def handle_command_errors(func):
|
|
@wraps(func)
|
|
async def wrapper(client, room_id, sender, args):
|
|
try:
|
|
return await func(client, room_id, sender, args)
|
|
except Exception as e:
|
|
logger.error(f"Error in command {func.__name__}: {e}", exc_info=True)
|
|
metrics.record_error(func.__name__)
|
|
try:
|
|
from utils import send_text
|
|
await send_text(client, room_id, "An unexpected error occurred. Please try again later.")
|
|
except Exception as e2:
|
|
logger.error(f"Failed to send error message: {e2}", exc_info=True)
|
|
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
|
|
if self.startup_sync_token is None:
|
|
return
|
|
|
|
# Ignore our own messages
|
|
if event.sender == MATRIX_USER_ID:
|
|
return
|
|
|
|
# Only act in designated command rooms. This covers both prefixed
|
|
# commands and the passive game-answer checks below, so the bot never
|
|
# speaks in public rooms such as #general. "*" allows every room.
|
|
if "*" not in COMMAND_ROOMS and room.room_id not in COMMAND_ROOMS:
|
|
return
|
|
|
|
body = event.body.strip() if event.body else ""
|
|
|
|
# Check active non-command games that monitor all room messages
|
|
if body and not body.startswith(BOT_PREFIX):
|
|
await check_scramble_answer(self.client, room.room_id, event.sender, body)
|
|
await check_riddle_answer(self.client, room.room_id, event.sender, body)
|
|
return
|
|
|
|
if not body.startswith(BOT_PREFIX):
|
|
return
|
|
|
|
# Parse command and args
|
|
without_prefix = body[len(BOT_PREFIX):]
|
|
parts = without_prefix.split(None, 1)
|
|
cmd_name = parts[0].lower() if parts else ""
|
|
args = parts[1] if len(parts) > 1 else ""
|
|
|
|
logger.info(f"Command '{cmd_name}' from {event.sender} in {room.room_id}")
|
|
|
|
handler_entry = COMMANDS.get(cmd_name)
|
|
if handler_entry is None:
|
|
return
|
|
|
|
handler, _ = handler_entry
|
|
metrics.record_command(cmd_name)
|
|
wrapped = handle_command_errors(handler)
|
|
await wrapped(self.client, room.room_id, event.sender, args)
|
|
|
|
async def reaction(self, room, event):
|
|
"""Handle ReactionEvent (nio's native reaction type)."""
|
|
if self.startup_sync_token is None:
|
|
return
|
|
if event.sender == MATRIX_USER_ID:
|
|
return
|
|
|
|
reacted_event_id = event.reacts_to
|
|
key = event.key
|
|
logger.info("reaction: key=%r target=%s sender=%s", key, reacted_event_id[:16], event.sender)
|
|
|
|
record_wyr_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_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."""
|
|
if self.startup_sync_token is None:
|
|
return
|
|
if event.sender == MATRIX_USER_ID:
|
|
return
|
|
if not hasattr(event, "source"):
|
|
return
|
|
|
|
content = event.source.get("content", {})
|
|
relates_to = content.get("m.relates_to", {})
|
|
if relates_to.get("rel_type") != "m.annotation":
|
|
return
|
|
|
|
reacted_event_id = relates_to.get("event_id", "")
|
|
key = relates_to.get("key", "")
|
|
logger.info("unknown_event reaction: key=%r target=%s sender=%s", key, reacted_event_id[:16], event.sender)
|
|
|
|
record_wyr_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_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.
|
|
|
|
The Space-join welcome flow was removed deliberately: it DM'd anyone
|
|
who joined the (public) Space and, on a reaction, invited them into
|
|
rooms including Voice, whose join rule is `knock`. An invite bypasses
|
|
that gate, so a stranger could skip the approval step entirely. The
|
|
bot no longer issues invites of any kind.
|
|
"""
|
|
return
|