feat: Matrix connector core — rooms, messaging, sync

- 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).
This commit is contained in:
2026-05-23 12:07:10 -04:00
parent 7423d3bd17
commit 3ddf8170f8
9 changed files with 3350 additions and 4 deletions
+417
View File
@@ -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