import logging from functools import wraps from nio import AsyncClient, RoomMessageText, UnknownEvent from config import BOT_PREFIX, MATRIX_USER_ID from commands import COMMANDS, metrics from welcome import handle_welcome_reaction, handle_space_join, SPACE_ROOM_ID 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 body = event.body.strip() if event.body else "" 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 m.reaction events (sent as UnknownEvent by matrix-nio).""" # Ignore events from before startup if self.startup_sync_token is None: return # Ignore our own reactions if event.sender == MATRIX_USER_ID: return # m.reaction events come as UnknownEvent with type "m.reaction" 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", "") await handle_welcome_reaction( self.client, room.room_id, event.sender, reacted_event_id, key ) async def member(self, room, event): """Handle m.room.member events — watch for Space joins.""" # Ignore events from before startup if self.startup_sync_token is None: return # Only care about the Space if room.room_id != SPACE_ROOM_ID: return # Ignore our own membership changes if event.state_key == MATRIX_USER_ID: return # Only trigger on joins (not leaves, bans, etc.) if event.membership != "join": return # Check if this is a new join (prev was not "join") prev = event.prev_membership if hasattr(event, "prev_membership") else None if prev == "join": return # Already was a member, this is a profile update or similar await handle_space_join(self.client, event.state_key)