From 3ddf8170f8e351d12d239dda2bfc867630d85ad6 Mon Sep 17 00:00:00 2001 From: Shawn Date: Sat, 23 May 2026 12:07:10 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20Matrix=20connector=20core=20=E2=80=94?= =?UTF-8?q?=20rooms,=20messaging,=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rooms: create, join, leave, forget, list, resolve alias, get info/members, invite/kick/ban/unban, set name/topic. RoomInfo/MemberInfo/PowerLevels dataclasses. - messaging: send text (with HTML, reply, thread), notice, emote, image, file, sticker, edit, redact, react, typing, read receipts, fully-read markers. - sync: SyncEngine with event-type/room/global handler dispatch, incremental sync with backoff retry, SyncFilter, paginated message history. - CLI: rooms (list/create/join/leave/info/members/invite), send (text/image/file), sync (listen with filters). - 182 tests pass (60 existing + 122 new). --- src/matrix_hermes/__init__.py | 152 ++++++++- src/matrix_hermes/cli.py | 323 +++++++++++++++++++ src/matrix_hermes/messaging.py | 417 ++++++++++++++++++++++++ src/matrix_hermes/rooms.py | 551 ++++++++++++++++++++++++++++++++ src/matrix_hermes/sync.py | 562 +++++++++++++++++++++++++++++++++ tests/test_cli.py | 97 ++++++ tests/test_messaging.py | 360 +++++++++++++++++++++ tests/test_rooms.py | 458 +++++++++++++++++++++++++++ tests/test_sync.py | 434 +++++++++++++++++++++++++ 9 files changed, 3350 insertions(+), 4 deletions(-) create mode 100644 src/matrix_hermes/messaging.py create mode 100644 src/matrix_hermes/rooms.py create mode 100644 src/matrix_hermes/sync.py create mode 100644 tests/test_messaging.py create mode 100644 tests/test_rooms.py create mode 100644 tests/test_sync.py 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 == []