feat: matrix-hermes project structure and Matrix authentication
This commit is contained in:
@@ -0,0 +1,407 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user