"""Async Matrix client wrapper built on mautrix. Provides a simplified async interface for connecting to Matrix, sending messages, joining rooms, and handling events. Designed to work with the Hermes gateway Matrix adapter conventions. """ from __future__ import annotations import asyncio import logging from dataclasses import dataclass, field from typing import Any, AsyncIterator, Awaitable, Callable, Optional from matrix_hermes.auth import AuthResult, login_from_config from matrix_hermes.config import MatrixConfig logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Data classes # --------------------------------------------------------------------------- @dataclass class MatrixMessage: """A received Matrix message event.""" event_id: str """The event ID.""" room_id: str """Room where the message was sent.""" sender: str """Full Matrix user ID of the sender.""" body: str """Message body text.""" msg_type: str = "m.text" """Message type: m.text, m.image, m.file, etc.""" raw: dict[str, Any] | None = None """Raw event dict from the homeserver.""" @dataclass class SendResult: """Result of sending a Matrix message.""" success: bool """True if the message was sent.""" event_id: str = "" """The event ID of the sent message.""" error: str = "" """Error message if sending failed.""" # --------------------------------------------------------------------------- # Client # --------------------------------------------------------------------------- class MatrixClient: """Async Matrix client wrapping mautrix. Provides a simplified interface for common operations: - Connect and authenticate - Send/receive messages - Join/leave rooms - Sync event listening Example: config = MatrixConfig.from_env() client = MatrixClient(config) await client.connect() await client.send_message("!room:server", "Hello Matrix!") await client.disconnect() """ def __init__(self, config: MatrixConfig): """Initialize the client. Args: config: MatrixConfig with connection parameters. """ self._config = config self._client: Any = None # mautrix.client.Client self._connected = False self._message_handlers: list[ Callable[[MatrixMessage], Awaitable[None]] ] = [] self._sync_task: Optional[asyncio.Task] = None self._closing = False # -- Properties ---------------------------------------------------------- @property def connected(self) -> bool: """True if the client is connected and authenticated.""" return self._connected @property def user_id(self) -> str: """The authenticated user's Matrix ID, or empty string if not connected.""" return self._config.user_id or "" # -- Lifecycle ----------------------------------------------------------- async def connect(self) -> AuthResult: """Connect to the Matrix homeserver and authenticate. Uses the configured access token or password to authenticate. On success, the client is ready to send messages and listen for events. Returns: AuthResult indicating success/failure and user details. """ if self._connected: return AuthResult( success=True, user_id=self._config.user_id or "", access_token=self._config.access_token, homeserver=self._config.homeserver, ) # Sync auth to get/validate token auth = login_from_config(self._config) if not auth.success: return auth # Store resolved credentials self._config.access_token = auth.access_token self._config.user_id = auth.user_id self._config.device_id = auth.device_id # Lazy-import mautrix try: from mautrix.client import Client from mautrix.types import PresenceState except ImportError as exc: return AuthResult( success=False, error=( f"mautrix not installed: {exc}. " "Run: pip install mautrix" ), homeserver=self._config.homeserver, ) # Build client self._client = Client( mxid=self._config.user_id, base_url=self._config.homeserver, token=self._config.access_token, ) # Set device ID if configured if self._config.device_id and hasattr(self._client, "device_id"): self._client.device_id = self._config.device_id self._connected = True self._closing = False logger.info("Connected to %s as %s", self._config.homeserver, self._config.user_id) return auth async def disconnect(self) -> None: """Disconnect from the Matrix homeserver.""" self._closing = True if self._sync_task and not self._sync_task.done(): self._sync_task.cancel() try: await self._sync_task except asyncio.CancelledError: pass if self._client: await self._client.stop() self._connected = False self._sync_task = None logger.info("Disconnected from Matrix") async def __aenter__(self) -> "MatrixClient": await self.connect() return self async def __aexit__(self, *args: Any) -> None: await self.disconnect() # -- Messaging ----------------------------------------------------------- async def send_message( self, room_id: str, body: str, msg_type: str = "m.text", thread_root: str = "", reply_to: str = "", ) -> SendResult: """Send a message to a Matrix room. Args: room_id: Target room ID. body: Message body text. msg_type: Message type (default: m.text). thread_root: Optional event ID to thread under. reply_to: Optional event ID to reply to. Returns: SendResult with success status and event ID. """ if not self._connected or not self._client: return SendResult(success=False, error="Not connected") try: content: dict[str, Any] = { "msgtype": msg_type, "body": body, } if reply_to: content["m.relates_to"] = { "m.in_reply_to": {"event_id": reply_to} } elif thread_root: content["m.relates_to"] = { "rel_type": "m.thread", "event_id": thread_root, } event_id = await self._client.send_message(room_id, content) return SendResult(success=True, event_id=str(event_id)) except Exception as exc: logger.error("Failed to send message: %s", exc) return SendResult(success=False, error=str(exc)) async def send_image( self, room_id: str, image_path: str, caption: str = "", thread_root: str = "", ) -> SendResult: """Send an image to a Matrix room. Args: room_id: Target room ID. image_path: Local path to the image file. caption: Optional caption text. thread_root: Optional event ID to thread under. Returns: SendResult with success status and event ID. """ if not self._connected or not self._client: return SendResult(success=False, error="Not connected") try: event_id = await self._client.send_image( room_id, image_path, caption=caption, thread_root=thread_root or None, ) return SendResult(success=True, event_id=str(event_id)) except Exception as exc: logger.error("Failed to send image: %s", exc) return SendResult(success=False, error=str(exc)) # -- Room management ----------------------------------------------------- async def join_room(self, room_id: str) -> bool: """Join a Matrix room. Args: room_id: Room ID or alias to join. Returns: True if joined successfully. """ if not self._connected or not self._client: return False try: await self._client.join_room(room_id) logger.info("Joined room %s", room_id) return True except Exception as exc: logger.error("Failed to join room %s: %s", room_id, exc) return False async def leave_room(self, room_id: str) -> bool: """Leave a Matrix room. Args: room_id: Room ID to leave. Returns: True if left successfully. """ if not self._connected or not self._client: return False try: await self._client.leave_room(room_id) logger.info("Left room %s", room_id) return True except Exception as exc: logger.error("Failed to leave room %s: %s", room_id, exc) return False async def get_joined_rooms(self) -> list[str]: """Get the list of rooms the user has joined. Returns: List of room IDs. """ if not self._connected or not self._client: return [] try: rooms = await self._client.get_joined_rooms() return [str(r) for r in rooms] except Exception as exc: logger.error("Failed to get joined rooms: %s", exc) return [] # -- Event listeners ----------------------------------------------------- def on_message( self, handler: Callable[[MatrixMessage], Awaitable[None]], ) -> None: """Register a message handler. Args: handler: Async callable that receives each MatrixMessage. """ self._message_handlers.append(handler) async def start_listening( self, poll_interval: float = 1.0, ) -> None: """Start listening for incoming Matrix events. This runs a sync loop that polls for new events and dispatches them to registered message handlers. Runs until disconnect() is called. Args: poll_interval: Seconds between sync polls. """ if not self._connected or not self._client: raise RuntimeError("Must connect() before start_listening()") logger.info("Starting event listener (poll_interval=%.1fs)", poll_interval) while not self._closing: try: await self._client.sync() # Process any new messages from the sync # (mautrix handles callbacks internally; this is a simplified API) await asyncio.sleep(poll_interval) except asyncio.CancelledError: break except Exception as exc: logger.error("Sync error: %s", exc) await asyncio.sleep(poll_interval) # --------------------------------------------------------------------------- # Quick helpers # --------------------------------------------------------------------------- async def quick_connect( homeserver: str = "", access_token: str = "", user_id: str = "", password: str = "", ) -> MatrixClient | None: """Quickly create and connect a MatrixClient from raw parameters. Args: homeserver: Matrix homeserver URL. access_token: Existing access token (preferred). user_id: Full user ID (required for password login). password: Password for login (alternative to token). Returns: Connected MatrixClient, or None if connection failed. """ config = MatrixConfig( homeserver=homeserver or "", access_token=access_token or "", user_id=user_id or "", password=password or "", ) client = MatrixClient(config) auth = await client.connect() if not auth.success: logger.error("Connection failed: %s", auth.error) return None return client