diff --git a/src/matrix_hermes/__init__.py b/src/matrix_hermes/__init__.py
index 5ce796c..4bd884b 100644
--- a/src/matrix_hermes/__init__.py
+++ b/src/matrix_hermes/__init__.py
@@ -1,11 +1,155 @@
"""
-Matrix-Hermes: Matrix authentication and client integration for Hermes Agent.
+Matrix-Hermes: Matrix authentication, client, rooms, messaging, and sync integration
+for Hermes Agent.
Provides:
- Authentication (login, token management, device registration)
- Async client wrapper around mautrix
- - CLI tools for setup and token management
+ - Room management (create, join, list, leave, invite, kick, ban)
+ - Messaging (send text, images, files, edits, reactions)
+ - Sync engine (incremental sync, event dispatch, filtering)
+ - CLI tools for all operations
"""
-__version__ = "0.1.0"
-__all__ = ["MatrixAuth", "MatrixClient", "MatrixConfig", "__version__"]
+__version__ = "0.3.0"
+
+# Auth
+from matrix_hermes.auth import (
+ AuthResult,
+ WhoAmIResult,
+ login,
+ login_from_config,
+ logout,
+ save_session,
+ load_session,
+ remove_session,
+ validate_token,
+)
+
+# Config
+from matrix_hermes.config import MatrixConfig
+
+# Client
+from matrix_hermes.client import (
+ MatrixClient,
+ MatrixMessage,
+ SendResult,
+ quick_connect,
+)
+
+# Rooms
+from matrix_hermes.rooms import (
+ RoomInfo,
+ CreateRoomResult,
+ MemberInfo,
+ PowerLevels,
+ create_room,
+ join_room,
+ leave_room,
+ forget_room,
+ get_joined_rooms,
+ resolve_room_alias,
+ get_room_info,
+ get_room_members,
+ invite_user,
+ kick_user,
+ ban_user,
+ unban_user,
+ set_room_name,
+ set_room_topic,
+)
+
+# Messaging
+from matrix_hermes.messaging import (
+ send_text,
+ send_notice,
+ send_emote,
+ send_image,
+ send_file,
+ send_sticker,
+ edit_message,
+ redact_message,
+ send_reaction,
+ send_typing,
+ send_read_receipt,
+ set_fully_read,
+)
+
+# Sync
+from matrix_hermes.sync import (
+ SyncFilter,
+ SyncResult,
+ SyncEvent,
+ SyncEngine,
+ EventHandler,
+ PaginatedMessages,
+ create_filter,
+ sync_once,
+ extract_events,
+ get_messages,
+)
+
+__all__ = [
+ # Auth
+ "AuthResult",
+ "WhoAmIResult",
+ "login",
+ "login_from_config",
+ "logout",
+ "save_session",
+ "load_session",
+ "remove_session",
+ "validate_token",
+ # Config
+ "MatrixConfig",
+ # Client
+ "MatrixClient",
+ "MatrixMessage",
+ "SendResult",
+ "quick_connect",
+ # Rooms
+ "RoomInfo",
+ "CreateRoomResult",
+ "MemberInfo",
+ "PowerLevels",
+ "create_room",
+ "join_room",
+ "leave_room",
+ "forget_room",
+ "get_joined_rooms",
+ "resolve_room_alias",
+ "get_room_info",
+ "get_room_members",
+ "invite_user",
+ "kick_user",
+ "ban_user",
+ "unban_user",
+ "set_room_name",
+ "set_room_topic",
+ # Messaging
+ "send_text",
+ "send_notice",
+ "send_emote",
+ "send_image",
+ "send_file",
+ "send_sticker",
+ "edit_message",
+ "redact_message",
+ "send_reaction",
+ "send_typing",
+ "send_read_receipt",
+ "set_fully_read",
+ # Sync
+ "SyncFilter",
+ "SyncResult",
+ "SyncEvent",
+ "SyncEngine",
+ "EventHandler",
+ "PaginatedMessages",
+ "create_filter",
+ "sync_once",
+ "extract_events",
+ "get_messages",
+ # Meta
+ "__version__",
+]
diff --git a/src/matrix_hermes/cli.py b/src/matrix_hermes/cli.py
index 80c089f..ad4a1dc 100644
--- a/src/matrix_hermes/cli.py
+++ b/src/matrix_hermes/cli.py
@@ -11,6 +11,7 @@ Usage:
from __future__ import annotations
import argparse
+import asyncio
import os
import sys
from pathlib import Path
@@ -210,15 +211,337 @@ def build_parser() -> argparse.ArgumentParser:
p_show.add_argument("--env-file", help="Path to .env file to load")
p_show.add_argument("--redact", action="store_true", help="Redact sensitive values")
+ # ── Rooms subcommands ─────────────────────────────────────────────────
+ p_rooms = sub.add_parser("rooms", help="Manage Matrix rooms")
+ rooms_sub = p_rooms.add_subparsers(dest="rooms_action", help="Room actions")
+
+ p_rooms_list = rooms_sub.add_parser("list", help="List joined rooms")
+ p_rooms_create = rooms_sub.add_parser("create", help="Create a new room")
+ p_rooms_create.add_argument("--name", help="Room display name")
+ p_rooms_create.add_argument("--topic", help="Room topic")
+ p_rooms_create.add_argument("--alias", help="Room alias localpart")
+ p_rooms_create.add_argument("--public", action="store_true", help="Make room public")
+ p_rooms_create.add_argument("--invite", nargs="*", default=[], help="User IDs to invite")
+
+ p_rooms_join = rooms_sub.add_parser("join", help="Join a room")
+ p_rooms_join.add_argument("room", help="Room ID or alias to join")
+
+ p_rooms_leave = rooms_sub.add_parser("leave", help="Leave a room")
+ p_rooms_leave.add_argument("room", help="Room ID to leave")
+
+ p_rooms_info = rooms_sub.add_parser("info", help="Get room details")
+ p_rooms_info.add_argument("room", help="Room ID")
+
+ p_rooms_members = rooms_sub.add_parser("members", help="List room members")
+ p_rooms_members.add_argument("room", help="Room ID")
+
+ p_rooms_invite = rooms_sub.add_parser("invite", help="Invite a user to a room")
+ p_rooms_invite.add_argument("room", help="Room ID")
+ p_rooms_invite.add_argument("user", help="User ID to invite")
+
+ # ── Send subcommands ──────────────────────────────────────────────────
+ p_send = sub.add_parser("send", help="Send messages to Matrix rooms")
+ send_sub = p_send.add_subparsers(dest="send_action", help="Send actions")
+
+ p_send_text = send_sub.add_parser("text", help="Send a text message")
+ p_send_text.add_argument("room", help="Target room ID")
+ p_send_text.add_argument("body", help="Message text")
+ p_send_text.add_argument("--reply-to", help="Event ID to reply to")
+
+ p_send_image = send_sub.add_parser("image", help="Send an image")
+ p_send_image.add_argument("room", help="Target room ID")
+ p_send_image.add_argument("path", help="Path to image file")
+ p_send_image.add_argument("--caption", default="", help="Image caption")
+
+ p_send_file = send_sub.add_parser("file", help="Send a file")
+ p_send_file.add_argument("room", help="Target room ID")
+ p_send_file.add_argument("path", help="Path to file")
+ p_send_file.add_argument("--caption", default="", help="File caption")
+
+ # ── Sync subcommands ──────────────────────────────────────────────────
+ p_sync = sub.add_parser("sync", help="Sync with Matrix homeserver")
+ sync_sub = p_sync.add_subparsers(dest="sync_action", help="Sync actions")
+
+ p_sync_listen = sync_sub.add_parser("listen", help="Listen for events (Ctrl+C to stop)")
+ p_sync_listen.add_argument("--filter-type", nargs="*", default=[], help="Event types to include")
+ p_sync_listen.add_argument("--room", nargs="*", default=[], help="Only listen in these rooms")
+ p_sync_listen.add_argument("--show-all", action="store_true", help="Show all event types, not just messages")
+
return parser
+# ── Async CLI handlers ──────────────────────────────────────────────────────
+
+
+def _build_client(args: argparse.Namespace) -> "MatrixClient":
+ """Build a MatrixClient from CLI args + env vars."""
+ from matrix_hermes.client import MatrixClient
+ from matrix_hermes.config import MatrixConfig
+
+ homeserver = _env_or_arg(str(getattr(args, "homeserver", "") or ""), "MATRIX_HOMESERVER")
+ token = _env_or_arg(str(getattr(args, "token", "") or ""), "MATRIX_ACCESS_TOKEN")
+ user_id = _env_or_arg(str(getattr(args, "user", "") or ""), "MATRIX_USER_ID")
+ password = _env_or_arg(str(getattr(args, "password", "") or ""), "MATRIX_PASSWORD")
+
+ config = MatrixConfig(
+ homeserver=homeserver or "",
+ access_token=token or "",
+ user_id=user_id or "",
+ password=password or "",
+ )
+ return MatrixClient(config)
+
+
+async def _rooms_list(client, args: argparse.Namespace) -> int:
+ rooms = await client.get_joined_rooms()
+ if not rooms:
+ print("No rooms joined.")
+ else:
+ print(f"Joined rooms ({len(rooms)}):")
+ for r in rooms:
+ print(f" {r}")
+ return 0
+
+
+async def _rooms_create(client, args: argparse.Namespace) -> int:
+ from matrix_hermes.rooms import create_room as do_create
+
+ preset = "public_chat" if getattr(args, "public", False) else "private_chat"
+ visibility = "public" if getattr(args, "public", False) else "private"
+ invitees = getattr(args, "invite", []) or []
+
+ result = await do_create(
+ client._client,
+ name=getattr(args, "name", "") or "",
+ topic=getattr(args, "topic", "") or "",
+ alias_localpart=getattr(args, "alias", "") or "",
+ preset=preset,
+ visibility=visibility,
+ invitees=invitees if invitees else None,
+ )
+ if result.success:
+ print(f"Room created: {result.room_id}")
+ return 0
+ else:
+ print(f"Failed to create room: {result.error}", file=sys.stderr)
+ return 1
+
+
+async def _rooms_join(client, args: argparse.Namespace) -> int:
+ ok = await client.join_room(args.room)
+ if ok:
+ print(f"Joined {args.room}")
+ return 0
+ else:
+ print(f"Failed to join {args.room}", file=sys.stderr)
+ return 1
+
+
+async def _rooms_leave(client, args: argparse.Namespace) -> int:
+ ok = await client.leave_room(args.room)
+ if ok:
+ print(f"Left {args.room}")
+ return 0
+ else:
+ print(f"Failed to leave {args.room}", file=sys.stderr)
+ return 1
+
+
+async def _rooms_info(client, args: argparse.Namespace) -> int:
+ from matrix_hermes.rooms import get_room_info
+
+ info = await get_room_info(client._client, args.room)
+ if info is None:
+ print(f"Failed to get info for {args.room}", file=sys.stderr)
+ return 1
+
+ print(f"Room: {info.room_id}")
+ print(f" Name: {info.name or '(unnamed)'}")
+ print(f" Topic: {info.topic or '(none)'}")
+ print(f" Members: {info.joined_members} joined ({info.invited_members} invited)")
+ print(f" Public: {'yes' if info.is_public else 'no'}")
+ print(f" Direct: {'yes' if info.is_direct else 'no'}")
+ print(f" Encrypted:{'yes' if info.encryption else 'no'}")
+ if info.canonical_alias:
+ print(f" Alias: {info.canonical_alias}")
+ return 0
+
+
+async def _rooms_members(client, args: argparse.Namespace) -> int:
+ from matrix_hermes.rooms import get_room_members
+
+ members = await get_room_members(client._client, args.room)
+ if not members:
+ print(f"No members found for {args.room}")
+ return 0
+
+ print(f"Members of {args.room} ({len(members)}):")
+ for m in members:
+ name = m.display_name or m.user_id
+ print(f" {name} [{m.membership}]")
+ return 0
+
+
+async def _rooms_invite(client, args: argparse.Namespace) -> int:
+ from matrix_hermes.rooms import invite_user
+
+ ok = await invite_user(client._client, args.room, args.user)
+ if ok:
+ print(f"Invited {args.user} to {args.room}")
+ return 0
+ else:
+ print(f"Failed to invite {args.user}", file=sys.stderr)
+ return 1
+
+
+async def _send_text(client, args: argparse.Namespace) -> int:
+ result = await client.send_message(
+ args.room,
+ args.body,
+ reply_to=getattr(args, "reply_to", "") or "",
+ )
+ if result.success:
+ print(f"Sent: {result.event_id}")
+ return 0
+ else:
+ print(f"Failed to send: {result.error}", file=sys.stderr)
+ return 1
+
+
+async def _send_image(client, args: argparse.Namespace) -> int:
+ from matrix_hermes.messaging import send_image as do_send
+
+ result = await do_send(
+ client._client,
+ args.room,
+ args.path,
+ caption=getattr(args, "caption", "") or "",
+ )
+ if result.success:
+ print(f"Sent image: {result.event_id}")
+ return 0
+ else:
+ print(f"Failed to send image: {result.error}", file=sys.stderr)
+ return 1
+
+
+async def _send_file(client, args: argparse.Namespace) -> int:
+ from matrix_hermes.messaging import send_file as do_send
+
+ result = await do_send(
+ client._client,
+ args.room,
+ args.path,
+ caption=getattr(args, "caption", "") or "",
+ )
+ if result.success:
+ print(f"Sent file: {result.event_id}")
+ return 0
+ else:
+ print(f"Failed to send file: {result.error}", file=sys.stderr)
+ return 1
+
+
+async def _sync_listen(client, args: argparse.Namespace) -> int:
+ from matrix_hermes.sync import SyncEngine, SyncFilter
+
+ filter_config = None
+ if getattr(args, "filter_type", None) or getattr(args, "room", None):
+ filter_config = SyncFilter(
+ types=list(args.filter_type) if args.filter_type else [],
+ rooms=list(args.room) if args.room else [],
+ )
+
+ engine = SyncEngine(client._client, filter_config=filter_config)
+
+ if not getattr(args, "show_all", False):
+ async def _msg_handler(event):
+ if event.event_type == "m.room.message":
+ body = event.content.get("body", "")
+ sender = event.sender
+ room = event.room_id
+ print(f"[{room}] {sender}: {body}")
+
+ engine.on_event("m.room.message", _msg_handler)
+ else:
+ async def _any_handler(event):
+ print(f"[{event.room_id}] {event.event_type} from {event.sender}")
+
+ engine.on_any(_any_handler)
+
+ print("Listening for Matrix events (Ctrl+C to stop)...")
+ print(f"User: {client.user_id}")
+ engine.start_background()
+ try:
+ await asyncio.sleep(2**30) # effectively forever
+ except asyncio.CancelledError:
+ pass
+ finally:
+ await engine.stop()
+ return 0
+
+
+# Router tables
+_ASYNC_ROOMS_ACTIONS = {
+ "list": _rooms_list,
+ "create": _rooms_create,
+ "join": _rooms_join,
+ "leave": _rooms_leave,
+ "info": _rooms_info,
+ "members": _rooms_members,
+ "invite": _rooms_invite,
+}
+
+_ASYNC_SEND_ACTIONS = {
+ "text": _send_text,
+ "image": _send_image,
+ "file": _send_file,
+}
+
+_ASYNC_SYNC_ACTIONS = {
+ "listen": _sync_listen,
+}
+
+
+async def _async_command(args: argparse.Namespace) -> int:
+ """Entry point for async commands (rooms, send, sync)."""
+ client = _build_client(args)
+ auth = await client.connect()
+ if not auth.success:
+ print(f"Connection failed: {auth.error}", file=sys.stderr)
+ return 1
+ try:
+ if args.command == "rooms":
+ handler = _ASYNC_ROOMS_ACTIONS.get(args.rooms_action)
+ if handler:
+ return await handler(client, args)
+ elif args.command == "send":
+ handler = _ASYNC_SEND_ACTIONS.get(args.send_action)
+ if handler:
+ return await handler(client, args)
+ elif args.command == "sync":
+ handler = _ASYNC_SYNC_ACTIONS.get(args.sync_action)
+ if handler:
+ return await handler(client, args)
+ finally:
+ await client.disconnect()
+ return 0
+
+
+def _run_async(args: argparse.Namespace) -> int:
+ """Synchronous wrapper for async commands."""
+ return asyncio.run(_async_command(args))
+
+
_COMMAND_MAP = {
"login": cmd_login,
"whoami": cmd_whoami,
"logout": cmd_logout,
"check": cmd_check,
"show-config": cmd_show_config,
+ "rooms": _run_async,
+ "send": _run_async,
+ "sync": _run_async,
}
diff --git a/src/matrix_hermes/messaging.py b/src/matrix_hermes/messaging.py
new file mode 100644
index 0000000..d02a563
--- /dev/null
+++ b/src/matrix_hermes/messaging.py
@@ -0,0 +1,417 @@
+"""Matrix messaging: send text, notices, media, edits, redactions, reactions.
+
+All functions operate on a mautrix Client and are async. Designed to
+work with the MatrixClient wrapper as well as standalone usage.
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+from dataclasses import dataclass
+from typing import Any, Optional
+
+logger = logging.getLogger(__name__)
+
+
+# ── Data classes ────────────────────────────────────────────────────────────
+
+
+@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."""
+
+
+# ── Core messaging ─────────────────────────────────────────────────────────
+
+
+async def send_text(
+ client: Any,
+ room_id: str,
+ body: str,
+ html: str = "",
+ thread_root: str = "",
+ reply_to: str = "",
+) -> SendResult:
+ """Send a plain-text message to a Matrix room.
+
+ Args:
+ client: A connected mautrix Client or MatrixClient.
+ room_id: Target room ID.
+ body: Plain-text message body.
+ html: Optional formatted HTML body.
+ thread_root: Optional event ID to thread under.
+ reply_to: Optional event ID to reply to (mutually exclusive with thread_root).
+
+ Returns:
+ SendResult with success status and event ID.
+ """
+ try:
+ kwargs: dict[str, Any] = {}
+
+ if reply_to:
+ kwargs["relates_to"] = {"m.in_reply_to": {"event_id": reply_to}}
+ elif thread_root:
+ kwargs["relates_to"] = {
+ "rel_type": "m.thread",
+ "event_id": thread_root,
+ }
+
+ event_id = await client.send_text(
+ room_id,
+ text=body,
+ html=html or None,
+ **kwargs,
+ )
+ return SendResult(success=True, event_id=str(event_id))
+
+ except Exception as exc:
+ logger.error("Failed to send text: %s", exc)
+ return SendResult(success=False, error=str(exc))
+
+
+async def send_notice(
+ client: Any,
+ room_id: str,
+ body: str,
+ html: str = "",
+) -> SendResult:
+ """Send a notice message (m.notice — less intrusive than m.text).
+
+ Args:
+ client: A connected mautrix Client or MatrixClient.
+ room_id: Target room ID.
+ body: Notice body text.
+ html: Optional formatted HTML body.
+
+ Returns:
+ SendResult with success status and event ID.
+ """
+ try:
+ from mautrix.types import MessageType
+
+ event_id = await client.send_message(
+ room_id,
+ {
+ "msgtype": MessageType.NOTICE.value,
+ "body": body,
+ **({"format": "org.matrix.custom.html", "formatted_body": html} if html else {}),
+ },
+ )
+ return SendResult(success=True, event_id=str(event_id))
+ except Exception as exc:
+ logger.error("Failed to send notice: %s", exc)
+ return SendResult(success=False, error=str(exc))
+
+
+async def send_emote(
+ client: Any,
+ room_id: str,
+ body: str,
+) -> SendResult:
+ """Send an emote message (m.emote — /me style).
+
+ Args:
+ client: A connected mautrix Client or MatrixClient.
+ room_id: Target room ID.
+ body: Emote body text.
+
+ Returns:
+ SendResult with success status and event ID.
+ """
+ try:
+ event_id = await client.send_emote(room_id, body)
+ return SendResult(success=True, event_id=str(event_id))
+ except Exception as exc:
+ logger.error("Failed to send emote: %s", exc)
+ return SendResult(success=False, error=str(exc))
+
+
+async def send_image(
+ client: Any,
+ room_id: str,
+ image_path: str,
+ caption: str = "",
+ thread_root: str = "",
+) -> SendResult:
+ """Send an image file to a Matrix room.
+
+ Args:
+ client: A connected mautrix Client or MatrixClient.
+ room_id: Target room ID.
+ image_path: Local filesystem path to the image.
+ caption: Optional caption text.
+ thread_root: Optional event ID to thread under.
+
+ Returns:
+ SendResult with success status and event ID.
+ """
+ try:
+ event_id = await client.send_image(
+ room_id,
+ image_path,
+ caption=caption or None,
+ 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))
+
+
+async def send_file(
+ client: Any,
+ room_id: str,
+ file_path: str,
+ filename: str = "",
+ caption: str = "",
+ thread_root: str = "",
+) -> SendResult:
+ """Send a generic file to a Matrix room.
+
+ Args:
+ client: A connected mautrix Client or MatrixClient.
+ room_id: Target room ID.
+ file_path: Local filesystem path to the file.
+ filename: Override filename (default: basename of file_path).
+ caption: Optional caption text.
+ thread_root: Optional event ID to thread under.
+
+ Returns:
+ SendResult with success status and event ID.
+ """
+ try:
+ event_id = await client.send_file(
+ room_id,
+ file_path,
+ filename=filename or os.path.basename(file_path),
+ caption=caption or None,
+ thread_root=thread_root or None,
+ )
+ return SendResult(success=True, event_id=str(event_id))
+ except Exception as exc:
+ logger.error("Failed to send file: %s", exc)
+ return SendResult(success=False, error=str(exc))
+
+
+async def send_sticker(
+ client: Any,
+ room_id: str,
+ image_path: str,
+) -> SendResult:
+ """Send a sticker to a Matrix room.
+
+ Args:
+ client: A connected mautrix Client or MatrixClient.
+ room_id: Target room ID.
+ image_path: Local path to the sticker image.
+
+ Returns:
+ SendResult with success status and event ID.
+ """
+ try:
+ event_id = await client.send_sticker(room_id, image_path)
+ return SendResult(success=True, event_id=str(event_id))
+ except Exception as exc:
+ logger.error("Failed to send sticker: %s", exc)
+ return SendResult(success=False, error=str(exc))
+
+
+# ── Message editing & redaction ─────────────────────────────────────────────
+
+
+async def edit_message(
+ client: Any,
+ room_id: str,
+ event_id: str,
+ new_body: str,
+ new_html: str = "",
+) -> SendResult:
+ """Edit a previously sent message.
+
+ Sends an m.replace event that updates the original message body.
+
+ Args:
+ client: A connected mautrix Client or MatrixClient.
+ room_id: Room ID of the message.
+ event_id: Event ID of the message to edit.
+ new_body: Replacement plain-text body (use "* text" for clear edits).
+ new_html: Optional replacement formatted body.
+
+ Returns:
+ SendResult with the edit event's ID.
+ """
+ try:
+ from mautrix.types import (
+ ContentURI,
+ EventType,
+ RelatesTo,
+ TextMessageEventContent,
+ )
+
+ content: dict[str, Any] = {
+ "msgtype": "m.text",
+ "body": f" * {new_body}",
+ "m.new_content": {
+ "msgtype": "m.text",
+ "body": new_body,
+ },
+ "m.relates_to": {
+ "rel_type": "m.replace",
+ "event_id": event_id,
+ },
+ }
+
+ if new_html:
+ content["m.new_content"]["format"] = "org.matrix.custom.html"
+ content["m.new_content"]["formatted_body"] = new_html
+
+ edit_event_id = await client.send_message_event(
+ room_id,
+ EventType.ROOM_MESSAGE,
+ content,
+ )
+ return SendResult(success=True, event_id=str(edit_event_id))
+ except Exception as exc:
+ logger.error("Failed to edit message: %s", exc)
+ return SendResult(success=False, error=str(exc))
+
+
+async def redact_message(
+ client: Any,
+ room_id: str,
+ event_id: str,
+ reason: str = "",
+) -> bool:
+ """Redact (delete) a message from a Matrix room.
+
+ Args:
+ client: A connected mautrix Client or MatrixClient.
+ room_id: Room ID of the message.
+ event_id: Event ID to redact.
+ reason: Optional reason for the redaction.
+
+ Returns:
+ True if redacted successfully.
+ """
+ try:
+ await client.redact(room_id, event_id, reason=reason or None)
+ logger.info("Redacted event %s in %s", event_id, room_id)
+ return True
+ except Exception as exc:
+ logger.error("Failed to redact event %s: %s", event_id, exc)
+ return False
+
+
+# ── Reactions ───────────────────────────────────────────────────────────────
+
+
+async def send_reaction(
+ client: Any,
+ room_id: str,
+ event_id: str,
+ key: str,
+) -> bool:
+ """Send a reaction to a message.
+
+ Standard reactions: 👍, 👎, ❤️, 🎉, 😂, 😢, 😡, etc.
+
+ Args:
+ client: A connected mautrix Client or MatrixClient.
+ room_id: Room ID.
+ event_id: Event ID to react to.
+ key: Reaction key string (emoji).
+
+ Returns:
+ True if reaction was sent successfully.
+ """
+ try:
+ await client.react(room_id, event_id, key)
+ logger.info("Reacted to %s with %s", event_id, key)
+ return True
+ except Exception as exc:
+ logger.error("Failed to send reaction: %s", exc)
+ return False
+
+
+# ── Typing & read receipts ──────────────────────────────────────────────────
+
+
+async def send_typing(
+ client: Any,
+ room_id: str,
+ typing: bool = True,
+ timeout: int = 5000,
+) -> bool:
+ """Send a typing notification to a room.
+
+ Args:
+ client: A connected mautrix Client or MatrixClient.
+ room_id: Room ID.
+ typing: True to indicate typing, False to stop.
+ timeout: Typing timeout in milliseconds (default: 5000ms).
+
+ Returns:
+ True if the notification was sent.
+ """
+ try:
+ await client.set_typing(room_id, typing=typing, timeout=timeout)
+ return True
+ except Exception as exc:
+ logger.error("Failed to send typing notification: %s", exc)
+ return False
+
+
+async def send_read_receipt(
+ client: Any,
+ room_id: str,
+ event_id: str,
+) -> bool:
+ """Send a read receipt for a room event.
+
+ Args:
+ client: A connected mautrix Client or MatrixClient.
+ room_id: Room ID.
+ event_id: Event ID to mark as read.
+
+ Returns:
+ True if the receipt was sent.
+ """
+ try:
+ await client.send_receipt(room_id, event_id, "m.read")
+ return True
+ except Exception as exc:
+ logger.error("Failed to send read receipt: %s", exc)
+ return False
+
+
+async def set_fully_read(
+ client: Any,
+ room_id: str,
+ event_id: str,
+) -> bool:
+ """Set the fully-read marker in a room.
+
+ Args:
+ client: A connected mautrix Client or MatrixClient.
+ room_id: Room ID.
+ event_id: Event ID to mark as fully read.
+
+ Returns:
+ True if set successfully.
+ """
+ try:
+ await client.set_fully_read_marker(room_id, event_id)
+ return True
+ except Exception as exc:
+ logger.error("Failed to set fully-read marker: %s", exc)
+ return False
diff --git a/src/matrix_hermes/rooms.py b/src/matrix_hermes/rooms.py
new file mode 100644
index 0000000..45ecb34
--- /dev/null
+++ b/src/matrix_hermes/rooms.py
@@ -0,0 +1,551 @@
+"""Matrix room management: create, join, list, leave, invite, kick, ban.
+
+All functions operate on a mautrix Client and are async. Designed to
+work with the MatrixClient wrapper as well as standalone usage.
+"""
+
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass, field
+from typing import Any, Optional
+
+logger = logging.getLogger(__name__)
+
+
+# ── Data classes ────────────────────────────────────────────────────────────
+
+
+@dataclass
+class RoomInfo:
+ """Metadata about a Matrix room."""
+
+ room_id: str
+ """The room ID (!room:server)."""
+
+ name: str = ""
+ """Room display name (from m.room.name state)."""
+
+ topic: str = ""
+ """Room topic (from m.room.topic state)."""
+
+ avatar_url: str = ""
+ """Room avatar URL."""
+
+ canonical_alias: str = ""
+ """Primary alias for the room (#room:server)."""
+
+ member_count: int = 0
+ """Number of joined members."""
+
+ joined_members: int = 0
+ """Number of joined members."""
+
+ invited_members: int = 0
+ """Number of invited members."""
+
+ is_direct: bool = False
+ """True if this room is a direct message (1:1)."""
+
+ is_public: bool = False
+ """True if the room is publicly joinable."""
+
+ encryption: bool = False
+ """True if the room has encryption enabled."""
+
+ raw: dict[str, Any] | None = None
+ """Raw room state dict from the homeserver."""
+
+
+@dataclass
+class CreateRoomResult:
+ """Result of creating a new Matrix room."""
+
+ success: bool
+ """True if the room was created."""
+
+ room_id: str = ""
+ """The new room's ID."""
+
+ error: str = ""
+ """Error message if creation failed."""
+
+
+@dataclass
+class MemberInfo:
+ """Information about a room member."""
+
+ user_id: str
+ """Full Matrix user ID."""
+
+ display_name: str = ""
+ """Display name of the user."""
+
+ avatar_url: str = ""
+ """Avatar URL of the user."""
+
+ membership: str = ""
+ """Membership state: join, invite, leave, ban."""
+
+ power_level: int = 0
+ """Power level in the room (0=default, 50=moderator, 100=admin)."""
+
+
+@dataclass
+class PowerLevels:
+ """Room power level settings."""
+
+ users: dict[str, int] = field(default_factory=dict)
+ """Per-user power levels."""
+
+ users_default: int = 0
+ """Default power level for new users."""
+
+ events: dict[str, int] = field(default_factory=dict)
+ """Power levels required for specific event types."""
+
+ events_default: int = 0
+ """Default power level for sending events."""
+
+ state_default: int = 50
+ """Default power level for state events."""
+
+ ban: int = 50
+ kick: int = 50
+ redact: int = 50
+ invite: int = 0
+
+
+# ── Room operations ─────────────────────────────────────────────────────────
+
+
+async def create_room(
+ client: Any,
+ name: str = "",
+ topic: str = "",
+ alias_localpart: str = "",
+ preset: str = "private_chat",
+ visibility: str = "private",
+ invitees: list[str] | None = None,
+ is_direct: bool = False,
+ room_version: str = "",
+ power_level_override: dict[str, Any] | None = None,
+ initial_state: list[dict[str, Any]] | None = None,
+) -> CreateRoomResult:
+ """Create a new Matrix room.
+
+ Args:
+ client: A connected mautrix Client or MatrixClient.
+ name: Room display name.
+ topic: Room topic.
+ alias_localpart: Local part for a room alias (#localpart:server).
+ preset: Preset configuration (private_chat, trusted_private_chat, public_chat).
+ visibility: Room visibility (private or public).
+ invitees: List of user IDs to invite on creation.
+ is_direct: Mark as a direct message room.
+ room_version: Room version override (e.g. '10').
+ power_level_override: Custom power level settings.
+ initial_state: Additional initial state events.
+
+ Returns:
+ CreateRoomResult with success status and room_id.
+ """
+ try:
+ content: dict[str, Any] = {
+ "preset": preset,
+ "visibility": visibility,
+ }
+ if name:
+ content["name"] = name
+ if topic:
+ content["topic"] = topic
+ if alias_localpart:
+ content["room_alias_name"] = alias_localpart
+ if invitees:
+ content["invite"] = invitees
+ if is_direct:
+ content["is_direct"] = True
+ if room_version:
+ content["room_version"] = room_version
+ if power_level_override:
+ content["power_level_content_override"] = power_level_override
+ if initial_state:
+ content["initial_state"] = initial_state
+
+ room_id = await client.create_room(**content)
+ logger.info("Created room %s", room_id)
+ return CreateRoomResult(success=True, room_id=str(room_id))
+
+ except Exception as exc:
+ logger.error("Failed to create room: %s", exc)
+ return CreateRoomResult(success=False, error=str(exc))
+
+
+async def join_room(
+ client: Any,
+ room_id_or_alias: str,
+ servers: list[str] | None = None,
+) -> bool:
+ """Join a Matrix room by ID or alias.
+
+ Args:
+ client: A connected mautrix Client or MatrixClient.
+ room_id_or_alias: Room ID (!room:server) or alias (#room:server).
+ servers: Optional list of servers to try via federation.
+
+ Returns:
+ True if joined successfully.
+ """
+ try:
+ await client.join_room(room_id_or_alias, servers=servers)
+ logger.info("Joined room %s", room_id_or_alias)
+ return True
+ except Exception as exc:
+ logger.error("Failed to join room %s: %s", room_id_or_alias, exc)
+ return False
+
+
+async def leave_room(client: Any, room_id: str) -> bool:
+ """Leave a Matrix room.
+
+ Args:
+ client: A connected mautrix Client or MatrixClient.
+ room_id: Room ID to leave.
+
+ Returns:
+ True if left successfully.
+ """
+ try:
+ await 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 forget_room(client: Any, room_id: str) -> bool:
+ """Forget a Matrix room (remove from room list entirely).
+
+ Args:
+ client: A connected mautrix Client or MatrixClient.
+ room_id: Room ID to forget.
+
+ Returns:
+ True if forgotten successfully.
+ """
+ try:
+ await client.forget_room(room_id)
+ logger.info("Forgot room %s", room_id)
+ return True
+ except Exception as exc:
+ logger.error("Failed to forget room %s: %s", room_id, exc)
+ return False
+
+
+async def get_joined_rooms(client: Any) -> list[str]:
+ """Get the list of rooms the user has joined.
+
+ Args:
+ client: A connected mautrix Client or MatrixClient.
+
+ Returns:
+ List of room IDs.
+ """
+ try:
+ rooms = await 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 []
+
+
+async def resolve_room_alias(
+ client: Any,
+ alias: str,
+) -> tuple[str, list[str]]:
+ """Resolve a room alias (#room:server) to a room ID.
+
+ Args:
+ client: A connected mautrix Client or MatrixClient.
+ alias: Room alias to resolve.
+
+ Returns:
+ Tuple of (room_id, list_of_servers). Room ID is empty if resolution fails.
+ """
+ try:
+ result = await client.resolve_room_alias(alias)
+ room_id = str(result.get("room_id", ""))
+ servers = [str(s) for s in result.get("servers", [])]
+ return room_id, servers
+ except Exception as exc:
+ logger.error("Failed to resolve alias %s: %s", alias, exc)
+ return "", []
+
+
+# ── Room state & info ──────────────────────────────────────────────────────
+
+
+async def get_room_info(client: Any, room_id: str) -> RoomInfo | None:
+ """Get metadata about a Matrix room.
+
+ Fetches room state events to build a RoomInfo dataclass.
+
+ Args:
+ client: A connected mautrix Client or MatrixClient.
+ room_id: Room ID to query.
+
+ Returns:
+ RoomInfo with room metadata, or None on failure.
+ """
+ try:
+ # Fetch key state events
+ name = ""
+ topic = ""
+ avatar = ""
+ alias = ""
+
+ try:
+ name_event = await client.get_state_event(room_id, "m.room.name")
+ name = name_event.get("name", "") if name_event else ""
+ except Exception:
+ pass
+
+ try:
+ topic_event = await client.get_state_event(room_id, "m.room.topic")
+ topic = topic_event.get("topic", "") if topic_event else ""
+ except Exception:
+ pass
+
+ try:
+ avatar_event = await client.get_state_event(room_id, "m.room.avatar")
+ avatar = avatar_event.get("url", "") if avatar_event else ""
+ except Exception:
+ pass
+
+ try:
+ alias_event = await client.get_state_event(
+ room_id, "m.room.canonical_alias"
+ )
+ alias = alias_event.get("alias", "") if alias_event else ""
+ except Exception:
+ pass
+
+ # Member counts
+ members = await client.get_members(room_id)
+ joined = 0
+ invited = 0
+ for m in members:
+ membership = m.get("content", {}).get("membership", "leave")
+ if membership == "join":
+ joined += 1
+ elif membership == "invite":
+ invited += 1
+
+ # Encryption check
+ encryption = False
+ try:
+ enc_event = await client.get_state_event(room_id, "m.room.encryption")
+ encryption = enc_event is not None
+ except Exception:
+ pass
+
+ # Join rules
+ is_public = False
+ try:
+ join_rules = await client.get_state_event(room_id, "m.room.join_rules")
+ is_public = join_rules.get("join_rule", "invite") == "public" if join_rules else False
+ except Exception:
+ pass
+
+ return RoomInfo(
+ room_id=room_id,
+ name=name,
+ topic=topic,
+ avatar_url=avatar,
+ canonical_alias=alias,
+ member_count=joined + invited,
+ joined_members=joined,
+ invited_members=invited,
+ is_direct=joined == 2 and not is_public,
+ is_public=is_public,
+ encryption=encryption,
+ )
+
+ except Exception as exc:
+ logger.error("Failed to get room info for %s: %s", room_id, exc)
+ return None
+
+
+async def get_room_members(client: Any, room_id: str) -> list[MemberInfo]:
+ """Get the member list for a room.
+
+ Args:
+ client: A connected mautrix Client or MatrixClient.
+ room_id: Room ID to query.
+
+ Returns:
+ List of MemberInfo for each member.
+ """
+ try:
+ members = await client.get_members(room_id)
+ result: list[MemberInfo] = []
+ for m in members:
+ content = m.get("content", {})
+ result.append(
+ MemberInfo(
+ user_id=str(m.get("state_key", "")),
+ display_name=content.get("displayname", ""),
+ avatar_url=content.get("avatar_url", ""),
+ membership=content.get("membership", ""),
+ )
+ )
+ return result
+ except Exception as exc:
+ logger.error("Failed to get members for %s: %s", room_id, exc)
+ return []
+
+
+# ── Member management ──────────────────────────────────────────────────────
+
+
+async def invite_user(
+ client: Any,
+ room_id: str,
+ user_id: str,
+ reason: str = "",
+) -> bool:
+ """Invite a user to a Matrix room.
+
+ Args:
+ client: A connected mautrix Client or MatrixClient.
+ room_id: Room ID.
+ user_id: User ID to invite.
+ reason: Optional reason for the invitation.
+
+ Returns:
+ True if invited successfully.
+ """
+ try:
+ await client.invite_user(room_id, user_id, reason=reason or None)
+ logger.info("Invited %s to %s", user_id, room_id)
+ return True
+ except Exception as exc:
+ logger.error("Failed to invite %s to %s: %s", user_id, room_id, exc)
+ return False
+
+
+async def kick_user(
+ client: Any,
+ room_id: str,
+ user_id: str,
+ reason: str = "",
+) -> bool:
+ """Kick a user from a Matrix room.
+
+ Args:
+ client: A connected mautrix Client or MatrixClient.
+ room_id: Room ID.
+ user_id: User ID to kick.
+ reason: Optional reason for the kick.
+
+ Returns:
+ True if kicked successfully.
+ """
+ try:
+ await client.kick_user(room_id, user_id, reason=reason)
+ logger.info("Kicked %s from %s", user_id, room_id)
+ return True
+ except Exception as exc:
+ logger.error("Failed to kick %s from %s: %s", user_id, room_id, exc)
+ return False
+
+
+async def ban_user(
+ client: Any,
+ room_id: str,
+ user_id: str,
+ reason: str = "",
+) -> bool:
+ """Ban a user from a Matrix room.
+
+ Args:
+ client: A connected mautrix Client or MatrixClient.
+ room_id: Room ID.
+ user_id: User ID to ban.
+ reason: Optional reason for the ban.
+
+ Returns:
+ True if banned successfully.
+ """
+ try:
+ await client.ban_user(room_id, user_id, reason=reason)
+ logger.info("Banned %s from %s", user_id, room_id)
+ return True
+ except Exception as exc:
+ logger.error("Failed to ban %s from %s: %s", user_id, room_id, exc)
+ return False
+
+
+async def unban_user(
+ client: Any,
+ room_id: str,
+ user_id: str,
+) -> bool:
+ """Unban a user from a Matrix room.
+
+ Args:
+ client: A connected mautrix Client or MatrixClient.
+ room_id: Room ID.
+ user_id: User ID to unban.
+
+ Returns:
+ True if unbanned successfully.
+ """
+ try:
+ await client.unban_user(room_id, user_id)
+ logger.info("Unbanned %s from %s", user_id, room_id)
+ return True
+ except Exception as exc:
+ logger.error("Failed to unban %s from %s: %s", user_id, room_id, exc)
+ return False
+
+
+async def set_room_name(client: Any, room_id: str, name: str) -> bool:
+ """Set a room's display name.
+
+ Args:
+ client: A connected mautrix Client or MatrixClient.
+ room_id: Room ID.
+ name: New room name.
+
+ Returns:
+ True if set successfully.
+ """
+ try:
+ await client.send_state_event(room_id, "m.room.name", {"name": name})
+ logger.info("Set room name for %s: %s", room_id, name)
+ return True
+ except Exception as exc:
+ logger.error("Failed to set room name: %s", exc)
+ return False
+
+
+async def set_room_topic(client: Any, room_id: str, topic: str) -> bool:
+ """Set a room's topic.
+
+ Args:
+ client: A connected mautrix Client or MatrixClient.
+ room_id: Room ID.
+ topic: New room topic.
+
+ Returns:
+ True if set successfully.
+ """
+ try:
+ await client.send_state_event(room_id, "m.room.topic", {"topic": topic})
+ logger.info("Set room topic for %s", room_id)
+ return True
+ except Exception as exc:
+ logger.error("Failed to set room topic: %s", exc)
+ return False
diff --git a/src/matrix_hermes/sync.py b/src/matrix_hermes/sync.py
new file mode 100644
index 0000000..f3a823f
--- /dev/null
+++ b/src/matrix_hermes/sync.py
@@ -0,0 +1,562 @@
+"""Matrix sync engine: incremental sync, filtering, event dispatch.
+
+Provides a clean async API for syncing with a Matrix homeserver,
+processing timeline events, and dispatching them to registered handlers.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from dataclasses import dataclass, field
+from datetime import datetime
+from typing import Any, Awaitable, Callable, Optional
+
+logger = logging.getLogger(__name__)
+
+
+# ── Data classes ────────────────────────────────────────────────────────────
+
+
+@dataclass
+class SyncFilter:
+ """A Matrix sync filter definition.
+
+ See: https://spec.matrix.org/v1.11/client-server-api/#filtering
+ """
+
+ types: list[str] = field(default_factory=list)
+ """Event types to include. Empty means all."""
+
+ not_types: list[str] = field(default_factory=list)
+ """Event types to exclude."""
+
+ senders: list[str] = field(default_factory=list)
+ """Only include events from these senders."""
+
+ not_senders: list[str] = field(default_factory=list)
+ """Exclude events from these senders."""
+
+ rooms: list[str] = field(default_factory=list)
+ """Only include events from these rooms."""
+
+ not_rooms: list[str] = field(default_factory=list)
+ """Exclude events from these rooms."""
+
+ limit: int = 20
+ """Max number of events per room to return."""
+
+ include_reactions: bool = True
+ """Include reaction events."""
+
+ include_redactions: bool = True
+ """Include redaction events."""
+
+ include_typing: bool = False
+ """Include typing events."""
+
+ include_receipts: bool = False
+ """Include read receipt events."""
+
+ def to_json(self) -> dict[str, Any]:
+ """Convert to a Matrix filter JSON dict."""
+ room_filter: dict[str, Any] = {}
+ timeline_filter: dict[str, Any] = {
+ "limit": self.limit,
+ }
+
+ if self.types:
+ timeline_filter["types"] = self.types
+ if self.not_types:
+ timeline_filter["not_types"] = self.not_types
+
+ if self.rooms:
+ room_filter["rooms"] = self.rooms
+ if self.not_rooms:
+ room_filter["not_rooms"] = self.not_rooms
+
+ # Account data exclusions
+ account_data_filter: dict[str, Any] = {}
+ if not self.include_typing:
+ account_data_filter["not_types"] = account_data_filter.get(
+ "not_types", []
+ ) + ["m.typing"]
+ if not self.include_receipts:
+ account_data_filter["not_types"] = account_data_filter.get(
+ "not_types", []
+ ) + ["m.receipt"]
+
+ timeline_filter["rooms"] = room_filter if room_filter else None
+
+ # Lazy load members
+ state_filter: dict[str, Any] = {
+ "lazy_load_members": True,
+ }
+
+ result: dict[str, Any] = {
+ "room": {
+ "timeline": timeline_filter,
+ "state": state_filter,
+ },
+ }
+ if account_data_filter:
+ # Per spec, account_data uses the same structure
+ result["account_data"] = {
+ "not_types": account_data_filter.get("not_types", []),
+ "limit": self.limit,
+ }
+
+ return result
+
+
+@dataclass
+class SyncResult:
+ """Result of a single sync call."""
+
+ next_batch: str = ""
+ """The next_batch token for incremental sync."""
+
+ rooms: dict[str, dict[str, Any]] = field(default_factory=dict)
+ """Room data keyed by room_id, with 'timeline', 'state', etc."""
+
+ presence: dict[str, Any] = field(default_factory=dict)
+ """Presence events from this sync."""
+
+ raw: dict[str, Any] = field(default_factory=dict)
+ """Raw sync response from the homeserver."""
+
+
+@dataclass
+class SyncEvent:
+ """A single event extracted from a sync response."""
+
+ event_id: str
+ """The event ID."""
+
+ room_id: str
+ """The room where the event occurred."""
+
+ sender: str
+ """Full Matrix user ID of the sender."""
+
+ event_type: str
+ """Event type (m.room.message, m.room.member, etc.)."""
+
+ content: dict[str, Any]
+ """The event content dict."""
+
+ timestamp: int = 0
+ """Server timestamp (origin_server_ts) in milliseconds."""
+
+ state_key: str = ""
+ """State key for state events."""
+
+ raw: dict[str, Any] = field(default_factory=dict)
+ """Raw event dict."""
+
+
+# Type aliases
+EventHandler = Callable[[SyncEvent], Awaitable[None]]
+"""Async handler for a sync event."""
+
+
+# ── Filter management ──────────────────────────────────────────────────────
+
+
+async def create_filter(
+ client: Any,
+ sync_filter: SyncFilter | None = None,
+) -> str:
+ """Create a sync filter on the homeserver.
+
+ Args:
+ client: A connected mautrix Client or MatrixClient.
+ sync_filter: SyncFilter configuration. If None, uses a default.
+
+ Returns:
+ The filter ID string.
+ """
+ try:
+ from mautrix.types.filter import Filter
+
+ if sync_filter is None:
+ sync_filter = SyncFilter()
+
+ filter_json = sync_filter.to_json()
+ filter_obj = Filter.deserialize(filter_json)
+ filter_id = await client.create_filter(filter_obj)
+ return str(filter_id)
+ except Exception as exc:
+ logger.error("Failed to create filter: %s", exc)
+ # Fall back to no filter
+ return ""
+
+
+# ── Sync operations ────────────────────────────────────────────────────────
+
+
+async def sync_once(
+ client: Any,
+ since: str = "",
+ filter_id: str = "",
+ full_state: bool = False,
+ timeout: int = 30000,
+) -> SyncResult:
+ """Perform a single sync with the homeserver.
+
+ Args:
+ client: A connected mautrix Client or MatrixClient.
+ since: The next_batch token from a previous sync (empty for initial).
+ filter_id: Filter ID from create_filter().
+ full_state: If True, include all state events (initial sync).
+ timeout: Long-poll timeout in milliseconds.
+
+ Returns:
+ SyncResult with next_batch, room data, and presence.
+ """
+ try:
+ raw = await client.sync(
+ since=since or None,
+ timeout=timeout,
+ filter_id=filter_id or None,
+ full_state=full_state,
+ )
+
+ return SyncResult(
+ next_batch=raw.get("next_batch", ""),
+ rooms=raw.get("rooms", {}),
+ presence=raw.get("presence", {}),
+ raw=raw,
+ )
+ except Exception as exc:
+ logger.error("Sync failed: %s", exc)
+ return SyncResult()
+
+
+def extract_events(sync_result: SyncResult) -> list[SyncEvent]:
+ """Extract timeline events from a sync result.
+
+ Args:
+ sync_result: Result from sync_once().
+
+ Returns:
+ List of SyncEvent objects for all timeline events across rooms.
+ """
+ events: list[SyncEvent] = []
+ rooms = sync_result.rooms
+
+ for event_type in ("join", "invite", "leave"):
+ room_dict = rooms.get(event_type, {})
+ for room_id, room_data in room_dict.items():
+ # Timeline events
+ timeline = room_data.get("timeline", {})
+ for evt in timeline.get("events", []):
+ events.append(
+ SyncEvent(
+ event_id=evt.get("event_id", ""),
+ room_id=room_id,
+ sender=evt.get("sender", ""),
+ event_type=evt.get("type", ""),
+ content=evt.get("content", {}),
+ timestamp=evt.get("origin_server_ts", 0),
+ state_key=evt.get("state_key", ""),
+ raw=evt,
+ )
+ )
+
+ # State events
+ state = room_data.get("state", {})
+ for evt in state.get("events", []):
+ events.append(
+ SyncEvent(
+ event_id=evt.get("event_id", ""),
+ room_id=room_id,
+ sender=evt.get("sender", ""),
+ event_type=evt.get("type", ""),
+ content=evt.get("content", {}),
+ timestamp=evt.get("origin_server_ts", 0),
+ state_key=evt.get("state_key", ""),
+ raw=evt,
+ )
+ )
+
+ return events
+
+
+# ── Sync engine ────────────────────────────────────────────────────────────
+
+
+class SyncEngine:
+ """Continuous sync engine that polls the homeserver and dispatches events.
+
+ Usage::
+
+ engine = SyncEngine(client)
+ engine.on_event("m.room.message", handle_message)
+
+ syncer = asyncio.create_task(engine.run())
+ # ... later ...
+ await engine.stop()
+ await syncer # wait for clean shutdown
+
+ Auto-retries on transient failures with configurable backoff.
+ """
+
+ def __init__(
+ self,
+ client: Any,
+ filter_config: SyncFilter | None = None,
+ poll_timeout: int = 30000,
+ initial_sync: bool = False,
+ ):
+ """Initialize the sync engine.
+
+ Args:
+ client: A connected mautrix Client or MatrixClient.
+ filter_config: Optional sync filter configuration.
+ poll_timeout: Long-poll timeout in ms (default: 30000).
+ initial_sync: If True, do a full-state initial sync.
+ """
+ self._client = client
+ self._filter_config = filter_config
+ self._poll_timeout = poll_timeout
+ self._initial_sync = initial_sync
+
+ self._next_batch: str = ""
+ self._filter_id: str = ""
+ self._running = False
+ self._task: Optional[asyncio.Task] = None
+
+ # Handler registries
+ self._event_type_handlers: dict[str, list[EventHandler]] = {}
+ self._room_handlers: dict[str, list[EventHandler]] = {}
+ self._global_handlers: list[EventHandler] = []
+
+ # Stats
+ self.total_events: int = 0
+ self.total_syncs: int = 0
+ self.last_sync_time: Optional[datetime] = None
+
+ @property
+ def running(self) -> bool:
+ """True if the engine is currently running."""
+ return self._running
+
+ @property
+ def next_batch(self) -> str:
+ """The current next_batch token (non-empty while running)."""
+ return self._next_batch
+
+ # -- Handlers ------------------------------------------------------------
+
+ def on_event(
+ self,
+ event_type: str,
+ handler: EventHandler,
+ ) -> None:
+ """Register a handler for a specific event type.
+
+ Args:
+ event_type: Matrix event type (e.g. 'm.room.message').
+ handler: Async callable that receives each matching SyncEvent.
+ """
+ self._event_type_handlers.setdefault(event_type, []).append(handler)
+
+ def on_room(
+ self,
+ room_id: str,
+ handler: EventHandler,
+ ) -> None:
+ """Register a handler for events in a specific room.
+
+ Args:
+ room_id: Room ID to listen in.
+ handler: Async callable that receives each matching SyncEvent.
+ """
+ self._room_handlers.setdefault(room_id, []).append(handler)
+
+ def on_any(
+ self,
+ handler: EventHandler,
+ ) -> None:
+ """Register a global handler for all events.
+
+ Args:
+ handler: Async callable that receives every SyncEvent.
+ """
+ self._global_handlers.append(handler)
+
+ # -- Lifecycle -----------------------------------------------------------
+
+ async def run(self) -> None:
+ """Run the sync loop. Call stop() to terminate."""
+ if self._running:
+ return
+
+ self._running = True
+ logger.info("SyncEngine starting...")
+
+ # Create filter if configured
+ if self._filter_config:
+ self._filter_id = await create_filter(self._client, self._filter_config)
+
+ sync_count = 0
+ backoff = 1 # seconds
+
+ while self._running:
+ try:
+ result = await sync_once(
+ self._client,
+ since=self._next_batch,
+ filter_id=self._filter_id,
+ full_state=self._initial_sync and sync_count == 0,
+ timeout=self._poll_timeout,
+ )
+
+ # Reset backoff on success
+ backoff = 1
+
+ if result.next_batch:
+ self._next_batch = result.next_batch
+ sync_count += 1
+ self.total_syncs += 1
+ self.last_sync_time = datetime.now()
+
+ # Extract and dispatch events
+ events = extract_events(result)
+ for evt in events:
+ self.total_events += 1
+ await self._dispatch(evt)
+ except asyncio.CancelledError:
+ break
+ except Exception as exc:
+ logger.error(
+ "Sync error (retry in %ds): %s", backoff, exc
+ )
+ await asyncio.sleep(backoff)
+ backoff = min(backoff * 2, 60) # max 60s backoff
+
+ logger.info(
+ "SyncEngine stopped after %d syncs, %d events",
+ self.total_syncs,
+ self.total_events,
+ )
+
+ async def stop(self) -> None:
+ """Stop the sync engine and wait for clean shutdown."""
+ self._running = False
+ if self._task and not self._task.done():
+ self._task.cancel()
+ try:
+ await self._task
+ except asyncio.CancelledError:
+ pass
+
+ def start_background(self) -> asyncio.Task:
+ """Run the sync engine as a background task.
+
+ Returns:
+ The asyncio Task for the sync loop.
+ """
+ self._task = asyncio.create_task(self.run())
+ return self._task
+
+ # -- Internal dispatch ---------------------------------------------------
+
+ async def _dispatch(self, event: SyncEvent) -> None:
+ """Dispatch an event to all matching handlers."""
+ # Event type handlers
+ type_handlers = self._event_type_handlers.get(event.event_type, [])
+ for handler in type_handlers:
+ try:
+ await handler(event)
+ except Exception as exc:
+ logger.error(
+ "Handler for %s failed: %s", event.event_type, exc
+ )
+
+ # Room-specific handlers
+ room_handlers = self._room_handlers.get(event.room_id, [])
+ for handler in room_handlers:
+ try:
+ await handler(event)
+ except Exception as exc:
+ logger.error(
+ "Room handler for %s failed: %s", event.room_id, exc
+ )
+
+ # Global handlers
+ for handler in self._global_handlers:
+ try:
+ await handler(event)
+ except Exception as exc:
+ logger.error("Global handler failed: %s", exc)
+
+
+# ── Message history ────────────────────────────────────────────────────────
+
+
+@dataclass
+class PaginatedMessages:
+ """Result of fetching message history."""
+
+ messages: list[dict[str, Any]]
+ """List of event dicts."""
+
+ start: str = ""
+ """Pagination token for the start of the batch."""
+
+ end: str = ""
+ """Pagination token for the end of the batch."""
+
+ has_more: bool = False
+ """True if there are more messages to fetch."""
+
+
+async def get_messages(
+ client: Any,
+ room_id: str,
+ limit: int = 20,
+ from_token: str = "",
+ to_token: str = "",
+ direction: str = "b",
+) -> PaginatedMessages:
+ """Fetch message history from a room.
+
+ Args:
+ client: A connected mautrix Client or MatrixClient.
+ room_id: Room ID to fetch from.
+ limit: Max number of events to return (default: 20).
+ from_token: Pagination token to start from.
+ to_token: Pagination token to end at.
+ direction: 'b' for backwards (newest→oldest), 'f' for forwards.
+
+ Returns:
+ PaginatedMessages with events and pagination tokens.
+ """
+ try:
+ from mautrix.types import PaginationDirection
+
+ dir_enum = (
+ PaginationDirection.FORWARD
+ if direction == "f"
+ else PaginationDirection.BACKWARD
+ )
+
+ result = await client.get_messages(
+ room_id,
+ direction=dir_enum,
+ from_token=from_token or None,
+ to_token=to_token or None,
+ limit=limit,
+ )
+
+ return PaginatedMessages(
+ messages=[dict(m) if hasattr(m, "serialize") else m for m in result.events],
+ start=result.start,
+ end=result.end,
+ has_more=len(result.events) >= limit,
+ )
+ except Exception as exc:
+ logger.error("Failed to get messages from %s: %s", room_id, exc)
+ return PaginatedMessages(messages=[])
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 1aa00b9..b874abb 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -203,3 +203,100 @@ class TestCmdLogout:
args = build_parser().parse_args(["logout"])
result = cmd_logout(args)
assert result == 0
+
+
+# ---------------------------------------------------------------------------
+# New async command parser tests
+# ---------------------------------------------------------------------------
+
+
+class TestNewCommandsParser:
+ """Tests for parser entries of rooms, send, sync commands."""
+
+ def test_rooms_subparser_exists(self):
+ parser = build_parser()
+ choices = parser._subparsers._group_actions[0].choices
+ assert "rooms" in choices
+ assert "send" in choices
+ assert "sync" in choices
+
+ def test_rooms_list(self):
+ args = build_parser().parse_args(["rooms", "list"])
+ assert args.command == "rooms"
+ assert args.rooms_action == "list"
+
+ def test_rooms_create(self):
+ args = build_parser().parse_args(
+ ["rooms", "create", "--name", "Test", "--topic", "Hello", "--public"]
+ )
+ assert args.rooms_action == "create"
+ assert args.name == "Test"
+ assert args.topic == "Hello"
+ assert args.public is True
+
+ def test_rooms_join(self):
+ args = build_parser().parse_args(["rooms", "join", "!room:example.org"])
+ assert args.rooms_action == "join"
+ assert args.room == "!room:example.org"
+
+ def test_rooms_leave(self):
+ args = build_parser().parse_args(["rooms", "leave", "!room:example.org"])
+ assert args.rooms_action == "leave"
+ assert args.room == "!room:example.org"
+
+ def test_rooms_info(self):
+ args = build_parser().parse_args(["rooms", "info", "!room:example.org"])
+ assert args.rooms_action == "info"
+ assert args.room == "!room:example.org"
+
+ def test_rooms_members(self):
+ args = build_parser().parse_args(["rooms", "members", "!room:example.org"])
+ assert args.rooms_action == "members"
+
+ def test_rooms_invite(self):
+ args = build_parser().parse_args(
+ ["rooms", "invite", "!room:example.org", "@alice:example.org"]
+ )
+ assert args.rooms_action == "invite"
+ assert args.user == "@alice:example.org"
+
+ def test_send_text(self):
+ args = build_parser().parse_args(["send", "text", "!room:example.org", "Hello world"])
+ assert args.send_action == "text"
+ assert args.room == "!room:example.org"
+ assert args.body == "Hello world"
+
+ def test_send_text_with_reply(self):
+ args = build_parser().parse_args(
+ ["send", "text", "!room:example.org", "Replying", "--reply-to", "$evt:example.org"]
+ )
+ assert args.reply_to == "$evt:example.org"
+
+ def test_send_image(self):
+ args = build_parser().parse_args(
+ ["send", "image", "!room:example.org", "/tmp/photo.jpg", "--caption", "My photo"]
+ )
+ assert args.send_action == "image"
+ assert args.path == "/tmp/photo.jpg"
+ assert args.caption == "My photo"
+
+ def test_send_file(self):
+ args = build_parser().parse_args(
+ ["send", "file", "!room:example.org", "/tmp/doc.pdf"]
+ )
+ assert args.send_action == "file"
+
+ def test_sync_listen(self):
+ args = build_parser().parse_args(["sync", "listen"])
+ assert args.sync_action == "listen"
+ assert args.filter_type == []
+ assert args.room == []
+ assert args.show_all is False
+
+ def test_sync_listen_with_filters(self):
+ args = build_parser().parse_args(
+ ["sync", "listen", "--filter-type", "m.room.message", "--room", "!test:example.org", "--show-all"]
+ )
+ assert args.filter_type == ["m.room.message"]
+ assert args.room == ["!test:example.org"]
+ assert args.show_all is True
diff --git a/tests/test_messaging.py b/tests/test_messaging.py
new file mode 100644
index 0000000..aa9a7cf
--- /dev/null
+++ b/tests/test_messaging.py
@@ -0,0 +1,360 @@
+"""Test Matrix messaging: send text, media, edits, reactions, typing, receipts."""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from matrix_hermes.messaging import (
+ SendResult,
+ edit_message,
+ redact_message,
+ send_emote,
+ send_file,
+ send_image,
+ send_notice,
+ send_reaction,
+ send_read_receipt,
+ send_sticker,
+ send_text,
+ send_typing,
+ set_fully_read,
+)
+
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture
+def mock_client():
+ """Return a mock mautrix Client with all messaging methods as AsyncMock."""
+ client = MagicMock()
+ client.send_text = AsyncMock(return_value="$event123:example.org")
+ client.send_message = AsyncMock(return_value="$event456:example.org")
+ client.send_message_event = AsyncMock(return_value="$edit789:example.org")
+ client.send_emote = AsyncMock(return_value="$emote000:example.org")
+ client.send_image = AsyncMock(return_value="$img111:example.org")
+ client.send_file = AsyncMock(return_value="$file222:example.org")
+ client.send_sticker = AsyncMock(return_value="$sticker333:example.org")
+ client.redact = AsyncMock()
+ client.react = AsyncMock()
+ client.set_typing = AsyncMock()
+ client.send_receipt = AsyncMock()
+ client.set_fully_read_marker = AsyncMock()
+ return client
+
+
+# ---------------------------------------------------------------------------
+# Data class tests
+# ---------------------------------------------------------------------------
+
+
+class TestSendResult:
+ def test_success(self) -> None:
+ r = SendResult(success=True, event_id="$abc:example.org")
+ assert r.success is True
+ assert r.event_id == "$abc:example.org"
+ assert r.error == ""
+
+ def test_failure(self) -> None:
+ r = SendResult(success=False, error="Rate limited")
+ assert r.success is False
+ assert r.error == "Rate limited"
+ assert r.event_id == ""
+
+ def test_default_values(self) -> None:
+ r = SendResult(success=True)
+ assert r.event_id == ""
+ assert r.error == ""
+
+
+# ---------------------------------------------------------------------------
+# Core messaging
+# ---------------------------------------------------------------------------
+
+
+class TestSendText:
+ @pytest.mark.asyncio
+ async def test_send_simple(self, mock_client) -> None:
+ result = await send_text(mock_client, "!room:example.org", "Hello world")
+ assert result.success is True
+ assert result.event_id == "$event123:example.org"
+ mock_client.send_text.assert_called_once_with(
+ "!room:example.org",
+ text="Hello world",
+ html=None,
+ )
+
+ @pytest.mark.asyncio
+ async def test_send_with_html(self, mock_client) -> None:
+ result = await send_text(
+ mock_client, "!room:example.org", "Hello", html="Hello"
+ )
+ assert result.success is True
+ mock_client.send_text.assert_called_once_with(
+ "!room:example.org",
+ text="Hello",
+ html="Hello",
+ )
+
+ @pytest.mark.asyncio
+ async def test_send_with_reply(self, mock_client) -> None:
+ result = await send_text(
+ mock_client, "!room:example.org", "Replying!", reply_to="$orig:example.org"
+ )
+ assert result.success is True
+ call_kwargs = mock_client.send_text.call_args[1]
+ assert "relates_to" in call_kwargs
+ assert call_kwargs["relates_to"]["m.in_reply_to"]["event_id"] == "$orig:example.org"
+
+ @pytest.mark.asyncio
+ async def test_send_in_thread(self, mock_client) -> None:
+ result = await send_text(
+ mock_client, "!room:example.org", "Thread reply", thread_root="$thread:example.org"
+ )
+ assert result.success is True
+ call_kwargs = mock_client.send_text.call_args[1]
+ assert "relates_to" in call_kwargs
+ assert call_kwargs["relates_to"]["rel_type"] == "m.thread"
+ assert call_kwargs["relates_to"]["event_id"] == "$thread:example.org"
+
+ @pytest.mark.asyncio
+ async def test_send_failure(self, mock_client) -> None:
+ mock_client.send_text.side_effect = Exception("Network error")
+ result = await send_text(mock_client, "!room:example.org", "fail")
+ assert result.success is False
+ assert "Network error" in result.error
+
+
+class TestSendNotice:
+ @pytest.mark.asyncio
+ async def test_send_notice(self, mock_client) -> None:
+ result = await send_notice(mock_client, "!room:example.org", "Notice text")
+ assert result.success is True
+ assert result.event_id == "$event456:example.org"
+
+ @pytest.mark.asyncio
+ async def test_send_notice_with_html(self, mock_client) -> None:
+ result = await send_notice(
+ mock_client, "!room:example.org", "Notice", html="Notice"
+ )
+ assert result.success is True
+
+ @pytest.mark.asyncio
+ async def test_send_notice_failure(self, mock_client) -> None:
+ mock_client.send_message.side_effect = Exception("Fail")
+ result = await send_notice(mock_client, "!room:example.org", "fail")
+ assert result.success is False
+
+
+class TestSendEmote:
+ @pytest.mark.asyncio
+ async def test_send_emote(self, mock_client) -> None:
+ result = await send_emote(mock_client, "!room:example.org", "waves")
+ assert result.success is True
+ assert result.event_id == "$emote000:example.org"
+ mock_client.send_emote.assert_called_once_with("!room:example.org", "waves")
+
+ @pytest.mark.asyncio
+ async def test_send_emote_failure(self, mock_client) -> None:
+ mock_client.send_emote.side_effect = Exception("Fail")
+ result = await send_emote(mock_client, "!room:example.org", "fail")
+ assert result.success is False
+
+
+# ---------------------------------------------------------------------------
+# Media
+# ---------------------------------------------------------------------------
+
+
+class TestSendImage:
+ @pytest.mark.asyncio
+ async def test_send_image(self, mock_client) -> None:
+ result = await send_image(mock_client, "!room:example.org", "/tmp/photo.jpg")
+ assert result.success is True
+ assert result.event_id == "$img111:example.org"
+ mock_client.send_image.assert_called_once_with(
+ "!room:example.org",
+ "/tmp/photo.jpg",
+ caption=None,
+ thread_root=None,
+ )
+
+ @pytest.mark.asyncio
+ async def test_send_image_with_caption(self, mock_client) -> None:
+ result = await send_image(
+ mock_client, "!room:example.org", "/tmp/photo.jpg", caption="My photo"
+ )
+ assert result.success is True
+
+ @pytest.mark.asyncio
+ async def test_send_image_failure(self, mock_client) -> None:
+ mock_client.send_image.side_effect = Exception("Upload failed")
+ result = await send_image(mock_client, "!room:example.org", "/tmp/bad.jpg")
+ assert result.success is False
+
+
+class TestSendFile:
+ @pytest.mark.asyncio
+ async def test_send_file(self, mock_client) -> None:
+ result = await send_file(
+ mock_client, "!room:example.org", "/tmp/doc.pdf", filename="report.pdf"
+ )
+ assert result.success is True
+ assert result.event_id == "$file222:example.org"
+
+ @pytest.mark.asyncio
+ async def test_send_file_failure(self, mock_client) -> None:
+ mock_client.send_file.side_effect = Exception("Upload failed")
+ result = await send_file(mock_client, "!room:example.org", "/tmp/bad.pdf")
+ assert result.success is False
+
+
+class TestSendSticker:
+ @pytest.mark.asyncio
+ async def test_send_sticker(self, mock_client) -> None:
+ result = await send_sticker(mock_client, "!room:example.org", "/tmp/sticker.png")
+ assert result.success is True
+ mock_client.send_sticker.assert_called_once_with("!room:example.org", "/tmp/sticker.png")
+
+ @pytest.mark.asyncio
+ async def test_send_sticker_failure(self, mock_client) -> None:
+ mock_client.send_sticker.side_effect = Exception("Fail")
+ result = await send_sticker(mock_client, "!room:example.org", "/tmp/sticker.png")
+ assert result.success is False
+
+
+# ---------------------------------------------------------------------------
+# Editing & redaction
+# ---------------------------------------------------------------------------
+
+
+class TestEditMessage:
+ @pytest.mark.asyncio
+ async def test_edit_message(self, mock_client) -> None:
+ result = await edit_message(
+ mock_client, "!room:example.org", "$orig:example.org", "Fixed text"
+ )
+ assert result.success is True
+ assert result.event_id == "$edit789:example.org"
+
+ @pytest.mark.asyncio
+ async def test_edit_message_with_html(self, mock_client) -> None:
+ result = await edit_message(
+ mock_client,
+ "!room:example.org",
+ "$orig:example.org",
+ "Fixed **bold**",
+ new_html="Fixed bold",
+ )
+ assert result.success is True
+
+ @pytest.mark.asyncio
+ async def test_edit_message_failure(self, mock_client) -> None:
+ mock_client.send_message_event.side_effect = Exception("Fail")
+ result = await edit_message(
+ mock_client, "!room:example.org", "$orig:example.org", "edit"
+ )
+ assert result.success is False
+
+
+class TestRedactMessage:
+ @pytest.mark.asyncio
+ async def test_redact_success(self, mock_client) -> None:
+ ok = await redact_message(mock_client, "!room:example.org", "$msg:example.org")
+ assert ok is True
+
+ @pytest.mark.asyncio
+ async def test_redact_with_reason(self, mock_client) -> None:
+ ok = await redact_message(
+ mock_client, "!room:example.org", "$msg:example.org", reason="Offensive"
+ )
+ assert ok is True
+
+ @pytest.mark.asyncio
+ async def test_redact_failure(self, mock_client) -> None:
+ mock_client.redact.side_effect = Exception("No permission")
+ ok = await redact_message(mock_client, "!room:example.org", "$msg:example.org")
+ assert ok is False
+
+
+# ---------------------------------------------------------------------------
+# Reactions
+# ---------------------------------------------------------------------------
+
+
+class TestSendReaction:
+ @pytest.mark.asyncio
+ async def test_send_reaction(self, mock_client) -> None:
+ ok = await send_reaction(
+ mock_client, "!room:example.org", "$msg:example.org", "👍"
+ )
+ assert ok is True
+ mock_client.react.assert_called_once_with("!room:example.org", "$msg:example.org", "👍")
+
+ @pytest.mark.asyncio
+ async def test_send_reaction_failure(self, mock_client) -> None:
+ mock_client.react.side_effect = Exception("Fail")
+ ok = await send_reaction(mock_client, "!room:example.org", "$msg:example.org", "❤️")
+ assert ok is False
+
+
+# ---------------------------------------------------------------------------
+# Typing & receipts
+# ---------------------------------------------------------------------------
+
+
+class TestSendTyping:
+ @pytest.mark.asyncio
+ async def test_typing_on(self, mock_client) -> None:
+ ok = await send_typing(mock_client, "!room:example.org", typing=True, timeout=5000)
+ assert ok is True
+ mock_client.set_typing.assert_called_once_with(
+ "!room:example.org", typing=True, timeout=5000
+ )
+
+ @pytest.mark.asyncio
+ async def test_typing_off(self, mock_client) -> None:
+ ok = await send_typing(mock_client, "!room:example.org", typing=False)
+ assert ok is True
+
+ @pytest.mark.asyncio
+ async def test_typing_failure(self, mock_client) -> None:
+ mock_client.set_typing.side_effect = Exception("Fail")
+ ok = await send_typing(mock_client, "!room:example.org")
+ assert ok is False
+
+
+class TestSendReadReceipt:
+ @pytest.mark.asyncio
+ async def test_send_receipt(self, mock_client) -> None:
+ ok = await send_read_receipt(mock_client, "!room:example.org", "$msg:example.org")
+ assert ok is True
+ mock_client.send_receipt.assert_called_once_with(
+ "!room:example.org", "$msg:example.org", "m.read"
+ )
+
+ @pytest.mark.asyncio
+ async def test_send_receipt_failure(self, mock_client) -> None:
+ mock_client.send_receipt.side_effect = Exception("Fail")
+ ok = await send_read_receipt(mock_client, "!room:example.org", "$msg:example.org")
+ assert ok is False
+
+
+class TestSetFullyRead:
+ @pytest.mark.asyncio
+ async def test_set_fully_read(self, mock_client) -> None:
+ ok = await set_fully_read(mock_client, "!room:example.org", "$msg:example.org")
+ assert ok is True
+ mock_client.set_fully_read_marker.assert_called_once_with(
+ "!room:example.org", "$msg:example.org"
+ )
+
+ @pytest.mark.asyncio
+ async def test_set_fully_read_failure(self, mock_client) -> None:
+ mock_client.set_fully_read_marker.side_effect = Exception("Fail")
+ ok = await set_fully_read(mock_client, "!room:example.org", "$msg:example.org")
+ assert ok is False
diff --git a/tests/test_rooms.py b/tests/test_rooms.py
new file mode 100644
index 0000000..76d60a4
--- /dev/null
+++ b/tests/test_rooms.py
@@ -0,0 +1,458 @@
+"""Test Matrix room management: create, join, leave, info, members, invite, kick, ban."""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from matrix_hermes.rooms import (
+ CreateRoomResult,
+ MemberInfo,
+ PowerLevels,
+ RoomInfo,
+ ban_user,
+ create_room,
+ forget_room,
+ get_joined_rooms,
+ get_room_info,
+ get_room_members,
+ invite_user,
+ join_room,
+ kick_user,
+ leave_room,
+ resolve_room_alias,
+ set_room_name,
+ set_room_topic,
+ unban_user,
+)
+
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture
+def mock_client():
+ """Return a mock mautrix Client with all room methods as AsyncMock."""
+ client = MagicMock()
+ client.join_room = AsyncMock()
+ client.leave_room = AsyncMock()
+ client.forget_room = AsyncMock()
+ client.get_joined_rooms = AsyncMock(return_value=["!room1:example.org", "!room2:example.org"])
+ client.create_room = AsyncMock(return_value="!newroom:example.org")
+ client.resolve_room_alias = AsyncMock(
+ return_value={"room_id": "!room1:example.org", "servers": ["example.org"]}
+ )
+ client.get_state_event = AsyncMock()
+ client.get_members = AsyncMock()
+ client.invite_user = AsyncMock()
+ client.kick_user = AsyncMock()
+ client.ban_user = AsyncMock()
+ client.unban_user = AsyncMock()
+ client.send_state_event = AsyncMock()
+ return client
+
+
+# ---------------------------------------------------------------------------
+# Data class tests
+# ---------------------------------------------------------------------------
+
+
+class TestRoomInfo:
+ def test_default_values(self) -> None:
+ info = RoomInfo(room_id="!test:example.org")
+ assert info.room_id == "!test:example.org"
+ assert info.name == ""
+ assert info.topic == ""
+ assert info.member_count == 0
+ assert info.is_direct is False
+
+ def test_full_info(self) -> None:
+ info = RoomInfo(
+ room_id="!test:example.org",
+ name="Test Room",
+ topic="Testing",
+ avatar_url="mxc://example.org/avatar",
+ canonical_alias="#test:example.org",
+ member_count=42,
+ joined_members=40,
+ invited_members=2,
+ is_direct=False,
+ is_public=True,
+ encryption=True,
+ )
+ assert info.name == "Test Room"
+ assert info.topic == "Testing"
+ assert info.is_public is True
+ assert info.encryption is True
+ assert info.joined_members == 40
+
+
+class TestCreateRoomResult:
+ def test_success(self) -> None:
+ r = CreateRoomResult(success=True, room_id="!abc:example.org")
+ assert r.success is True
+ assert r.room_id == "!abc:example.org"
+ assert r.error == ""
+
+ def test_failure(self) -> None:
+ r = CreateRoomResult(success=False, error="Server error")
+ assert r.success is False
+ assert r.error == "Server error"
+ assert r.room_id == ""
+
+
+class TestMemberInfo:
+ def test_basic(self) -> None:
+ m = MemberInfo(user_id="@alice:example.org", display_name="Alice")
+ assert m.user_id == "@alice:example.org"
+ assert m.display_name == "Alice"
+ assert m.membership == ""
+ assert m.power_level == 0
+
+ def test_full(self) -> None:
+ m = MemberInfo(
+ user_id="@bob:example.org",
+ display_name="Bob",
+ avatar_url="mxc://example.org/bob",
+ membership="join",
+ power_level=50,
+ )
+ assert m.power_level == 50
+ assert m.membership == "join"
+
+
+class TestPowerLevels:
+ def test_defaults(self) -> None:
+ pl = PowerLevels()
+ assert pl.users_default == 0
+ assert pl.ban == 50
+ assert pl.invite == 0
+
+ def test_custom(self) -> None:
+ pl = PowerLevels(
+ users={"@admin:example.org": 100},
+ users_default=0,
+ ban=60,
+ invite=50,
+ )
+ assert pl.users["@admin:example.org"] == 100
+ assert pl.ban == 60
+ assert pl.invite == 50
+
+
+# ---------------------------------------------------------------------------
+# Room operations tests
+# ---------------------------------------------------------------------------
+
+
+class TestCreateRoom:
+ @pytest.mark.asyncio
+ async def test_create_room_basic(self, mock_client) -> None:
+ result = await create_room(mock_client, name="My Room")
+ assert result.success is True
+ assert result.room_id == "!newroom:example.org"
+ mock_client.create_room.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_create_room_with_all_options(self, mock_client) -> None:
+ result = await create_room(
+ mock_client,
+ name="Public Room",
+ topic="Hello world",
+ alias_localpart="myroom",
+ preset="public_chat",
+ visibility="public",
+ invitees=["@alice:example.org"],
+ is_direct=False,
+ room_version="10",
+ )
+ assert result.success is True
+ call_kwargs = mock_client.create_room.call_args[1]
+ assert call_kwargs["name"] == "Public Room"
+ assert call_kwargs["topic"] == "Hello world"
+ assert call_kwargs["preset"] == "public_chat"
+
+ @pytest.mark.asyncio
+ async def test_create_room_failure(self, mock_client) -> None:
+ mock_client.create_room.side_effect = Exception("Server error")
+ result = await create_room(mock_client, name="Fail")
+ assert result.success is False
+ assert "Server error" in result.error
+
+
+class TestJoinRoom:
+ @pytest.mark.asyncio
+ async def test_join_success(self, mock_client) -> None:
+ ok = await join_room(mock_client, "!room:example.org")
+ assert ok is True
+ mock_client.join_room.assert_called_once_with("!room:example.org", servers=None)
+
+ @pytest.mark.asyncio
+ async def test_join_with_servers(self, mock_client) -> None:
+ ok = await join_room(mock_client, "#room:example.org", servers=["matrix.org"])
+ assert ok is True
+ mock_client.join_room.assert_called_once_with("#room:example.org", servers=["matrix.org"])
+
+ @pytest.mark.asyncio
+ async def test_join_failure(self, mock_client) -> None:
+ mock_client.join_room.side_effect = Exception("Room not found")
+ ok = await join_room(mock_client, "!bad:example.org")
+ assert ok is False
+
+
+class TestLeaveRoom:
+ @pytest.mark.asyncio
+ async def test_leave_success(self, mock_client) -> None:
+ ok = await leave_room(mock_client, "!room:example.org")
+ assert ok is True
+ mock_client.leave_room.assert_called_once_with("!room:example.org")
+
+ @pytest.mark.asyncio
+ async def test_leave_failure(self, mock_client) -> None:
+ mock_client.leave_room.side_effect = Exception("Not joined")
+ ok = await leave_room(mock_client, "!room:example.org")
+ assert ok is False
+
+
+class TestForgetRoom:
+ @pytest.mark.asyncio
+ async def test_forget_success(self, mock_client) -> None:
+ ok = await forget_room(mock_client, "!room:example.org")
+ assert ok is True
+
+ @pytest.mark.asyncio
+ async def test_forget_failure(self, mock_client) -> None:
+ mock_client.forget_room.side_effect = Exception("Error")
+ ok = await forget_room(mock_client, "!room:example.org")
+ assert ok is False
+
+
+class TestGetJoinedRooms:
+ @pytest.mark.asyncio
+ async def test_returns_rooms(self, mock_client) -> None:
+ rooms = await get_joined_rooms(mock_client)
+ assert rooms == ["!room1:example.org", "!room2:example.org"]
+
+ @pytest.mark.asyncio
+ async def test_returns_empty_on_error(self, mock_client) -> None:
+ mock_client.get_joined_rooms.side_effect = Exception("Network error")
+ rooms = await get_joined_rooms(mock_client)
+ assert rooms == []
+
+
+class TestResolveRoomAlias:
+ @pytest.mark.asyncio
+ async def test_resolve_success(self, mock_client) -> None:
+ room_id, servers = await resolve_room_alias(mock_client, "#test:example.org")
+ assert room_id == "!room1:example.org"
+ assert servers == ["example.org"]
+
+ @pytest.mark.asyncio
+ async def test_resolve_failure(self, mock_client) -> None:
+ mock_client.resolve_room_alias.side_effect = Exception("Not found")
+ room_id, servers = await resolve_room_alias(mock_client, "#bad:example.org")
+ assert room_id == ""
+ assert servers == []
+
+
+# ---------------------------------------------------------------------------
+# Room info & members
+# ---------------------------------------------------------------------------
+
+
+class TestGetRoomInfo:
+ @pytest.mark.asyncio
+ async def test_get_room_info_basic(self, mock_client) -> None:
+ mock_client.get_state_event = AsyncMock(side_effect=lambda room_id, event_type: {
+ "m.room.name": {"name": "Test Room"},
+ "m.room.topic": {"topic": "Testing stuff"},
+ "m.room.avatar": {"url": "mxc://example.org/avatar"},
+ "m.room.canonical_alias": {"alias": "#test:example.org"},
+ "m.room.join_rules": {"join_rule": "invite"},
+ "m.room.encryption": {"algorithm": "m.megolm.v1.aes-sha2"},
+ }.get(event_type, None))
+
+ mock_client.get_members = AsyncMock(return_value=[
+ {"state_key": "@a:example.org", "content": {"membership": "join", "displayname": "A"}},
+ {"state_key": "@b:example.org", "content": {"membership": "join", "displayname": "B"}},
+ {"state_key": "@c:example.org", "content": {"membership": "invite", "displayname": "C"}},
+ ])
+
+ info = await get_room_info(mock_client, "!room:example.org")
+ assert info is not None
+ assert info.name == "Test Room"
+ assert info.topic == "Testing stuff"
+ assert info.avatar_url == "mxc://example.org/avatar"
+ assert info.canonical_alias == "#test:example.org"
+ assert info.joined_members == 2
+ assert info.invited_members == 1
+ assert info.member_count == 3
+ assert info.encryption is True
+ assert info.is_public is False
+ # joined==2 and not is_public → heuristically direct
+ assert info.is_direct is True
+
+ @pytest.mark.asyncio
+ async def test_get_room_info_public(self, mock_client) -> None:
+ mock_client.get_state_event = AsyncMock(side_effect=lambda room_id, event_type: {
+ "m.room.join_rules": {"join_rule": "public"},
+ }.get(event_type, None))
+ mock_client.get_members = AsyncMock(return_value=[
+ {"state_key": "@a:example.org", "content": {"membership": "join"}},
+ {"state_key": "@b:example.org", "content": {"membership": "join"}},
+ ])
+
+ info = await get_room_info(mock_client, "!room:example.org")
+ assert info is not None
+ assert info.is_public is True
+ # public room + not direct since is_public → not direct
+ assert info.is_direct is False
+
+ @pytest.mark.asyncio
+ async def test_get_room_info_error(self, mock_client) -> None:
+ # Individual state event exceptions are caught internally,
+ # so we get a RoomInfo with defaults. Only a total failure
+ # (e.g. get_members failing) returns None.
+ mock_client.get_state_event = AsyncMock(side_effect=Exception("Failed"))
+ mock_client.get_members = AsyncMock(return_value=[])
+ info = await get_room_info(mock_client, "!room:example.org")
+ assert info is not None
+ assert info.room_id == "!room:example.org"
+ assert info.name == "" # failed state events → empty defaults
+
+
+class TestGetRoomMembers:
+ @pytest.mark.asyncio
+ async def test_returns_members(self, mock_client) -> None:
+ mock_client.get_members = AsyncMock(return_value=[
+ {
+ "state_key": "@alice:example.org",
+ "content": {"membership": "join", "displayname": "Alice", "avatar_url": "mxc://a"},
+ },
+ {
+ "state_key": "@bob:example.org",
+ "content": {"membership": "join", "displayname": "Bob"},
+ },
+ ])
+ members = await get_room_members(mock_client, "!room:example.org")
+ assert len(members) == 2
+ assert members[0].user_id == "@alice:example.org"
+ assert members[0].display_name == "Alice"
+ assert members[0].avatar_url == "mxc://a"
+ assert members[0].membership == "join"
+
+ @pytest.mark.asyncio
+ async def test_returns_empty_on_error(self, mock_client) -> None:
+ mock_client.get_members.side_effect = Exception("Error")
+ members = await get_room_members(mock_client, "!room:example.org")
+ assert members == []
+
+
+# ---------------------------------------------------------------------------
+# Member management
+# ---------------------------------------------------------------------------
+
+
+class TestInviteUser:
+ @pytest.mark.asyncio
+ async def test_invite_success(self, mock_client) -> None:
+ ok = await invite_user(mock_client, "!room:example.org", "@alice:example.org")
+ assert ok is True
+ mock_client.invite_user.assert_called_once_with(
+ "!room:example.org", "@alice:example.org", reason=None
+ )
+
+ @pytest.mark.asyncio
+ async def test_invite_with_reason(self, mock_client) -> None:
+ ok = await invite_user(
+ mock_client, "!room:example.org", "@bob:example.org", reason="Welcome!"
+ )
+ assert ok is True
+ mock_client.invite_user.assert_called_once_with(
+ "!room:example.org", "@bob:example.org", reason="Welcome!"
+ )
+
+ @pytest.mark.asyncio
+ async def test_invite_failure(self, mock_client) -> None:
+ mock_client.invite_user.side_effect = Exception("User blocked")
+ ok = await invite_user(mock_client, "!room:example.org", "@bad:example.org")
+ assert ok is False
+
+
+class TestKickUser:
+ @pytest.mark.asyncio
+ async def test_kick_success(self, mock_client) -> None:
+ ok = await kick_user(mock_client, "!room:example.org", "@spammer:example.org")
+ assert ok is True
+
+ @pytest.mark.asyncio
+ async def test_kick_failure(self, mock_client) -> None:
+ mock_client.kick_user.side_effect = Exception("No permission")
+ ok = await kick_user(mock_client, "!room:example.org", "@admin:example.org")
+ assert ok is False
+
+
+class TestBanUser:
+ @pytest.mark.asyncio
+ async def test_ban_success(self, mock_client) -> None:
+ ok = await ban_user(mock_client, "!room:example.org", "@spammer:example.org", reason="Spam")
+ assert ok is True
+
+ @pytest.mark.asyncio
+ async def test_ban_failure(self, mock_client) -> None:
+ mock_client.ban_user.side_effect = Exception("No permission")
+ ok = await ban_user(mock_client, "!room:example.org", "@admin:example.org")
+ assert ok is False
+
+
+class TestUnbanUser:
+ @pytest.mark.asyncio
+ async def test_unban_success(self, mock_client) -> None:
+ ok = await unban_user(mock_client, "!room:example.org", "@former_spammer:example.org")
+ assert ok is True
+
+ @pytest.mark.asyncio
+ async def test_unban_failure(self, mock_client) -> None:
+ mock_client.unban_user.side_effect = Exception("Error")
+ ok = await unban_user(mock_client, "!room:example.org", "@user:example.org")
+ assert ok is False
+
+
+# ---------------------------------------------------------------------------
+# Room state
+# ---------------------------------------------------------------------------
+
+
+class TestSetRoomName:
+ @pytest.mark.asyncio
+ async def test_set_name_success(self, mock_client) -> None:
+ ok = await set_room_name(mock_client, "!room:example.org", "New Name")
+ assert ok is True
+ mock_client.send_state_event.assert_called_once_with(
+ "!room:example.org", "m.room.name", {"name": "New Name"}
+ )
+
+ @pytest.mark.asyncio
+ async def test_set_name_failure(self, mock_client) -> None:
+ mock_client.send_state_event.side_effect = Exception("No permission")
+ ok = await set_room_name(mock_client, "!room:example.org", "Name")
+ assert ok is False
+
+
+class TestSetRoomTopic:
+ @pytest.mark.asyncio
+ async def test_set_topic_success(self, mock_client) -> None:
+ ok = await set_room_topic(mock_client, "!room:example.org", "New Topic")
+ assert ok is True
+ mock_client.send_state_event.assert_called_once_with(
+ "!room:example.org", "m.room.topic", {"topic": "New Topic"}
+ )
+
+ @pytest.mark.asyncio
+ async def test_set_topic_failure(self, mock_client) -> None:
+ mock_client.send_state_event.side_effect = Exception("No permission")
+ ok = await set_room_topic(mock_client, "!room:example.org", "Topic")
+ assert ok is False
diff --git a/tests/test_sync.py b/tests/test_sync.py
new file mode 100644
index 0000000..281f9a3
--- /dev/null
+++ b/tests/test_sync.py
@@ -0,0 +1,434 @@
+"""Test Matrix sync engine: filtering, sync, event extraction, dispatch."""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from matrix_hermes.sync import (
+ EventHandler,
+ PaginatedMessages,
+ SyncEngine,
+ SyncEvent,
+ SyncFilter,
+ SyncResult,
+ create_filter,
+ extract_events,
+ get_messages,
+ sync_once,
+)
+
+
+# ---------------------------------------------------------------------------
+# Data class tests
+# ---------------------------------------------------------------------------
+
+
+class TestSyncFilter:
+ def test_default(self) -> None:
+ f = SyncFilter()
+ assert f.types == []
+ assert f.not_types == []
+ assert f.limit == 20
+ assert f.include_reactions is True
+ assert f.include_typing is False
+
+ def test_to_json_basic(self) -> None:
+ f = SyncFilter()
+ j = f.to_json()
+ assert "room" in j
+ assert j["room"]["timeline"]["limit"] == 20
+ assert j["room"]["state"]["lazy_load_members"] is True
+
+ def test_to_json_with_filters(self) -> None:
+ f = SyncFilter(
+ types=["m.room.message"],
+ not_types=["m.typing"],
+ rooms=["!room1:example.org"],
+ limit=50,
+ include_typing=True,
+ include_receipts=True,
+ )
+ j = f.to_json()
+ tl = j["room"]["timeline"]
+ assert tl["types"] == ["m.room.message"]
+ assert tl["not_types"] == ["m.typing"]
+
+ def test_to_json_excludes_typing_and_receipts(self) -> None:
+ f = SyncFilter(include_typing=False, include_receipts=False)
+ j = f.to_json()
+ if "account_data" in j:
+ not_types = j["account_data"].get("not_types", [])
+ assert "m.typing" in not_types
+ assert "m.receipt" in not_types
+
+
+class TestSyncResult:
+ def test_default(self) -> None:
+ r = SyncResult()
+ assert r.next_batch == ""
+ assert r.rooms == {}
+ assert r.presence == {}
+
+ def test_with_data(self) -> None:
+ r = SyncResult(
+ next_batch="s12345",
+ rooms={"join": {"!room:example.org": {}}},
+ presence={"events": []},
+ )
+ assert r.next_batch == "s12345"
+ assert "!room:example.org" in r.rooms["join"]
+
+
+class TestSyncEvent:
+ def test_basic(self) -> None:
+ e = SyncEvent(
+ event_id="$evt:example.org",
+ room_id="!room:example.org",
+ sender="@alice:example.org",
+ event_type="m.room.message",
+ content={"body": "hi", "msgtype": "m.text"},
+ timestamp=1234567890,
+ )
+ assert e.event_id == "$evt:example.org"
+ assert e.sender == "@alice:example.org"
+ assert e.content["body"] == "hi"
+ assert e.timestamp == 1234567890
+ assert e.state_key == ""
+
+
+class TestPaginatedMessages:
+ def test_default(self) -> None:
+ pm = PaginatedMessages(messages=[])
+ assert pm.messages == []
+ assert pm.start == ""
+ assert pm.has_more is False
+
+ def test_with_data(self) -> None:
+ msgs = [{"event_id": "$1", "content": {"body": "hi"}}]
+ pm = PaginatedMessages(messages=msgs, start="t1", end="t2", has_more=True)
+ assert len(pm.messages) == 1
+ assert pm.has_more is True
+
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture
+def mock_client():
+ """Return a mock mautrix Client with sync methods as AsyncMock."""
+ client = MagicMock()
+ client.sync = AsyncMock()
+ client.create_filter = AsyncMock(return_value="filter_id_123")
+ client.get_messages = AsyncMock()
+ return client
+
+
+@pytest.fixture
+def sample_sync_response():
+ """Return a realistic Matrix sync response."""
+ return {
+ "next_batch": "s72595_4483_1934",
+ "rooms": {
+ "join": {
+ "!room1:example.org": {
+ "timeline": {
+ "events": [
+ {
+ "event_id": "$evt1:example.org",
+ "sender": "@alice:example.org",
+ "type": "m.room.message",
+ "origin_server_ts": 1680000000000,
+ "content": {"body": "Hello!", "msgtype": "m.text"},
+ },
+ {
+ "event_id": "$evt2:example.org",
+ "sender": "@bob:example.org",
+ "type": "m.room.message",
+ "origin_server_ts": 1680000001000,
+ "content": {"body": "Hi!", "msgtype": "m.text"},
+ },
+ ]
+ },
+ "state": {
+ "events": [
+ {
+ "event_id": "$state1:example.org",
+ "sender": "@alice:example.org",
+ "type": "m.room.name",
+ "origin_server_ts": 1670000000000,
+ "state_key": "",
+ "content": {"name": "Test Room"},
+ }
+ ]
+ },
+ }
+ }
+ },
+ "presence": {"events": []},
+ }
+
+
+# ---------------------------------------------------------------------------
+# Filter management
+# ---------------------------------------------------------------------------
+
+
+class TestCreateFilter:
+ @pytest.mark.asyncio
+ async def test_create_filter(self, mock_client) -> None:
+ filter_id = await create_filter(mock_client)
+ assert filter_id == "filter_id_123"
+ mock_client.create_filter.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_create_filter_with_config(self, mock_client) -> None:
+ sf = SyncFilter(types=["m.room.message"], limit=10)
+ filter_id = await create_filter(mock_client, sf)
+ assert filter_id == "filter_id_123"
+
+ @pytest.mark.asyncio
+ async def test_create_filter_fallback_on_error(self, mock_client) -> None:
+ mock_client.create_filter.side_effect = Exception("Failed")
+ filter_id = await create_filter(mock_client)
+ assert filter_id == ""
+
+
+# ---------------------------------------------------------------------------
+# Sync operations
+# ---------------------------------------------------------------------------
+
+
+class TestSyncOnce:
+ @pytest.mark.asyncio
+ async def test_sync_initial(self, mock_client, sample_sync_response) -> None:
+ mock_client.sync.return_value = sample_sync_response
+ result = await sync_once(mock_client)
+ assert result.next_batch == "s72595_4483_1934"
+ assert "!room1:example.org" in result.rooms.get("join", {})
+
+ @pytest.mark.asyncio
+ async def test_sync_with_since(self, mock_client, sample_sync_response) -> None:
+ mock_client.sync.return_value = sample_sync_response
+ result = await sync_once(mock_client, since="s_prev")
+ assert result.next_batch == "s72595_4483_1934"
+
+ @pytest.mark.asyncio
+ async def test_sync_with_filter(self, mock_client, sample_sync_response) -> None:
+ mock_client.sync.return_value = sample_sync_response
+ result = await sync_once(mock_client, filter_id="f123")
+ assert result.next_batch != ""
+
+ @pytest.mark.asyncio
+ async def test_sync_failure(self, mock_client) -> None:
+ mock_client.sync.side_effect = Exception("Connection lost")
+ result = await sync_once(mock_client)
+ assert result.next_batch == ""
+ assert result.rooms == {}
+
+
+class TestExtractEvents:
+ def test_extract_timeline_and_state(self, sample_sync_response) -> None:
+ sr = SyncResult(
+ next_batch="s123",
+ rooms=sample_sync_response["rooms"],
+ presence=sample_sync_response["presence"],
+ )
+ events = extract_events(sr)
+ # 2 timeline + 1 state = 3 events
+ assert len(events) == 3
+
+ def test_extract_event_properties(self, sample_sync_response) -> None:
+ sr = SyncResult(rooms=sample_sync_response["rooms"])
+ events = extract_events(sr)
+ msg_events = [e for e in events if e.event_type == "m.room.message"]
+ assert len(msg_events) == 2
+ assert msg_events[0].event_id == "$evt1:example.org"
+ assert msg_events[0].sender == "@alice:example.org"
+ assert msg_events[0].content["body"] == "Hello!"
+
+ def test_extract_state_event(self, sample_sync_response) -> None:
+ sr = SyncResult(rooms=sample_sync_response["rooms"])
+ events = extract_events(sr)
+ state_events = [e for e in events if e.event_type == "m.room.name"]
+ assert len(state_events) == 1
+ assert state_events[0].state_key == ""
+ assert state_events[0].content["name"] == "Test Room"
+
+ def test_extract_empty(self) -> None:
+ sr = SyncResult(rooms={})
+ events = extract_events(sr)
+ assert events == []
+
+
+# ---------------------------------------------------------------------------
+# Sync engine
+# ---------------------------------------------------------------------------
+
+
+class TestSyncEngine:
+ @pytest.fixture
+ def engine(self, mock_client):
+ return SyncEngine(mock_client, poll_timeout=1000)
+
+ def test_init_defaults(self, engine) -> None:
+ assert engine.running is False
+ assert engine.next_batch == ""
+ assert engine.total_events == 0
+
+ def test_register_handler(self, engine) -> None:
+ called = []
+
+ async def handler(event):
+ called.append(event)
+
+ engine.on_event("m.room.message", handler)
+ assert "m.room.message" in engine._event_type_handlers
+ assert len(engine._event_type_handlers["m.room.message"]) == 1
+
+ def test_register_room_handler(self, engine) -> None:
+ async def handler(event):
+ pass
+
+ engine.on_room("!room:example.org", handler)
+ assert "!room:example.org" in engine._room_handlers
+
+ def test_register_global_handler(self, engine) -> None:
+ async def handler(event):
+ pass
+
+ engine.on_any(handler)
+ assert len(engine._global_handlers) == 1
+
+ @pytest.mark.asyncio
+ async def test_start_background(self, engine) -> None:
+ task = engine.start_background()
+ assert task is not None
+ assert engine._task is task
+ # Clean up: stop the engine so the task doesn't run forever
+ await engine.stop()
+
+ @pytest.mark.asyncio
+ async def test_dispatch_to_event_type_handler(self, engine) -> None:
+ called = []
+
+ async def handler(event):
+ called.append(event)
+
+ engine.on_event("m.room.message", handler)
+
+ event = SyncEvent(
+ event_id="$e",
+ room_id="!r:example.org",
+ sender="@u:example.org",
+ event_type="m.room.message",
+ content={"body": "test"},
+ )
+ await engine._dispatch(event)
+ assert len(called) == 1
+ assert called[0].event_id == "$e"
+
+ @pytest.mark.asyncio
+ async def test_dispatch_to_room_handler(self, engine) -> None:
+ called = []
+
+ async def handler(event):
+ called.append(event)
+
+ engine.on_room("!room:example.org", handler)
+
+ event = SyncEvent(
+ event_id="$e",
+ room_id="!room:example.org",
+ sender="@u:example.org",
+ event_type="m.room.message",
+ content={"body": "test"},
+ )
+ await engine._dispatch(event)
+ assert len(called) == 1
+
+ @pytest.mark.asyncio
+ async def test_dispatch_to_global_handler(self, engine) -> None:
+ called = []
+
+ async def handler(event):
+ called.append(event)
+
+ engine.on_any(handler)
+
+ event = SyncEvent(
+ event_id="$e",
+ room_id="!r:example.org",
+ sender="@u:example.org",
+ event_type="m.room.member",
+ content={"membership": "join"},
+ )
+ await engine._dispatch(event)
+ assert len(called) == 1
+
+ @pytest.mark.asyncio
+ async def test_dispatch_handles_handler_exception(self, engine, caplog) -> None:
+ async def bad_handler(event):
+ raise RuntimeError("Handler failed")
+
+ engine.on_any(bad_handler)
+
+ event = SyncEvent(
+ event_id="$e",
+ room_id="!r:example.org",
+ sender="@u:example.org",
+ event_type="m.room.message",
+ content={},
+ )
+ # Should not raise
+ await engine._dispatch(event)
+
+ @pytest.mark.asyncio
+ async def test_stop_not_running(self, engine) -> None:
+ # Should not raise — no task running
+ await engine.stop()
+
+
+# ---------------------------------------------------------------------------
+# Message history
+# ---------------------------------------------------------------------------
+
+
+class TestGetMessages:
+ @pytest.mark.asyncio
+ async def test_get_messages_backward(self, mock_client) -> None:
+ mock_event = MagicMock()
+ mock_event.serialize.return_value = {"event_id": "$1", "type": "m.room.message"}
+ mock_result = MagicMock()
+ mock_result.events = [mock_event]
+ mock_result.start = "t1"
+ mock_result.end = "t2"
+ mock_client.get_messages.return_value = mock_result
+
+ result = await get_messages(mock_client, "!room:example.org", limit=10)
+ assert len(result.messages) == 1
+ assert result.start == "t1"
+ assert result.has_more is False # 1 < 10
+
+ @pytest.mark.asyncio
+ async def test_get_messages_has_more(self, mock_client) -> None:
+ mock_event = MagicMock()
+ mock_event.serialize.return_value = {"event_id": "$1"}
+ mock_result = MagicMock()
+ mock_result.events = [mock_event] * 20 # 20 events, limit=20
+ mock_result.start = "t1"
+ mock_result.end = "t2"
+ mock_client.get_messages.return_value = mock_result
+
+ result = await get_messages(mock_client, "!room:example.org", limit=20)
+ assert len(result.messages) == 20
+ assert result.has_more is True
+
+ @pytest.mark.asyncio
+ async def test_get_messages_error(self, mock_client) -> None:
+ mock_client.get_messages.side_effect = Exception("Failed")
+ result = await get_messages(mock_client, "!room:example.org")
+ assert result.messages == []