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