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, ) 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 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 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) 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) 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