Files
matrix-hermes/src/matrix_hermes/client.py
T

408 lines
12 KiB
Python

"""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