diff --git a/matrix/bot.py b/matrix/bot.py index bb1222d..2c8dab3 100644 --- a/matrix/bot.py +++ b/matrix/bot.py @@ -337,15 +337,9 @@ def wrapper(func: ErrorCallback) -> Callable: return wrapper def get_room(self, room_id: str) -> Room: - """ - Retrieve a Room instance based on the room_id. - - :param room_id: The ID of the room to retrieve. - :type room_id: str - :return: An instance of the Room class. - :rtype: Room - """ - return Room(room_id=room_id, bot=self) + """Retrieve a Room instance based on the room_id.""" + matrix_room = self.client.rooms[room_id] + return Room(matrix_room=matrix_room, client=self.client) def _auto_register_events(self) -> None: for attr in dir(self): @@ -390,8 +384,9 @@ async def _process_commands(self, room: MatrixRoom, event: Event) -> None: await ctx.command(ctx) - async def _build_context(self, room: MatrixRoom, event: Event) -> Context: + async def _build_context(self, matrix_room: MatrixRoom, event: Event) -> Context: """Builds the base context and extracts the command from the event""" + room = self.get_room(matrix_room.room_id) ctx = Context(bot=self, room=room, event=event) if not self.prefix or not ctx.body.startswith(self.prefix): diff --git a/matrix/content.py b/matrix/content.py new file mode 100644 index 0000000..82baffe --- /dev/null +++ b/matrix/content.py @@ -0,0 +1,164 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass +from markdown import markdown +from typing import Any + + +class BaseMessageContent(ABC): + """Base class for outgoing message payloads.""" + + msgtype: str + + @abstractmethod + def build(self) -> dict[str, Any]: + pass + + +@dataclass +class TextContent(BaseMessageContent): + msgtype = "m.text" + body: str + + def build(self) -> dict: + return {"msgtype": self.msgtype, "body": self.body} + + +@dataclass +class MarkdownMessage(TextContent): + def build(self) -> dict: + return { + "msgtype": self.msgtype, + "body": self.body, + "format": "org.matrix.custom.html", + "formatted_body": markdown(self.body, extensions=["nl2br"]), + } + + +@dataclass +class NoticeContent(TextContent): + msgtype = "m.notice" + + +@dataclass +class ReplyContent(TextContent): + reply_to_event_id: str + + def build(self) -> dict: + return { + "msgtype": self.msgtype, + "body": self.body, + "m.relates_to": {"m.in_reply_to": {"event_id": self.reply_to_event_id}}, + } + + +@dataclass +class FileContent(BaseMessageContent): + msgtype = "m.file" + filename: str + url: str + mimetype: str + + def build(self) -> dict: + return { + "msgtype": self.msgtype, + "body": self.filename, + "url": self.url, + "info": {"mimetype": self.mimetype}, + } + + +@dataclass +class ImageContent(BaseMessageContent): + msgtype = "m.image" + filename: str + url: str + mimetype: str + height: int = 0 + width: int = 0 + + def build(self) -> dict: + return { + "msgtype": self.msgtype, + "body": self.filename, + "url": self.url, + "info": { + "mimetype": self.mimetype, + "h": self.height, + "w": self.width, + }, + } + + +@dataclass +class AudioContent(BaseMessageContent): + msgtype = "m.audio" + filename: str + url: str + mimetype: str + duration: int = 0 + + def build(self) -> dict: + return { + "msgtype": self.msgtype, + "body": self.filename, + "url": self.url, + "info": { + "mimetype": self.mimetype, + "duration": self.duration, + }, + } + + +@dataclass +class VideoContent(BaseMessageContent): + msgtype = "m.video" + filename: str + url: str + mimetype: str + height: int = 0 + width: int = 0 + duration: int = 0 + + def build(self) -> dict: + return { + "msgtype": self.msgtype, + "body": self.filename, + "url": self.url, + "info": { + "mimetype": self.mimetype, + "h": self.height, + "w": self.width, + "duration": self.duration, + }, + } + + +@dataclass +class LocationContent(BaseMessageContent): + msgtype = "m.location" + geo_uri: str + description: str = "" + + def build(self) -> dict: + return { + "msgtype": self.msgtype, + "body": self.description or self.geo_uri, + "geo_uri": self.geo_uri, + } + + +@dataclass +class ReactionContent(BaseMessageContent): + """For sending reactions to an event.""" + + event_id: str + emoji: str + + def build(self) -> dict: + return { + "m.relates_to": { + "rel_type": "m.annotation", + "event_id": self.event_id, + "key": self.emoji, + } + } diff --git a/matrix/context.py b/matrix/context.py index 274d40d..03709bd 100644 --- a/matrix/context.py +++ b/matrix/context.py @@ -5,6 +5,7 @@ from .errors import MatrixError from .message import Message +from .room import Room if TYPE_CHECKING: from .bot import Bot # pragma: no cover @@ -20,14 +21,14 @@ class Context: :param bot: The bot instance executing the command. :type bot: Bot :param room: The Matrix room where the event occurred. - :type room: MatrixRoom + :type room: Room :param event: The event that triggered the command or message. :type event: Event :raises MatrixError: If a Matrix operation fails. """ - def __init__(self, bot: "Bot", room: MatrixRoom, event: Event): + def __init__(self, bot: "Bot", room: Room, event: Event): self.bot = bot self.room = room self.event = event @@ -39,7 +40,7 @@ def __init__(self, bot: "Bot", room: MatrixRoom, event: Event): self.room_id: str = room.room_id self.room_name: str = room.name - # Command metdata + # Command metadata self.prefix: str = bot.prefix self.command: Optional[Command] = None self.subcommand: Optional[Command] = None @@ -68,19 +69,17 @@ def logger(self) -> Any: """Logger for instance specific to the current room or event.""" return self.bot.log.getChild(self.room_id) - async def reply(self, message: str) -> None: - """ - Send a message to the Matrix room. - - :param message: The message to send. - :type message: str - - :return: None - """ + async def reply( + self, + content: str | None, + *, + raw: bool = False, + notice: bool = False, + ) -> Message: + """Send a message to the Matrix room.""" try: - c = Message(self.bot) - await c.send(room_id=self.room_id, message=message) + return await self.room.send(content, raw=raw, notice=notice) except Exception as e: raise MatrixError(f"Failed to send message: {e}") diff --git a/matrix/group.py b/matrix/group.py index 3447878..8500bd0 100644 --- a/matrix/group.py +++ b/matrix/group.py @@ -77,7 +77,7 @@ def register_command(self, cmd: Command) -> Command: return cmd async def invoke(self, ctx: "Context") -> None: - if subcommand := ctx.args.pop(0): + if ctx.args and (subcommand := ctx.args.pop(0)): ctx.subcommand = self.get_command(subcommand) await ctx.subcommand(ctx) else: diff --git a/matrix/message.py b/matrix/message.py index 2de4366..6a583fc 100644 --- a/matrix/message.py +++ b/matrix/message.py @@ -1,134 +1,41 @@ -from matrix.errors import MatrixError -import markdown -from typing import TYPE_CHECKING, Dict, Optional -from nio import Event +from typing import TYPE_CHECKING, Optional +from nio import AsyncClient, Event +from matrix.content import ReactionContent, ReplyContent if TYPE_CHECKING: - from .bot import Bot # pragma: no cover + from .room import Room # pragma: no cover class Message: - """ - Handle sending messages in a Matrix room. - - This class provides methods to send messages to a Matrix room, including - formatting the message content as either plain text or HTML. - - :param bot: The bot instance to use for messages. - :type bot: Bot - """ - - MESSAGE_TYPE = "m.room.message" - MATRIX_CUSTOM_HTML = "org.matrix.custom.html" - TEXT_MESSAGE_TYPE = "m.text" - - def __init__(self, bot: "Bot") -> None: - self.bot = bot - - async def _send_to_room( - self, room_id: str, content: Dict, message_type: str = MESSAGE_TYPE + def __init__( + self, *, room: "Room", event_id: str, body: Optional[str], client: AsyncClient ) -> None: - """ - Send a message to the Matrix room. - - :param room_id: The ID of the room to send the message to. - :type room_id: str - :param content: The matrix JSON payload. - :type content: Dict - :param message_type: The type of the message. - :type message_type: str - - :raise MatrixError: If sending the message fails. - """ - try: - await self.bot.client.room_send( - room_id=room_id, - message_type=message_type, - content=content, - ) - except Exception as e: - raise MatrixError(f"Failed to send message: {e}") - - def _make_content( - self, - body: str = "", - html: Optional[bool] = None, - reaction: Optional[bool] = None, - event_id: Optional[str] = None, - key: Optional[str] = None, - ) -> Dict: - """ - Create the content dictionary for a message. - - :param body: The body of the message. - :type body: str - :param html: Wheter to format the message as HTML. - :type html: Optional[bool] - :param reaction: Wheter to format the context with a reaction event. - :type reaction: Optional[bool] - :param event_id: The ID of the event to react to. - :type event_id: Optional[str] - :param key: The reaction to the message. - :type key: Optional[str] - - :return: The content of the dictionary. - """ - - base: Dict = { - "msgtype": self.TEXT_MESSAGE_TYPE, - "body": body, - } - if html: - html_body = markdown.markdown(body, extensions=["nl2br"]) - base["format"] = self.MATRIX_CUSTOM_HTML - base["formatted_body"] = html_body - - if reaction: - base["m.relates_to"] = { - "event_id": event_id, - "key": key, - "rel_type": "m.annotation", - } - - return base - - async def send( - self, room_id: str, message: str, format_markdown: Optional[bool] = True - ) -> None: - """ - Send a message to a Matrix room. - - :param room_id: The ID of the room to send the message to. - :type room_id: str - :param message: The message to send. - :type message: str - :param format_markdown: Whether to format the message as Markdown - (default to True). - :type format_markdown: Optional[bool] - """ - await self._send_to_room( - room_id=room_id, - content=self._make_content(body=str(message), html=format_markdown), + self.room = room + self.id = event_id + self.body = body + self.client = client + + async def reply(self, body: str): + content = ReplyContent(body, reply_to_event_id=self.id) + + resp = await self.client.room_send( + room_id=self.room.room_id, + message_type="m.room.message", + content=content.build(), ) - async def send_reaction(self, room_id: str, event: Event, key: str) -> None: - """ - Send a reaction to a message from a user in a Matrix room. + return Message( + room=self.room, + event_id=resp.event_id, + body=body, + client=self.client, + ) - :param room_id: The ID of the room to send the message to. - :type room_id: str - :param event: The event object to react to. - :type event: Event - :param key: The reaction to the message. - :type key: str - """ - if isinstance(event, Event): - event_id = event.event_id - else: - event_id = event + async def react(self, emoji: str) -> None: + content = ReactionContent(event_id=self.id, emoji=emoji) - await self._send_to_room( - room_id=room_id, - content=self._make_content(event_id=event_id, key=key, reaction=True), + await self.client.room_send( + room_id=self.room.room_id, message_type="m.reaction", + content=content.build(), ) diff --git a/matrix/room.py b/matrix/room.py index 91e747f..36aced4 100644 --- a/matrix/room.py +++ b/matrix/room.py @@ -1,53 +1,94 @@ -from matrix.errors import MatrixError -from matrix.message import Message -from typing import TYPE_CHECKING, Optional -from nio import Event - -if TYPE_CHECKING: - from matrix.bot import Bot # pragma: no cover +from typing import Optional +from nio import AsyncClient, Event, MatrixRoom +from .errors import MatrixError +from .message import Message +from .content import ( + TextContent, + MarkdownMessage, + NoticeContent, + FileContent, + ImageContent, +) +from matrix.types import File, Image class Room: - """ - Represents a Matrix room and provides methods to interact with it. + """Represents a Matrix room and provides methods to interact with it.""" - :param room_id: The unique identifier of the room. - :type room_id: str - :param bot: The bot instance used to send messages. - :type bot: Bot - """ + def __init__(self, matrix_room: MatrixRoom, client: AsyncClient) -> None: + self.matrix_room = matrix_room + self.client = client - def __init__(self, room_id: str, bot: "Bot") -> None: - self.room_id = room_id - self.bot = bot + self.name = matrix_room.name + self.room_id = matrix_room.room_id async def send( self, - message: str = "", - markdown: Optional[bool] = True, - event: Optional[Event] = None, - key: Optional[str] = None, - ) -> None: - """ - Send a message to the room. - - :param message: The message to send. - :type message: str - :param markdown: Whether to format the message as Markdown. - :type markdown: Optional[bool] - :param event: An event object to react to. - :type event: Optional[Event] - :param key: The reaction to the message. - :type key: Optional[str] - - :raises MatrixError: If sending the message fails. + content: str | None = None, + *, + raw: bool = False, + notice: bool = False, + file: File | None = None, + image: Image | None = None, + ) -> Message: + """Send a message to the room.""" + if content: + return await self.send_text(content, raw=raw, notice=notice) + + if file: + return await self.send_file(file) + + if image: + return await self.send_image(image) + raise ValueError("You must provide content, file, or image to send.") + + async def send_text( + self, content: str, *, raw: bool = False, notice: bool = False + ) -> Message: + """Send a text message. + + Formatted in Markdown by default. Can be unformatted with `raw=True` or sent as a notice with `notice=True`. """ + if notice: + payload = NoticeContent(content) + elif raw: + payload = TextContent(content) + else: + payload = MarkdownMessage(content) + + return await self._send_payload(payload) + + async def send_file(self, file: File) -> Message: + """Send a file message.""" + payload = FileContent( + filename=file.filename, url=file.path, mimetype=file.mimetype + ) + return await self._send_payload(payload) + + async def send_image(self, image: Image) -> Message: + """Send an image message.""" + payload = ImageContent( + filename=image.filename, + url=image.path, + mimetype=image.mimetype, + ) + return await self._send_payload(payload) + + async def _send_payload(self, payload) -> Message: + """Send a BaseMessageContent payload and return a Message object.""" try: - msg = Message(self.bot) - if key: - await msg.send_reaction(self.room_id, event, key) - else: - await msg.send(self.room_id, message, markdown) + resp = await self.client.room_send( + room_id=self.room_id, + message_type="m.room.message", + content=payload.build(), + ) + + return Message( + room=self, + event_id=resp.event_id, + body=getattr(payload, "body", None), + client=self.client, + ) except Exception as e: raise MatrixError(f"Failed to send message: {e}") @@ -59,9 +100,7 @@ async def invite_user(self, user_id: str) -> None: :raises MatrixError: If inviting the user fails. """ try: - # TODO: Abstract this to Context? - # EX: await Context.invite_user_to_room(user_id) - await self.bot.client.room_invite(room_id=self.room_id, user_id=user_id) + await self.client.room_invite(room_id=self.room_id, user_id=user_id) except Exception as e: raise MatrixError(f"Failed to invite user: {e}") @@ -77,8 +116,7 @@ async def ban_user(self, user_id: str, reason: Optional[str] = None) -> None: :raises MatrixError: If banning the user fails. """ try: - # TODO: Abstract this to Context? - await self.bot.client.room_ban( + await self.client.room_ban( room_id=self.room_id, user_id=user_id, reason=reason ) except Exception as e: @@ -94,8 +132,7 @@ async def unban_user(self, user_id: str) -> None: :raises MatrixError: If unbanning the user fails. """ try: - # TODO: Abstract this to Context? - await self.bot.client.room_unban(room_id=self.room_id, user_id=user_id) + await self.client.room_unban(room_id=self.room_id, user_id=user_id) except Exception as e: raise MatrixError(f"Failed to unban user: {e}") @@ -111,8 +148,7 @@ async def kick_user(self, user_id: str, reason: Optional[str] = None) -> None: :raises MatrixError: If kicking the user fails. """ try: - # TODO: Abstract this to Context? - await self.bot.client.room_kick( + await self.client.room_kick( room_id=self.room_id, user_id=user_id, reason=reason ) except Exception as e: diff --git a/matrix/types.py b/matrix/types.py new file mode 100644 index 0000000..00fcbd4 --- /dev/null +++ b/matrix/types.py @@ -0,0 +1,15 @@ +from dataclasses import dataclass + + +@dataclass +class File: + path: str + filename: str + mimetype: str + + +@dataclass +class Image: + path: str + filename: str + mimetype: str