408 lines
12 KiB
Python
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
|