From 8c7bc21b5fc0b9c808e2a4717a6bba0f0c008db2 Mon Sep 17 00:00:00 2001 From: penguinboi Date: Fri, 26 Dec 2025 14:51:01 -0500 Subject: [PATCH 1/7] WIP refactor --- matrix/bot.py | 17 ++++++ matrix/context.py | 2 +- matrix/message.py | 147 ++++++++++++++++++++++++++-------------------- matrix/room.py | 33 ++++++----- 4 files changed, 119 insertions(+), 80 deletions(-) diff --git a/matrix/bot.py b/matrix/bot.py index 2b1b6ff..b27a720 100644 --- a/matrix/bot.py +++ b/matrix/bot.py @@ -33,6 +33,7 @@ from .scheduler import Scheduler from .errors import ( + GroupAlreadyRegisteredError, AlreadyRegisteredError, CommandNotFoundError, CheckError, @@ -261,6 +262,22 @@ def register_command(self, cmd: Command) -> Command: return cmd + def group(self, **kwargs) -> GroupCallable: + """Decorator to register a custom error handler for the command.""" + + def wrapper(func: Callback) -> Group: + group = Group(func, prefix=self.prefix, **kwargs) + return self.register_group(group) + return wrapper + + def register_group(self, group: Group) -> Group: + if group in self.commands: + raise GroupAlreadyRegisteredError(group) + + self.commands[group.name] = group + self.log.debug("group '%s' registered", group) + return group + def error(self, exception: Optional[type[Exception]] = None) -> Callable: """ Decorator to register a custom error handler for commands. diff --git a/matrix/context.py b/matrix/context.py index 274d40d..369b3fe 100644 --- a/matrix/context.py +++ b/matrix/context.py @@ -39,7 +39,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 diff --git a/matrix/message.py b/matrix/message.py index 2de4366..26672e4 100644 --- a/matrix/message.py +++ b/matrix/message.py @@ -1,10 +1,14 @@ -from matrix.errors import MatrixError import markdown -from typing import TYPE_CHECKING, Dict, Optional +from typing import TYPE_CHECKING, Any, Dict, Optional from nio import Event if TYPE_CHECKING: - from .bot import Bot # pragma: no cover + from .room import Room # pragma: no cover + + +MESSAGE_TYPE = "m.room.message" +MATRIX_CUSTOM_HTML = "org.matrix.custom.html" +TEXT_MESSAGE_TYPE = "m.text" class Message: @@ -13,41 +17,68 @@ class Message: 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, + body: str, + *, + message_type: str = MESSAGE_TYPE, + room: Optional["Room"] = None, + event: Optional[Event] = None + ) -> None: + self.body = body + self.message_type = message_type - 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 - ) -> None: - """ - Send a message to the Matrix room. + self.room = room + self.event = event - :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 + @property + def content(self) -> dict[Any, Any]: + base = { + "msgtype": TEXT_MESSAGE_TYPE, + "body": self.body, + } - :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}") + # if html: + # html_body = markdown.markdown(self.body, extensions=["nl2br"]) + # + # base["format"] = 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_to_room( + # self, room_id: str, content: Dict, message_type: str = MESSAGE_TYPE + # ) -> 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, @@ -59,19 +90,6 @@ def _make_content( ) -> 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 = { @@ -92,25 +110,26 @@ def _make_content( 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), - ) - + # 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), + # ) + + # TODO: rename -> react 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. diff --git a/matrix/room.py b/matrix/room.py index 91e747f..b7e0abd 100644 --- a/matrix/room.py +++ b/matrix/room.py @@ -23,7 +23,7 @@ def __init__(self, room_id: str, bot: "Bot") -> None: async def send( self, - message: str = "", + content: str = "", markdown: Optional[bool] = True, event: Optional[Event] = None, key: Optional[str] = None, @@ -31,26 +31,29 @@ async def send( """ 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. """ + 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) + message = Message(content, room=self, event=event) + + await self.bot.client.room_send( + room_id=self.room_id, + message_type=message.message_type, + content=message.content, + ) except Exception as e: raise MatrixError(f"Failed to send message: {e}") + # 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) + # except Exception as e: + # raise MatrixError(f"Failed to send message: {e}") + async def invite_user(self, user_id: str) -> None: """ Invite a user to the room. From d7a089740e0b6660d7724c3e77bbd0d48d2b7279 Mon Sep 17 00:00:00 2001 From: penguinboi Date: Sat, 17 Jan 2026 14:54:30 -0500 Subject: [PATCH 2/7] Wip refactoring of sending mechanism --- matrix/bot.py | 15 ++--- matrix/content.py | 164 ++++++++++++++++++++++++++++++++++++++++++++++ matrix/context.py | 25 ++++--- matrix/group.py | 2 +- matrix/message.py | 162 +++++++-------------------------------------- matrix/room.py | 133 +++++++++++++++++++++++-------------- matrix/types.py | 15 +++++ 7 files changed, 305 insertions(+), 211 deletions(-) create mode 100644 matrix/content.py create mode 100644 matrix/types.py 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 369b3fe..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 @@ -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 26672e4..6a583fc 100644 --- a/matrix/message.py +++ b/matrix/message.py @@ -1,153 +1,41 @@ -import markdown -from typing import TYPE_CHECKING, Any, 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 .room import Room # pragma: no cover -MESSAGE_TYPE = "m.room.message" -MATRIX_CUSTOM_HTML = "org.matrix.custom.html" -TEXT_MESSAGE_TYPE = "m.text" - - 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. - """ - def __init__( - self, - body: str, - *, - message_type: str = MESSAGE_TYPE, - room: Optional["Room"] = None, - event: Optional[Event] = None + self, *, room: "Room", event_id: str, body: Optional[str], client: AsyncClient ) -> None: - self.body = body - self.message_type = message_type - - self.room = room - self.event = event - - @property - def content(self) -> dict[Any, Any]: - base = { - "msgtype": TEXT_MESSAGE_TYPE, - "body": self.body, - } - - # if html: - # html_body = markdown.markdown(self.body, extensions=["nl2br"]) - # - # base["format"] = 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_to_room( - # self, room_id: str, content: Dict, message_type: str = MESSAGE_TYPE - # ) -> 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. - """ - - 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", - } + self.id = event_id + self.body = body + self.client = client - return base + async def reply(self, body: str): + content = ReplyContent(body, reply_to_event_id=self.id) - # 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), - # ) + resp = await self.client.room_send( + room_id=self.room.room_id, + message_type="m.room.message", + content=content.build(), + ) - # TODO: rename -> react - 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 b7e0abd..36aced4 100644 --- a/matrix/room.py +++ b/matrix/room.py @@ -1,59 +1,97 @@ -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, - content: str = "", - markdown: Optional[bool] = True, - event: Optional[Event] = None, - key: Optional[str] = None, - ) -> None: - """ - Send a message to the room. - - :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: - message = Message(content, room=self, event=event) - - await self.bot.client.room_send( + resp = await self.client.room_send( room_id=self.room_id, - message_type=message.message_type, - content=message.content, + 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}") - # 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) - # except Exception as e: - # raise MatrixError(f"Failed to send message: {e}") - async def invite_user(self, user_id: str) -> None: """ Invite a user to the room. @@ -62,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}") @@ -80,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: @@ -97,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}") @@ -114,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 From 470ab07bf87dba696306b3090f6511c3eeff9f2b Mon Sep 17 00:00:00 2001 From: penguinboi Date: Thu, 12 Feb 2026 10:12:38 -0500 Subject: [PATCH 3/7] Removed unused code, added delegation to MatrixRoom and added documentation --- matrix/bot.py | 1 - matrix/context.py | 71 +++++++++-- matrix/help/pagination.py | 1 - matrix/message.py | 4 +- matrix/room.py | 242 ++++++++++++++++++++++++++++++++------ matrix/types.py | 2 + 6 files changed, 271 insertions(+), 50 deletions(-) diff --git a/matrix/bot.py b/matrix/bot.py index 2c8dab3..1e25253 100644 --- a/matrix/bot.py +++ b/matrix/bot.py @@ -39,7 +39,6 @@ CheckError, ) - Callback = Callable[..., Coroutine[Any, Any, Any]] GroupCallable = Callable[[Callable[..., Coroutine[Any, Any, Any]]], Group] ErrorCallback = Callable[[Exception], Coroutine] diff --git a/matrix/context.py b/matrix/context.py index 03709bd..ce950c4 100644 --- a/matrix/context.py +++ b/matrix/context.py @@ -1,11 +1,12 @@ import shlex -from nio import Event, MatrixRoom +from nio import Event from typing import TYPE_CHECKING, Optional, Any, List from .errors import MatrixError from .message import Message from .room import Room +from .types import File, Image if TYPE_CHECKING: from .bot import Bot # pragma: no cover @@ -36,10 +37,6 @@ def __init__(self, bot: "Bot", room: Room, event: Event): self.body: str = getattr(event, "body", "") self.sender: str = event.sender - # Room metadata. - self.room_id: str = room.room_id - self.room_name: str = room.name - # Command metadata self.prefix: str = bot.prefix self.command: Optional[Command] = None @@ -67,19 +64,75 @@ def args(self) -> List[str]: @property def logger(self) -> Any: """Logger for instance specific to the current room or event.""" - return self.bot.log.getChild(self.room_id) + return self.bot.log.getChild(self.room.room_id) async def reply( self, - content: str | None, + content: str | None = None, *, raw: bool = False, notice: bool = False, + file: File | None = None, + image: Image | None = None, ) -> Message: - """Send a message to the Matrix room.""" + """Reply to the command with a message. + + ## Example + + ```python + @bot.command() + async def hello(ctx: Context): + # Send a markdown-formatted reply + await ctx.reply("Hello **world**!") + + @bot.command() + async def status(ctx: Context): + # Send a notice message + await ctx.reply("Bot is online!", notice=True) + + @bot.command() + async def document(ctx: Context): + # Upload and send a file + with open("report.pdf", "rb") as f: + resp, _ = await ctx.room.client.upload(f, content_type="application/pdf") + + file = File( + filename="report.pdf", + path=resp.content_uri, + mimetype="application/pdf" + ) + await ctx.reply(file=file) + + @bot.command() + async def cat(ctx: Context): + # Upload and send an image + from PIL import Image as PILImage + + with PILImage.open("cat.jpg") as img: + width, height = img.size + + with open("cat.jpg", "rb") as f: + resp, _ = await ctx.room.client.upload(f, content_type="image/jpeg") + + image = Image( + filename="cat.jpg", + path=resp.content_uri, + mimetype="image/jpeg", + width=width, + height=height + ) + await ctx.reply(image=image) + ``` + """ try: - return await self.room.send(content, raw=raw, notice=notice) + return await self.room.send( + content, + raw=raw, + notice=notice, + file=file, + image=image, + ) except Exception as e: raise MatrixError(f"Failed to send message: {e}") diff --git a/matrix/help/pagination.py b/matrix/help/pagination.py index 9acdbb5..137aa67 100644 --- a/matrix/help/pagination.py +++ b/matrix/help/pagination.py @@ -1,6 +1,5 @@ from typing import Optional, List, TypeVar, Generic - T = TypeVar("T") diff --git a/matrix/message.py b/matrix/message.py index 6a583fc..5a5ac59 100644 --- a/matrix/message.py +++ b/matrix/message.py @@ -1,5 +1,5 @@ from typing import TYPE_CHECKING, Optional -from nio import AsyncClient, Event +from nio import AsyncClient from matrix.content import ReactionContent, ReplyContent if TYPE_CHECKING: @@ -15,7 +15,7 @@ def __init__( self.body = body self.client = client - async def reply(self, body: str): + async def reply(self, body: str) -> "Message": content = ReplyContent(body, reply_to_event_id=self.id) resp = await self.client.room_send( diff --git a/matrix/room.py b/matrix/room.py index 36aced4..6ad5ed0 100644 --- a/matrix/room.py +++ b/matrix/room.py @@ -1,5 +1,7 @@ -from typing import Optional -from nio import AsyncClient, Event, MatrixRoom +from typing import Optional, Any + +from aiohttp import payload_type +from nio import AsyncClient, MatrixRoom from .errors import MatrixError from .message import Message from .content import ( @@ -8,6 +10,7 @@ NoticeContent, FileContent, ImageContent, + BaseMessageContent, ) from matrix.types import File, Image @@ -16,11 +19,64 @@ class Room: """Represents a Matrix room and provides methods to interact with it.""" def __init__(self, matrix_room: MatrixRoom, client: AsyncClient) -> None: - self.matrix_room = matrix_room - self.client = client + self._matrix_room: MatrixRoom = matrix_room + self._client: AsyncClient = client + + @property + def matrix_room(self) -> MatrixRoom: + """Access to underlying MatrixRoom object.""" + return self._matrix_room + + @property + def client(self) -> AsyncClient: + """Access to the Matrix client.""" + return self._client + + @property + def name(self) -> str | None: + """Room display name.""" + return self._matrix_room.name # type: ignore[no-any-return] + + @property + def room_id(self) -> str: + """Room ID.""" + return self._matrix_room.room_id # type: ignore[no-any-return] + + @property + def display_name(self) -> str: + """Room display name (alias for name).""" + return self._matrix_room.display_name # type: ignore[no-any-return] + + @property + def topic(self) -> str | None: + """Room topic.""" + return self._matrix_room.topic # type: ignore[no-any-return] - self.name = matrix_room.name - self.room_id = matrix_room.room_id + @property + def member_count(self) -> int: + """Number of members in the room.""" + return self._matrix_room.member_count # type: ignore[no-any-return] + + @property + def encrypted(self) -> bool: + """Whether the room is encrypted.""" + return self._matrix_room.encrypted # type: ignore[no-any-return] + + def __getattr__(self, name: str) -> Any: + """ + Fallback to MatrixRoom for attributes not explicitly defined. + + This allows access to any MatrixRoom attribute not wrapped by this class. + See matrix-nio's MatrixRoom documentation for available attributes. + + https://matrix-nio.readthedocs.io/en/latest/nio.html#nio.rooms.MatrixRoom + """ + try: + return getattr(self._matrix_room, name) + except AttributeError: + raise AttributeError( + f"'{type(self).__name__}' object has no attribute '{name}'" + ) from None async def send( self, @@ -31,7 +87,33 @@ async def send( file: File | None = None, image: Image | None = None, ) -> Message: - """Send a message to the room.""" + """Send a message to the room. + + This is a convenience method that automatically routes to the appropriate + send method based on the provided arguments. Supports text messages (with + optional markdown formatting), file uploads, and image uploads. + + ## Example + + ```python + # Send a markdown-formatted text message + await room.send("Hello **world**!") + + # Send raw text without markdown + await room.send("Hello world!", raw=True) + + # Send a notice message + await room.send("Bot is starting up...", notice=True) + + # Send a file + file = File(filename="document.pdf", path="mxc://...", mimetype="application/pdf") + await room.send(file=file) + + # Send an image + image = Image(filename="photo.jpg", path="mxc://...", mimetype="image/jpeg", width=800, height=600) + await room.send(image=image) + ``` + """ if content: return await self.send_text(content, raw=raw, notice=notice) @@ -45,10 +127,27 @@ async def send( async def send_text( self, content: str, *, raw: bool = False, notice: bool = False ) -> Message: - """Send a text message. + """Send a text message to the room. + + By default, messages are formatted using Markdown. You can send raw unformatted + text with `raw=True`, or send a notice message (typically used for bot status + updates) with `notice=True`. + + ## Example + + ```python + # Send markdown-formatted message + await room.send_text("**Bold** and *italic* text") + + # Send raw text without formatting + await room.send_text("This is plain text", raw=True) - Formatted in Markdown by default. Can be unformatted with `raw=True` or sent as a notice with `notice=True`. + # Send a notice message + await room.send_text("Bot restarted successfully", notice=True) + ``` """ + payload: NoticeContent | TextContent | MarkdownMessage + if notice: payload = NoticeContent(content) elif raw: @@ -59,22 +158,70 @@ async def send_text( return await self._send_payload(payload) async def send_file(self, file: File) -> Message: - """Send a file message.""" + """Send a file to the room. + + The file must be uploaded to the Matrix content repository before sending. + Use the room's client upload method to get the MXC URI for the file. + + ## Example + + ```python + # Upload file first, then send + with open("document.pdf", "rb") as f: + resp, _ = await room.client.upload(f, content_type="application/pdf") + + file = File( + filename="document.pdf", + path=resp.content_uri, + mimetype="application/pdf" + ) + await room.send_file(file) + ``` + """ 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.""" + """Send an image to the room. + + The image must be uploaded to the Matrix content repository before sending. + Use the room's client upload method to get the MXC URI for the image. + + ## Example + + ```python + from PIL import Image as PILImage + + # Get image dimensions + with PILImage.open("photo.jpg") as img: + width, height = img.size + + # Upload image first + with open("photo.jpg", "rb") as f: + resp, _ = await room.client.upload(f, content_type="image/jpeg") + + image = Image( + filename="photo.jpg", + path=resp.content_uri, + mimetype="image/jpeg", + width=width, + height=height + ) + await room.send_image(image) + ``` + """ payload = ImageContent( filename=image.filename, url=image.path, mimetype=image.mimetype, + height=image.height, + width=image.width, ) return await self._send_payload(payload) - async def _send_payload(self, payload) -> Message: + async def _send_payload(self, payload: BaseMessageContent) -> Message: """Send a BaseMessageContent payload and return a Message object.""" try: resp = await self.client.room_send( @@ -93,27 +240,38 @@ async def _send_payload(self, payload) -> Message: raise MatrixError(f"Failed to send message: {e}") async def invite_user(self, user_id: str) -> None: - """ - Invite a user to the room. + """Invite a user to the room. + + The bot must have permission to invite users to the room. The user will + receive an invitation that they can accept or decline. - :param user_id: The ID of the user to invite. - :raises MatrixError: If inviting the user fails. + ## Example + + ```python + # Invite a user by their Matrix ID + await room.invite_user("@alice:example.com") + ``` """ try: 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}") - async def ban_user(self, user_id: str, reason: Optional[str] = None) -> None: - """ - Ban a user from a room. + async def ban_user(self, user_id: str, reason: str | None = None) -> None: + """Ban a user from the room. + + The bot must have permission to ban users. Banned users cannot rejoin + the room until they are unbanned. Optionally provide a reason for the ban. - :param user_id: The ID of the user to ban of the room. - :type user_id: str - :param reason: The reason to ban the user. - :type reason: Optional[str] + ## Example - :raises MatrixError: If banning the user fails. + ```python + # Ban a user without a reason + await room.ban_user("@spammer:example.com") + + # Ban a user with a reason + await room.ban_user("@spammer:example.com", reason="Spam and harassment") + ``` """ try: await self.client.room_ban( @@ -123,29 +281,39 @@ async def ban_user(self, user_id: str, reason: Optional[str] = None) -> None: raise MatrixError(f"Failed to ban user: {e}") async def unban_user(self, user_id: str) -> None: - """ - Unban a user from a room. + """Unban a user from the room. + + The bot must have permission to unban users. This removes the ban, + allowing the user to rejoin the room if invited or if the room is public. - :param user_id: The ID of the user to unban of the room. - :type user_id: str + ## Example - :raises MatrixError: If unbanning the user fails. + ```python + # Unban a previously banned user + await room.unban_user("@alice:example.com") + ``` """ try: 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}") - async def kick_user(self, user_id: str, reason: Optional[str] = None) -> None: - """ - Kick a user from a room. + async def kick_user(self, user_id: str, reason: str | None = None) -> None: + """Kick a user from the room. + + The bot must have permission to kick users. Unlike banning, kicked users + can rejoin the room if they have an invite or if the room is public. + Optionally provide a reason for the kick. + + ## Example - :param user_id: The ID of the user to kick of the room. - :type user_id: str - :param reason: The reason to kick the user. - :type reason: Optional[str] + ```python + # Kick a user without a reason + await room.kick_user("@troublemaker:example.com") - :raises MatrixError: If kicking the user fails. + # Kick a user with a reason + await room.kick_user("@troublemaker:example.com", reason="Violating room rules") + ``` """ try: await self.client.room_kick( diff --git a/matrix/types.py b/matrix/types.py index 00fcbd4..c439847 100644 --- a/matrix/types.py +++ b/matrix/types.py @@ -13,3 +13,5 @@ class Image: path: str filename: str mimetype: str + height: int + width: int From eda0bc031db25bfeaff7a5d005481a269c944420 Mon Sep 17 00:00:00 2001 From: penguinboi Date: Sun, 15 Feb 2026 20:56:29 -0500 Subject: [PATCH 4/7] Refactor Message and added new functions --- matrix/content.py | 19 +++++++ matrix/message.py | 138 ++++++++++++++++++++++++++++++++++++++-------- matrix/room.py | 27 ++++++--- 3 files changed, 152 insertions(+), 32 deletions(-) diff --git a/matrix/content.py b/matrix/content.py index 82baffe..05e8fa1 100644 --- a/matrix/content.py +++ b/matrix/content.py @@ -51,6 +51,25 @@ def build(self) -> dict: } +@dataclass +class EditContent(TextContent): + original_event_id: str + + def build(self) -> dict: + return { + "msgtype": self.msgtype, + "body": f"* {self.body}", + "m.new_content": { + "msgtype": "m.text", + "body": self.body, + }, + "m.relates_to": { + "rel_type": "m.replace", + "event_id": self.original_event_id, + }, + } + + @dataclass class FileContent(BaseMessageContent): msgtype = "m.file" diff --git a/matrix/message.py b/matrix/message.py index 5a5ac59..f52cc8f 100644 --- a/matrix/message.py +++ b/matrix/message.py @@ -1,41 +1,131 @@ -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from nio import AsyncClient -from matrix.content import ReactionContent, ReplyContent +from matrix.content import ReactionContent, EditContent +from matrix.errors import MatrixError if TYPE_CHECKING: from .room import Room # pragma: no cover class Message: + """Represents a Matrix message with methods to interact with it.""" + def __init__( - self, *, room: "Room", event_id: str, body: Optional[str], client: AsyncClient + self, *, room: "Room", event_id: str, body: str | None, client: AsyncClient ) -> None: - self.room = room - self.id = event_id - self.body = body - self.client = client + self._room = room + self._event_id = event_id + self._body = body + self._client = client + + @property + def room(self) -> "Room": + """The room this message was sent in.""" + return self._room + + @property + def id(self) -> str: + """The event ID of this message.""" + return self._event_id + + @property + def event_id(self) -> str: + """The event ID of this message (alias for id).""" + return self._event_id + + @property + def body(self) -> str | None: + """The text content of this message.""" + return self._body + + @property + def client(self) -> AsyncClient: + """The Matrix client.""" + return self._client async def reply(self, body: str) -> "Message": - content = ReplyContent(body, reply_to_event_id=self.id) + """Reply to this message. - resp = await self.client.room_send( - room_id=self.room.room_id, - message_type="m.room.message", - content=content.build(), - ) + Creates a threaded reply to this message in the same room. - return Message( - room=self.room, - event_id=resp.event_id, - body=body, - client=self.client, - ) + ## Example + ```python + @bot.command() + async def echo(ctx: Context): + msg = await ctx.reply("Echo!") + await msg.reply("Replying to my own message") + ``` + """ + try: + return await self.room.send_text(content=body, reply_to=self.id) + except Exception as e: + raise MatrixError(f"Failed to send reply: {e}") async def react(self, emoji: str) -> None: + """Add a reaction emoji to this message. + + ## Example + ```python + @bot.command() + async def thumbsup(ctx: Context): + msg = await ctx.reply("React to this!") + await msg.react("👍") + ``` + """ content = ReactionContent(event_id=self.id, emoji=emoji) - await self.client.room_send( - room_id=self.room.room_id, - message_type="m.reaction", - content=content.build(), - ) + try: + await self.client.room_send( + room_id=self.room.room_id, + message_type="m.reaction", + content=content.build(), + ) + except Exception as e: + raise MatrixError(f"Failed to add reaction: {e}") + + async def edit(self, new_body: str) -> None: + """Updates the message content to the new text. + + ## Example + + ```python + @bot.command() + async def typo(ctx: Context): + msg = await ctx.reply("Helo world!") + await msg.edit("Hello world!") + ``` + """ + content = EditContent(new_body, original_event_id=self.id) + + try: + await self.client.room_send( + room_id=self.room.room_id, + message_type="m.room.message", + content=content.build(), + ) + self._body = new_body + except Exception as e: + raise MatrixError(f"Failed to edit message: {e}") + + async def delete(self) -> None: + """Removes the message content from the room. This action cannot be undone. + + ## Example + + ```python + @bot.command() + async def oops(ctx: Context): + msg = await ctx.reply("Secret info!") + await msg.delete() + ``` + """ + try: + await self.client.room_redact( + room_id=self.room.room_id, + event_id=self.id, + ) + except Exception as e: + raise MatrixError(f"Failed to delete message: {e}") + + def __repr__(self) -> str: + return f"" diff --git a/matrix/room.py b/matrix/room.py index 6ad5ed0..9f8b64d 100644 --- a/matrix/room.py +++ b/matrix/room.py @@ -1,16 +1,17 @@ -from typing import Optional, Any +from typing import Any -from aiohttp import payload_type from nio import AsyncClient, MatrixRoom -from .errors import MatrixError -from .message import Message -from .content import ( + +from matrix.errors import MatrixError +from matrix.message import Message +from matrix.content import ( TextContent, MarkdownMessage, NoticeContent, FileContent, ImageContent, BaseMessageContent, + ReplyContent, ) from matrix.types import File, Image @@ -125,7 +126,12 @@ async def send( raise ValueError("You must provide content, file, or image to send.") async def send_text( - self, content: str, *, raw: bool = False, notice: bool = False + self, + content: str, + *, + raw: bool = False, + notice: bool = False, + reply_to: str | None = None, ) -> Message: """Send a text message to the room. @@ -144,11 +150,16 @@ async def send_text( # Send a notice message await room.send_text("Bot restarted successfully", notice=True) + + # Reply to another message + await room.send_text("Bot restarted successfully", replay_to=message.id) ``` """ - payload: NoticeContent | TextContent | MarkdownMessage + payload: TextContent - if notice: + if reply_to: + payload = ReplyContent(content, reply_to_event_id=reply_to) + elif notice: payload = NoticeContent(content) elif raw: payload = TextContent(content) From 88cdd5cf1f4f244bebf95eb1e1e0db0c0c2a765c Mon Sep 17 00:00:00 2001 From: penguinboi Date: Sun, 15 Feb 2026 21:15:21 -0500 Subject: [PATCH 5/7] Fix tests --- tests/test_context.py | 241 ++++++++++++++++++++++++-------- tests/test_message.py | 168 +++++++++++++++-------- tests/test_room.py | 311 +++++++++++++++++++++++++++++++----------- 3 files changed, 529 insertions(+), 191 deletions(-) diff --git a/tests/test_context.py b/tests/test_context.py index 7354579..02245ad 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -1,28 +1,43 @@ import pytest -from unittest.mock import MagicMock, AsyncMock -from nio import MatrixRoom, RoomMessageText +from unittest.mock import AsyncMock, Mock +from nio import MatrixRoom, RoomMessageText, AsyncClient from matrix.errors import MatrixError -from matrix.bot import Bot from matrix.context import Context +from matrix.room import Room +from matrix.message import Message @pytest.fixture -def bot(): - bot = Bot(config="tests/config_fixture.yaml") +def matrix_room(): + room = Mock(spec=MatrixRoom) + room.room_id = "!room:example.com" + room.name = "Test Room" + room.display_name = "Test Room" + room.topic = "A test room" + room.member_count = 5 + room.encrypted = False + return room - bot.client = MagicMock() - bot.client.room_send = AsyncMock() - bot.log = MagicMock() - bot.log.getChild.return_value = MagicMock() - return bot +@pytest.fixture +def client(): + client = AsyncMock(spec=AsyncClient) + return client @pytest.fixture -def room(): - room = MatrixRoom(room_id="!room:id", own_user_id="grace") - room.name = "Test Room" - return room +def room(matrix_room, client): + return Room(matrix_room, client) + + +@pytest.fixture +def bot(client): + bot = Mock() + bot.prefix = "!" + bot.client = client + bot.log = Mock() + bot.log.getChild = Mock(return_value=Mock()) + return bot @pytest.fixture @@ -30,7 +45,7 @@ def event(): return RoomMessageText.from_dict( { "content": {"body": "!echo hello world", "msgtype": "m.text"}, - "event_id": "$id", + "event_id": "$event123", "origin_server_ts": 123456, "sender": "@user:matrix.org", "type": "m.room.message", @@ -38,59 +53,175 @@ def event(): ) -def test_context_initialization(bot, room, event): - ctx = Context(bot, room, event) +@pytest.fixture +def context(bot, room, event): + return Context(bot, room, event) + + +def test_context_initialization__expect_correct_properties(context, bot, room, event): + assert context.bot is bot + assert context.room is room + assert context.event is event + assert context.body == "!echo hello world" + assert context.sender == "@user:matrix.org" + assert context.prefix == "!" + assert context.command is None + assert context.subcommand is None + assert context._args == ["!echo", "hello", "world"] + - assert ctx.bot == bot - assert ctx.room == room - assert ctx.event == event - assert ctx.body == "!echo hello world" - assert ctx.sender == "@user:matrix.org" - assert ctx.room_id == "!room:id" - assert ctx.room_name == "Test Room" - assert ctx.prefix == "!" - assert ctx.command is None - assert ctx._args == ["!echo", "hello", "world"] +def test_args_without_command__expect_full_args_list(context): + assert context.args == ["!echo", "hello", "world"] -def test_args_without_command(bot, room, event): - ctx = Context(bot, room, event) - assert ctx.args == ["!echo", "hello", "world"] +def test_args_with_command__expect_args_without_command_name(context): + context.command = Mock() + assert context.args == ["hello", "world"] -def test_args_with_command(bot, room, event): - ctx = Context(bot, room, event) - ctx.command = MagicMock() - assert ctx.args == ["hello", "world"] +def test_args_with_subcommand__expect_args_without_command_and_subcommand(context): + context.command = Mock() + context.subcommand = Mock() + assert context.args == ["world"] -def test_logger_property(bot, room, event): - ctx = Context(bot, room, event) - logger = ctx.logger +def test_logger_property__expect_room_specific_logger(context): + logger = context.logger assert logger is not None + context.bot.log.getChild.assert_called_once_with("!room:example.com") + + +@pytest.mark.asyncio +async def test_reply__expect_message_sent_to_room(context, client): + mock_response = Mock() + mock_response.event_id = "$reply123" + client.room_send = AsyncMock(return_value=mock_response) + + msg = await context.reply("Hello!") + + client.room_send.assert_awaited_once() + call_args = client.room_send.call_args + assert call_args.kwargs["room_id"] == "!room:example.com" + assert call_args.kwargs["message_type"] == "m.room.message" + content = call_args.kwargs["content"] + assert content["body"] == "Hello!" + assert isinstance(msg, Message) + + +@pytest.mark.asyncio +async def test_reply_with_raw__expect_unformatted_message(context, client): + mock_response = Mock() + mock_response.event_id = "$reply123" + client.room_send = AsyncMock(return_value=mock_response) + + await context.reply("Plain text", raw=True) + + call_args = client.room_send.call_args + content = call_args.kwargs["content"] + assert content["msgtype"] == "m.text" + assert content["body"] == "Plain text" + assert "formatted_body" not in content + + +@pytest.mark.asyncio +async def test_reply_with_notice__expect_notice_message_type(context, client): + mock_response = Mock() + mock_response.event_id = "$reply123" + client.room_send = AsyncMock(return_value=mock_response) + + await context.reply("Notice!", notice=True) + + call_args = client.room_send.call_args + content = call_args.kwargs["content"] + assert content["msgtype"] == "m.notice" + assert content["body"] == "Notice!" + + +@pytest.mark.asyncio +async def test_reply_with_file__expect_file_message_sent(context, client): + from matrix.types import File + + mock_response = Mock() + mock_response.event_id = "$reply123" + client.room_send = AsyncMock(return_value=mock_response) + + file = File( + filename="doc.pdf", + path="mxc://example.com/abc", + mimetype="application/pdf" + ) + + await context.reply(file=file) + + call_args = client.room_send.call_args + content = call_args.kwargs["content"] + assert content["msgtype"] == "m.file" + assert content["body"] == "doc.pdf" @pytest.mark.asyncio -async def test_send_success(bot, room, event): - ctx = Context(bot, room, event) - await ctx.reply("Hello!") - - bot.client.room_send.assert_awaited_once_with( - room_id="!room:id", - message_type="m.room.message", - content={ - "msgtype": "m.text", - "body": "Hello!", - "format": "org.matrix.custom.html", - "formatted_body": "

Hello!

", - }, +async def test_reply_with_image__expect_image_message_sent(context, client): + from matrix.types import Image + + mock_response = Mock() + mock_response.event_id = "$reply123" + client.room_send = AsyncMock(return_value=mock_response) + + image = Image( + filename="pic.jpg", + path="mxc://example.com/xyz", + mimetype="image/jpeg", + width=800, + height=600 ) + await context.reply(image=image) + + call_args = client.room_send.call_args + content = call_args.kwargs["content"] + assert content["msgtype"] == "m.image" + assert content["body"] == "pic.jpg" + @pytest.mark.asyncio -async def test_send_failure_raises_matrix_error(bot, room, event): - bot.client.room_send.side_effect = Exception("API failure") - ctx = Context(bot, room, event) +async def test_reply_with_error__expect_matrix_error(context, client): + client.room_send = AsyncMock(side_effect=Exception("Network failure")) + + with pytest.raises(MatrixError, match="Failed to send message"): + await context.reply("This will fail") + + +@pytest.mark.asyncio +async def test_send_help_with_subcommand__expect_subcommand_help(context): + context.subcommand = Mock() + context.subcommand.help = "Subcommand help text" + context.room.send = AsyncMock() + + await context.send_help() + + context.room.send.assert_awaited_once() + call_args = context.room.send.call_args + assert call_args.args[0] == "Subcommand help text" + + +@pytest.mark.asyncio +async def test_send_help_with_command__expect_command_help(context): + context.command = Mock() + context.command.help = "Command help text" + context.room.send = AsyncMock() + + await context.send_help() + + context.room.send.assert_awaited_once() + call_args = context.room.send.call_args + assert call_args.args[0] == "Command help text" + + +@pytest.mark.asyncio +async def test_send_help_without_command__expect_bot_help_executed(context): + context.bot.help = Mock() + context.bot.help.execute = AsyncMock() + + await context.send_help() - with pytest.raises(MatrixError, match="Failed to send message: API failure"): - await ctx.reply("Test failure") + context.bot.help.execute.assert_awaited_once_with(context) \ No newline at end of file diff --git a/tests/test_message.py b/tests/test_message.py index ac39a7e..e6585de 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -1,91 +1,145 @@ import pytest -from matrix.bot import Bot -from matrix.message import Message +from unittest.mock import AsyncMock, Mock +from nio import MatrixRoom, AsyncClient from matrix.errors import MatrixError -from unittest.mock import AsyncMock, MagicMock -from nio import RoomMessageText +from matrix.message import Message +from matrix.room import Room @pytest.fixture -def message_default(): - bot = Bot(config="tests/config_fixture.yaml") +def matrix_room(): + room = Mock(spec=MatrixRoom) + room.room_id = "!room:example.com" + room.name = "Test Room" + room.display_name = "Test Room" + room.topic = "A test room" + room.member_count = 5 + room.encrypted = False + return room - bot.client = MagicMock() - bot.client.room_send = AsyncMock() - bot.log = MagicMock() - bot.log.getChild.return_value = MagicMock() - return Message(bot) +@pytest.fixture +def client(): + client = AsyncMock(spec=AsyncClient) + return client @pytest.fixture -def event(): - return RoomMessageText.from_dict( - { - "content": {"body": "hello", "msgtype": "m.text"}, - "event_id": "$id", - "origin_server_ts": 123456, - "sender": "@user:matrix.org", - "type": "m.room.message", - } +def room(matrix_room, client): + return Room(matrix_room, client) + + +@pytest.fixture +def message(room, client): + return Message( + room=room, + event_id="$event123", + body="Hello world!", + client=client ) @pytest.mark.asyncio -async def test_send_message_success(message_default): - room_id = "!room:id" - message = "Hello, world!" +async def test_reply__expect_threaded_reply_message(message, client): + mock_response = Mock() + mock_response.event_id = "$reply456" + client.room_send = AsyncMock(return_value=mock_response) + + result = await message.reply("Replying to you!") - await message_default.send(room_id, message) - message_default.bot.client.room_send.assert_awaited_once() + client.room_send.assert_awaited_once() + call_args = client.room_send.call_args + content = call_args.kwargs["content"] + assert content["body"] == "Replying to you!" + assert "m.relates_to" in content + assert content["m.relates_to"]["m.in_reply_to"]["event_id"] == "$event123" + assert isinstance(result, Message) + assert result.id == "$reply456" @pytest.mark.asyncio -async def test_send_message_failure(message_default): - room_id = "!room:id" - message = "Hello, world!" +async def test_reply_with_error__expect_matrix_error(message, client): + client.room_send = AsyncMock(side_effect=Exception("Network error")) + + with pytest.raises(MatrixError, match="Failed to send reply"): + await message.reply("This will fail") - message_default.bot.client.room_send.side_effect = Exception( - "Failed to send message" - ) - with pytest.raises(MatrixError, match="Failed to send message"): - await message_default.send(room_id, message) +@pytest.mark.asyncio +async def test_react__expect_reaction_sent(message, client): + client.room_send = AsyncMock() -def test_make_content_with_html(message_default): - body = "# Hello, world!" - content = message_default._make_content(body, True) + await message.react("👍") - assert content["msgtype"] == message_default.TEXT_MESSAGE_TYPE - assert content["body"] == body - assert content["format"] == message_default.MATRIX_CUSTOM_HTML - assert "formatted_body" in content + client.room_send.assert_awaited_once() + call_args = client.room_send.call_args + assert call_args.kwargs["message_type"] == "m.reaction" + content = call_args.kwargs["content"] + assert content["m.relates_to"]["rel_type"] == "m.annotation" + assert content["m.relates_to"]["event_id"] == "$event123" + assert content["m.relates_to"]["key"] == "👍" -def test_make_content_without_html(message_default): - body = "Hello, world!" - content = message_default._make_content(body, False) +@pytest.mark.asyncio +async def test_react_with_error__expect_matrix_error(message, client): + client.room_send = AsyncMock(side_effect=Exception("Failed to react")) - assert content["msgtype"] == message_default.TEXT_MESSAGE_TYPE - assert content["body"] == body - assert "format" not in content - assert "formatted_body" not in content + with pytest.raises(MatrixError, match="Failed to add reaction"): + await message.react("😀") @pytest.mark.asyncio -async def test_send_reaction_success(message_default, event): - room_id = "!room:id" +async def test_edit__expect_message_updated(message, client): + client.room_send = AsyncMock() + + await message.edit("Updated content") - await message_default.send_reaction(room_id, event, "hi") - message_default.bot.client.room_send.assert_awaited_once() + client.room_send.assert_awaited_once() + call_args = client.room_send.call_args + content = call_args.kwargs["content"] + assert content["body"] == "* Updated content" + assert content["m.new_content"]["body"] == "Updated content" + assert content["m.relates_to"]["rel_type"] == "m.replace" + assert content["m.relates_to"]["event_id"] == "$event123" + assert message.body == "Updated content" @pytest.mark.asyncio -async def test_send_reaction_failure(message_default, event): - room_id = "!room:id" +async def test_edit_with_error__expect_matrix_error(message, client): + client.room_send = AsyncMock(side_effect=Exception("Failed to edit")) + + with pytest.raises(MatrixError, match="Failed to edit message"): + await message.edit("New content") - message_default.bot.client.room_send.side_effect = Exception( - "Failed to send message" + +@pytest.mark.asyncio +async def test_delete__expect_message_redacted(message, client): + client.room_redact = AsyncMock() + + await message.delete() + + client.room_redact.assert_awaited_once_with( + room_id="!room:example.com", + event_id="$event123" ) - with pytest.raises(MatrixError, match="Failed to send message"): - await message_default.send_reaction(room_id, event, "🙏") + + +@pytest.mark.asyncio +async def test_delete_with_error__expect_matrix_error(message, client): + client.room_redact = AsyncMock(side_effect=Exception("Failed to redact")) + + with pytest.raises(MatrixError, match="Failed to delete message"): + await message.delete() + + +def test_message_properties__expect_correct_values(message, room, client): + assert message.id == "$event123" + assert message.event_id == "$event123" + assert message.body == "Hello world!" + assert message.room is room + assert message.client is client + + +def test_message_repr__expect_formatted_string(message): + result = repr(message) + assert result == "" \ No newline at end of file diff --git a/tests/test_room.py b/tests/test_room.py index a8ff1ba..3037dbb 100644 --- a/tests/test_room.py +++ b/tests/test_room.py @@ -1,151 +1,304 @@ import pytest -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock +from nio import MatrixRoom from matrix.errors import MatrixError from matrix.room import Room +from matrix.message import Message @pytest.fixture -def room_default(): - bot = AsyncMock() - room_id = "!room:id" - return Room(room_id, bot) +def matrix_room(): + room = Mock(spec=MatrixRoom) + room.room_id = "!room:example.com" + room.name = "Test Room" + room.display_name = "Test Room" + room.topic = "A test room" + room.member_count = 5 + room.encrypted = False + return room @pytest.fixture -def message(): - message_mock = AsyncMock() - message_mock.send = AsyncMock() - return message_mock +def client(): + client = AsyncMock() + return client + + +@pytest.fixture +def room(matrix_room, client): + return Room(matrix_room, client) + + +@pytest.mark.asyncio +async def test_send_markdown__expect_formatted_message(room, client): + client.room_send = AsyncMock() + mock_response = Mock() + mock_response.event_id = "$event123" + client.room_send.return_value = mock_response + + msg = await room.send("Hello **world**!") + + client.room_send.assert_awaited_once() + call_args = client.room_send.call_args + assert call_args.kwargs["room_id"] == "!room:example.com" + assert call_args.kwargs["message_type"] == "m.room.message" + assert call_args.kwargs["content"]["msgtype"] == "m.text" + assert call_args.kwargs["content"]["body"] == "Hello **world**!" + assert "formatted_body" in call_args.kwargs["content"] + assert isinstance(msg, Message) + assert msg.id == "$event123" + + +@pytest.mark.asyncio +async def test_send_raw__expect_unformatted_message(room, client): + client.room_send = AsyncMock() + mock_response = Mock() + mock_response.event_id = "$event123" + client.room_send.return_value = mock_response + + await room.send("Hello world!", raw=True) + + client.room_send.assert_awaited_once() + call_args = client.room_send.call_args + content = call_args.kwargs["content"] + assert content["msgtype"] == "m.text" + assert content["body"] == "Hello world!" + assert "formatted_body" not in content + + +@pytest.mark.asyncio +async def test_send_notice__expect_notice_message_type(room, client): + client.room_send = AsyncMock() + mock_response = Mock() + mock_response.event_id = "$event123" + client.room_send.return_value = mock_response + + await room.send("Bot starting up...", notice=True) + + client.room_send.assert_awaited_once() + call_args = client.room_send.call_args + content = call_args.kwargs["content"] + assert content["msgtype"] == "m.notice" + assert content["body"] == "Bot starting up..." + + +@pytest.mark.asyncio +async def test_send_text_with_reply_to__expect_threaded_reply(room, client): + client.room_send = AsyncMock() + mock_response = Mock() + mock_response.event_id = "$event123" + client.room_send.return_value = mock_response + + await room.send_text("Replying!", reply_to="$original_event") + + client.room_send.assert_awaited_once() + call_args = client.room_send.call_args + content = call_args.kwargs["content"] + assert content["msgtype"] == "m.text" + assert content["body"] == "Replying!" + assert "m.relates_to" in content + assert content["m.relates_to"]["m.in_reply_to"]["event_id"] == "$original_event" @pytest.mark.asyncio -async def test_send_message_room_success(room_default, message): - message.send.return_value = None +async def test_send_file__expect_file_message(room, client): + from matrix.types import File + + client.room_send = AsyncMock() + mock_response = Mock() + mock_response.event_id = "$event123" + client.room_send.return_value = mock_response + + file = File( + filename="document.pdf", + path="mxc://example.com/abc123", + mimetype="application/pdf" + ) - room_default.bot.client.room_send = message.send + await room.send(file=file) - msg_to_send = "Hello, world!" + client.room_send.assert_awaited_once() + call_args = client.room_send.call_args + content = call_args.kwargs["content"] + assert content["msgtype"] == "m.file" + assert content["body"] == "document.pdf" + assert content["url"] == "mxc://example.com/abc123" - await room_default.send(msg_to_send) - message.send.assert_awaited_once_with( - room_id=room_default.room_id, - message_type="m.room.message", - content={ - "msgtype": "m.text", - "body": msg_to_send, - "format": "org.matrix.custom.html", - "formatted_body": f"

{msg_to_send}

", - }, + +@pytest.mark.asyncio +async def test_send_image__expect_image_message_with_dimensions(room, client): + from matrix.types import Image + + client.room_send = AsyncMock() + mock_response = Mock() + mock_response.event_id = "$event123" + client.room_send.return_value = mock_response + + image = Image( + filename="photo.jpg", + path="mxc://example.com/xyz789", + mimetype="image/jpeg", + width=800, + height=600 ) + await room.send(image=image) + + client.room_send.assert_awaited_once() + call_args = client.room_send.call_args + content = call_args.kwargs["content"] + assert content["msgtype"] == "m.image" + assert content["body"] == "photo.jpg" + assert content["url"] == "mxc://example.com/xyz789" + assert content["info"]["w"] == 800 + assert content["info"]["h"] == 600 + @pytest.mark.asyncio -async def test_send_message_room_failure(room_default, message): - message.send.side_effect = Exception("Failed to send message") +async def test_send_no_content__expect_value_error(room): + with pytest.raises(ValueError, match="You must provide content, file, or image"): + await room.send() + - room_default.bot.client.room_send = message.send +@pytest.mark.asyncio +async def test_send_message_with_network_error__expect_matrix_error(room, client): + client.room_send = AsyncMock() + client.room_send.side_effect = Exception("Network error") with pytest.raises(MatrixError, match="Failed to send message"): - await room_default.send("Hello, world!") + await room.send("Hello, world!") @pytest.mark.asyncio -async def test_invite_user_success(room_default): - room_default.bot.client.room_invite = AsyncMock() - room_default.bot.client.room_invite.return_value = None +async def test_invite_user__expect_successful_invitation(room, client): + client.room_invite = AsyncMock() + client.room_invite.return_value = None - await room_default.invite_user("@user:matrix.org") - room_default.bot.client.room_invite.assert_awaited_once_with( - room_id=room_default.room_id, user_id="@user:matrix.org" + await room.invite_user("@alice:example.com") + + client.room_invite.assert_awaited_once_with( + room_id="!room:example.com", + user_id="@alice:example.com" ) @pytest.mark.asyncio -async def test_invite_user_failure(room_default): - room_default.bot.client.room_invite = AsyncMock() - room_default.bot.client.room_invite.side_effect = Exception("Failed to invite user") +async def test_invite_user_with_error__expect_matrix_error(room, client): + client.room_invite = AsyncMock() + client.room_invite.side_effect = Exception("User not found") with pytest.raises(MatrixError, match="Failed to invite user"): - await room_default.invite_user("@user:matrix.org") + await room.invite_user("@alice:example.com") @pytest.mark.asyncio -async def test_ban_user_success_without_reason(room_default): - room_default.bot.client.room_ban = AsyncMock() - room_default.bot.client.room_ban.return_value = None +async def test_ban_user_without_reason__expect_successful_ban(room, client): + client.room_ban = AsyncMock() + client.room_ban.return_value = None + + await room.ban_user("@spammer:example.com") - await room_default.ban_user("@user:matrix.org") - room_default.bot.client.room_ban.assert_awaited_once_with( - room_id=room_default.room_id, user_id="@user:matrix.org", reason=None + client.room_ban.assert_awaited_once_with( + room_id="!room:example.com", + user_id="@spammer:example.com", + reason=None ) @pytest.mark.asyncio -async def test_ban_user_success_with_reason(room_default): - room_default.bot.client.room_ban = AsyncMock() - room_default.bot.client.room_ban.return_value = None +async def test_ban_user_with_reason__expect_successful_ban_with_reason(room, client): + client.room_ban = AsyncMock() + client.room_ban.return_value = None - await room_default.ban_user("@user:matrix.org", "Test Ban") - room_default.bot.client.room_ban.assert_awaited_once_with( - room_id=room_default.room_id, user_id="@user:matrix.org", reason="Test Ban" + await room.ban_user("@spammer:example.com", "Spam and harassment") + + client.room_ban.assert_awaited_once_with( + room_id="!room:example.com", + user_id="@spammer:example.com", + reason="Spam and harassment" ) @pytest.mark.asyncio -async def test_ban_user_failure(room_default): - room_default.bot.client.room_ban = AsyncMock() - room_default.bot.client.room_ban.side_effect = Exception("Failed to ban user") +async def test_ban_user_with_error__expect_matrix_error(room, client): + client.room_ban = AsyncMock() + client.room_ban.side_effect = Exception("Insufficient permissions") with pytest.raises(MatrixError, match="Failed to ban user"): - await room_default.ban_user("@user:matrix.org") + await room.ban_user("@spammer:example.com") @pytest.mark.asyncio -async def test_unban_user_success(room_default): - room_default.bot.client.room_unban = AsyncMock() - room_default.bot.client.room_unban.return_value = None +async def test_unban_user__expect_successful_unban(room, client): + client.room_unban = AsyncMock() + client.room_unban.return_value = None + + await room.unban_user("@alice:example.com") - await room_default.unban_user("@user:matrix.org") - room_default.bot.client.room_unban.assert_awaited_once_with( - room_id=room_default.room_id, user_id="@user:matrix.org" + client.room_unban.assert_awaited_once_with( + room_id="!room:example.com", + user_id="@alice:example.com" ) @pytest.mark.asyncio -async def test_unban_user_failure(room_default): - room_default.bot.client.room_unban = AsyncMock() - room_default.bot.client.room_unban.side_effect = Exception("Failed to unban user") +async def test_unban_user_with_error__expect_matrix_error(room, client): + client.room_unban = AsyncMock() + client.room_unban.side_effect = Exception("User not banned") with pytest.raises(MatrixError, match="Failed to unban user"): - await room_default.unban_user("@user:matrix.org") + await room.unban_user("@alice:example.com") @pytest.mark.asyncio -async def test_kick_user_success_without_reason(room_default): - room_default.bot.client.room_kick = AsyncMock() - room_default.bot.client.room_kick.return_value = None +async def test_kick_user_without_reason__expect_successful_kick(room, client): + client.room_kick = AsyncMock() + client.room_kick.return_value = None + + await room.kick_user("@troublemaker:example.com") - await room_default.kick_user("@user:matrix.org") - room_default.bot.client.room_kick.assert_awaited_once_with( - room_id=room_default.room_id, user_id="@user:matrix.org", reason=None + client.room_kick.assert_awaited_once_with( + room_id="!room:example.com", + user_id="@troublemaker:example.com", + reason=None ) @pytest.mark.asyncio -async def test_kick_user_success_with_reason(room_default): - room_default.bot.client.room_kick = AsyncMock() - room_default.bot.client.room_kick.return_value = None +async def test_kick_user_with_reason__expect_successful_kick_with_reason(room, client): + client.room_kick = AsyncMock() + client.room_kick.return_value = None - await room_default.kick_user("@user:matrix.org", "Test Kick") - room_default.bot.client.room_kick.assert_awaited_once_with( - room_id=room_default.room_id, user_id="@user:matrix.org", reason="Test Kick" + await room.kick_user("@troublemaker:example.com", "Violating rules") + + client.room_kick.assert_awaited_once_with( + room_id="!room:example.com", + user_id="@troublemaker:example.com", + reason="Violating rules" ) @pytest.mark.asyncio -async def test_kick_user_failure(room_default): - room_default.bot.client.room_kick = AsyncMock() - room_default.bot.client.room_kick.side_effect = Exception("Failed to kick user") +async def test_kick_user_with_error__expect_matrix_error(room, client): + client.room_kick = AsyncMock() + client.room_kick.side_effect = Exception("User not in room") with pytest.raises(MatrixError, match="Failed to kick user"): - await room_default.kick_user("@user:matrix.org") + await room.kick_user("@troublemaker:example.com") + + +def test_room_properties__expect_correct_delegation_to_matrix_room(room, matrix_room): + assert room.room_id == "!room:example.com" + assert room.name == "Test Room" + assert room.display_name == "Test Room" + assert room.topic == "A test room" + assert room.member_count == 5 + assert room.encrypted is False + + +def test_room_matrix_room_property__expect_underlying_matrix_room(room, matrix_room): + assert room.matrix_room is matrix_room + + +def test_room_client_property__expect_async_client(room, client): + assert room.client is client \ No newline at end of file From 74176cf3eb92e02ff42cee322d7d58212b2918f7 Mon Sep 17 00:00:00 2001 From: penguinboi Date: Sun, 15 Feb 2026 21:20:00 -0500 Subject: [PATCH 6/7] docstring for send_help --- matrix/context.py | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/matrix/context.py b/matrix/context.py index ce950c4..c1a7d86 100644 --- a/matrix/context.py +++ b/matrix/context.py @@ -14,19 +14,9 @@ class Context: - """ - Represents the context in which a command is executed. Provides + """Represents the context in which a command is executed. Provides access to the bot instance, room and event metadata, parsed arguments, and other utilities. - - :param bot: The bot instance executing the command. - :type bot: Bot - :param room: The Matrix room where the event occurred. - :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: Room, event: Event): @@ -137,6 +127,21 @@ async def cat(ctx: Context): raise MatrixError(f"Failed to send message: {e}") async def send_help(self) -> None: + """Send help from the current command context. + + Displays help text for the current subcommand, command, or the bot's + general help menu depending on what's available in the context. The help + hierarchy is: subcommand help > command help > bot help. + + ## Example + + ```python + @bot.group() + async def config(ctx: Context): + # If user runs just "!config" with no subcommand + await ctx.send_help() + ``` + """ if self.subcommand: await self.reply(self.subcommand.help) return From 55cae804e136cf400ba9c3b038bc45e080b274f1 Mon Sep 17 00:00:00 2001 From: penguinboi Date: Sun, 15 Feb 2026 21:54:45 -0500 Subject: [PATCH 7/7] formatting --- tests/test_context.py | 8 +++----- tests/test_message.py | 12 +++--------- tests/test_room.py | 24 +++++++++--------------- 3 files changed, 15 insertions(+), 29 deletions(-) diff --git a/tests/test_context.py b/tests/test_context.py index 02245ad..91bf25b 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -146,9 +146,7 @@ async def test_reply_with_file__expect_file_message_sent(context, client): client.room_send = AsyncMock(return_value=mock_response) file = File( - filename="doc.pdf", - path="mxc://example.com/abc", - mimetype="application/pdf" + filename="doc.pdf", path="mxc://example.com/abc", mimetype="application/pdf" ) await context.reply(file=file) @@ -172,7 +170,7 @@ async def test_reply_with_image__expect_image_message_sent(context, client): path="mxc://example.com/xyz", mimetype="image/jpeg", width=800, - height=600 + height=600, ) await context.reply(image=image) @@ -224,4 +222,4 @@ async def test_send_help_without_command__expect_bot_help_executed(context): await context.send_help() - context.bot.help.execute.assert_awaited_once_with(context) \ No newline at end of file + context.bot.help.execute.assert_awaited_once_with(context) diff --git a/tests/test_message.py b/tests/test_message.py index e6585de..3b70583 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -31,12 +31,7 @@ def room(matrix_room, client): @pytest.fixture def message(room, client): - return Message( - room=room, - event_id="$event123", - body="Hello world!", - client=client - ) + return Message(room=room, event_id="$event123", body="Hello world!", client=client) @pytest.mark.asyncio @@ -119,8 +114,7 @@ async def test_delete__expect_message_redacted(message, client): await message.delete() client.room_redact.assert_awaited_once_with( - room_id="!room:example.com", - event_id="$event123" + room_id="!room:example.com", event_id="$event123" ) @@ -142,4 +136,4 @@ def test_message_properties__expect_correct_values(message, room, client): def test_message_repr__expect_formatted_string(message): result = repr(message) - assert result == "" \ No newline at end of file + assert result == "" diff --git a/tests/test_room.py b/tests/test_room.py index 3037dbb..ae61ed5 100644 --- a/tests/test_room.py +++ b/tests/test_room.py @@ -112,7 +112,7 @@ async def test_send_file__expect_file_message(room, client): file = File( filename="document.pdf", path="mxc://example.com/abc123", - mimetype="application/pdf" + mimetype="application/pdf", ) await room.send(file=file) @@ -139,7 +139,7 @@ async def test_send_image__expect_image_message_with_dimensions(room, client): path="mxc://example.com/xyz789", mimetype="image/jpeg", width=800, - height=600 + height=600, ) await room.send(image=image) @@ -177,8 +177,7 @@ async def test_invite_user__expect_successful_invitation(room, client): await room.invite_user("@alice:example.com") client.room_invite.assert_awaited_once_with( - room_id="!room:example.com", - user_id="@alice:example.com" + room_id="!room:example.com", user_id="@alice:example.com" ) @@ -199,9 +198,7 @@ async def test_ban_user_without_reason__expect_successful_ban(room, client): await room.ban_user("@spammer:example.com") client.room_ban.assert_awaited_once_with( - room_id="!room:example.com", - user_id="@spammer:example.com", - reason=None + room_id="!room:example.com", user_id="@spammer:example.com", reason=None ) @@ -215,7 +212,7 @@ async def test_ban_user_with_reason__expect_successful_ban_with_reason(room, cli client.room_ban.assert_awaited_once_with( room_id="!room:example.com", user_id="@spammer:example.com", - reason="Spam and harassment" + reason="Spam and harassment", ) @@ -236,8 +233,7 @@ async def test_unban_user__expect_successful_unban(room, client): await room.unban_user("@alice:example.com") client.room_unban.assert_awaited_once_with( - room_id="!room:example.com", - user_id="@alice:example.com" + room_id="!room:example.com", user_id="@alice:example.com" ) @@ -258,9 +254,7 @@ async def test_kick_user_without_reason__expect_successful_kick(room, client): await room.kick_user("@troublemaker:example.com") client.room_kick.assert_awaited_once_with( - room_id="!room:example.com", - user_id="@troublemaker:example.com", - reason=None + room_id="!room:example.com", user_id="@troublemaker:example.com", reason=None ) @@ -274,7 +268,7 @@ async def test_kick_user_with_reason__expect_successful_kick_with_reason(room, c client.room_kick.assert_awaited_once_with( room_id="!room:example.com", user_id="@troublemaker:example.com", - reason="Violating rules" + reason="Violating rules", ) @@ -301,4 +295,4 @@ def test_room_matrix_room_property__expect_underlying_matrix_room(room, matrix_r def test_room_client_property__expect_async_client(room, client): - assert room.client is client \ No newline at end of file + assert room.client is client