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:
@@ -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
|
||||
Reference in New Issue
Block a user